From 85296cb6fa6849a1b37ef4b456f3579fce95d164 Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Fri, 14 Aug 2026 15:07:39 -0700 Subject: [PATCH 1/3] Keep played board visible during results --- README.md | 8 +- apps/client/src/App.test.tsx | 24 ++++-- .../src/components/PhoneRoundSummary.tsx | 2 +- apps/client/src/components/RoomLobby.test.tsx | 81 ++++++++++++++----- apps/client/src/components/RoomLobby.tsx | 25 ++++-- apps/client/src/styles.css | 1 + apps/client/src/styles.test.ts | 2 +- apps/server/tests/room-store.test.ts | 3 +- apps/server/tests/server.test.ts | 7 +- docs/DEVELOPMENT_LOG.md | 7 ++ docs/PROJECT_SPEC.md | 6 +- docs/RESULTS.md | 13 +-- docs/ROUND_LIFECYCLE.md | 4 +- packages/shared/src/config.test.ts | 2 +- packages/shared/src/config.ts | 2 +- 15 files changed, 137 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 733da0b..d1fa71e 100644 --- a/README.md +++ b/README.md @@ -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 20-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 @@ -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 @@ -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 20-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. diff --git a/apps/client/src/App.test.tsx b/apps/client/src/App.test.tsx index 970e3c1..2612286 100644 --- a/apps/client/src/App.test.tsx +++ b/apps/client/src/App.test.tsx @@ -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, @@ -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' }), @@ -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.'), @@ -3029,7 +3039,11 @@ describe('Stage 4B display and player room routes', () => { expect(screen.queryByText(' (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(); diff --git a/apps/client/src/components/PhoneRoundSummary.tsx b/apps/client/src/components/PhoneRoundSummary.tsx index 5cd16e7..cda0f3b 100644 --- a/apps/client/src/components/PhoneRoundSummary.tsx +++ b/apps/client/src/components/PhoneRoundSummary.tsx @@ -63,7 +63,7 @@ export function PhoneRoundSummary({ return (

ROUND OVER

diff --git a/apps/client/src/components/RoomLobby.test.tsx b/apps/client/src/components/RoomLobby.test.tsx index 49febc6..afb3825 100644 --- a/apps/client/src/components/RoomLobby.test.tsx +++ b/apps/client/src/components/RoomLobby.test.tsx @@ -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' }); @@ -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).toBe(summary); + expect(summary.nextElementSibling).toContainElement(button); await user.click(button); expect(onReturnToLobby).toHaveBeenCalledTimes(1); }); @@ -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(); @@ -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( @@ -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', @@ -845,9 +851,40 @@ 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(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( @@ -1354,8 +1391,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(); diff --git a/apps/client/src/components/RoomLobby.tsx b/apps/client/src/components/RoomLobby.tsx index 3f3e369..2abf5a9 100644 --- a/apps/client/src/components/RoomLobby.tsx +++ b/apps/client/src/components/RoomLobby.tsx @@ -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 ? ( - ) : ( <> @@ -623,6 +632,12 @@ export function RoomLobby({ )}
+ {roundIsEnded && ( + + )} {isConnectedController && room.phase === 'LOBBY' && (
{ diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index 9ceaa5c..f6a24fb 100644 --- a/apps/client/src/styles.css +++ b/apps/client/src/styles.css @@ -2121,6 +2121,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 { diff --git a/apps/client/src/styles.test.ts b/apps/client/src/styles.test.ts index 0052744..7f0f565 100644 --- a/apps/client/src/styles.test.ts +++ b/apps/client/src/styles.test.ts @@ -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 {'), diff --git a/apps/server/tests/room-store.test.ts b/apps/server/tests/room-store.test.ts index f749f9c..ec4211c 100644 --- a/apps/server/tests/room-store.test.ts +++ b/apps/server/tests/room-store.test.ts @@ -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 += 19_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']); diff --git a/apps/server/tests/server.test.ts b/apps/server/tests/server.test.ts index a3b4614..c6d29d3 100644 --- a/apps/server/tests/server.test.ts +++ b/apps/server/tests/server.test.ts @@ -1759,7 +1759,12 @@ describe('Words Stage 4B server', () => { display, (room) => room.phase === 'LOBBY' && room.round === null, ); - now += 20_000; + now += 19_999; + lifecycleSweep?.(); + await new Promise((resolve) => setImmediate(resolve)); + expect(observedPhases).toHaveLength(broadcastCountBeforeReset); + + now += 1; lifecycleSweep?.(); const lobby = await lobbyState; await new Promise((resolve) => setImmediate(resolve)); diff --git a/docs/DEVELOPMENT_LOG.md b/docs/DEVELOPMENT_LOG.md index ff948ae..4b714d8 100644 --- a/docs/DEVELOPMENT_LOG.md +++ b/docs/DEVELOPMENT_LOG.md @@ -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 restored to 20 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 diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index 80210f5..fdd01d1 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -82,9 +82,9 @@ word count. The active TV shows authoritative accepted-word counts only; word identities and provisional scores remain private. The production UI does not show a development-stage badge. Fresh clients default to Trace while an explicit local Tap choice remains -persisted. During `ROUND_ENDED`, phones replace the board and word-entry UI -with their own authoritative final score and the winning score; detailed -results remain on the display. Official grid sizes share their presentation gap +persisted. During `ROUND_ENDED`, phones keep the exact authoritative finished +board frozen above their own final score and the winning score; word-entry UI is +removed and detailed results remain on the display. Official grid sizes share their presentation gap system, and the lobby QR board spells `WORDS` / `ATLEE` / `WANNA` / `SHARE`. Pre-round demonstrations use fixed, size-keyed presentation boards only; the 5 × 5 fourth row is `NSEVR`, and they never replace server-generated official diff --git a/docs/RESULTS.md b/docs/RESULTS.md index 305ec34..b4e90a4 100644 --- a/docs/RESULTS.md +++ b/docs/RESULTS.md @@ -181,7 +181,7 @@ not publish obsolete results. ## Timed results interface and return to lobby -`ROUND_ENDED` is a 15-second server-authoritative results window, not a fourth +`ROUND_ENDED` is a 20-second server-authoritative results window, not a fourth phase. The display replaces its header with redesigned result cards for every authoritative participant, including departed players, plus the join URL footer. Compact dark cards show final integer points, separate Words and Unique words counts, and a @@ -190,11 +190,12 @@ rather than stretching across the display. Positive-scoring rounds use authoritative competition ranks for restrained vertical podium levels; ties share a level, while all-zero rounds remain flat and do not celebrate. There is no board, QR, timer, side -bubble, control, table, or word review. Phones replace their puzzle with a -concise `ROUND OVER` / `Look at the TV!` summary showing only their own final -score and the winning score (or `No scoring winner`). Below a positive winning -score, it shows the authoritative winner name or tied winner names in result -order; they receive no administration or detailed opponent results. Result +bubble, control, table, or word review. Phones keep the authoritative finished +board as a frozen, observational grid above a concise `ROUND OVER` / `Look at +the TV!` summary showing only their own final score and the winning score (or +`No scoring winner`). Below a positive winning score, it shows the authoritative +winner name or tied winner names in result order; they receive no administration +or detailed opponent results. Result cards use the established dark Words panel treatment while retaining their centred intrinsic layout. diff --git a/docs/ROUND_LIFECYCLE.md b/docs/ROUND_LIFECYCLE.md index 69c0a50..c8f890e 100644 --- a/docs/ROUND_LIFECYCLE.md +++ b/docs/ROUND_LIFECYCLE.md @@ -43,7 +43,7 @@ An active round has `endedAt: null` and `results: null`. At or after its deadline, the server reconciles it once to `ROUND_ENDED`, sets `endedAt` to the official `deadlineAt`, and attaches one non-null finalized result projection. Ending a round does not update room activity or extend room TTL. The server then -keeps that result for a single authoritative 15-second window. The connected +keeps that result for a single authoritative 20-second window. The connected controller may also return to the lobby early. The same 250 ms lifecycle sweep changes it to `LOBBY`, clears the public round and private submission map, and increments the version once without changing activity, @@ -82,7 +82,7 @@ when nobody submitted a scoring word the all-zero round has no winner. An all-shared positive round can have tied winners. Accepted timestamps, paths, private versions, sockets, and credentials remain absent. -The room retains only its current round. After the authoritative 15-second +The room retains only its current round. After the authoritative 20-second window, the lifecycle sweep clears the ended snapshot and private submissions; the next lobby start creates the next numbered round. Only bounded highlights remain, so round history and cumulative scoring cannot grow without bound. diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index ad8c32b..511b137 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -16,7 +16,7 @@ describe('product configuration', () => { reconnectGraceSeconds: 300, roomTtlMinutes: 120, }); - expect(productConfig.resultsDisplaySeconds).toBe(15); + expect(productConfig.resultsDisplaySeconds).toBe(20); }); it('supports every planned grid size and no fixed sixteen-tile assumption', () => { diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index d07b4d2..b86df6c 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -7,7 +7,7 @@ export const productConfig = { supportedRoundDurationsSeconds: [30, 60, 90, 120, 150, 180], defaultGridSize: 5, defaultRoundDurationSeconds: 120, - resultsDisplaySeconds: 15, + resultsDisplaySeconds: 20, defaultScoringMode: 'length-plus-unique', maxPlayers: 8, maximumSubmittedWordLength: 64, From 9b5044de4b4847d8c9146184d0941b87997ff450 Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Fri, 14 Aug 2026 15:35:52 -0700 Subject: [PATCH 2/3] Refine post-round timing and action order --- README.md | 4 ++-- apps/client/src/components/RoomLobby.test.tsx | 9 ++++--- apps/client/src/components/RoomLobby.tsx | 24 +++++++++---------- apps/server/tests/room-store.test.ts | 2 +- apps/server/tests/server.test.ts | 2 +- docs/DEVELOPMENT_LOG.md | 2 +- docs/PLAYTEST_DECISIONS.md | 6 ++--- docs/RESULTS.md | 2 +- docs/ROUND_LIFECYCLE.md | 4 ++-- packages/shared/src/config.test.ts | 2 +- packages/shared/src/config.ts | 2 +- 11 files changed, 31 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d1fa71e..e64b351 100644 --- a/README.md +++ b/README.md @@ -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 20-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 @@ -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 20-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. diff --git a/apps/client/src/components/RoomLobby.test.tsx b/apps/client/src/components/RoomLobby.test.tsx index afb3825..45b197d 100644 --- a/apps/client/src/components/RoomLobby.test.tsx +++ b/apps/client/src/components/RoomLobby.test.tsx @@ -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( @@ -421,8 +421,8 @@ describe('RoomLobby word entry', () => { }); const summary = screen.getByRole('region', { name: 'Look at the TV!' }); expect(board).toHaveClass('panel', 'board-panel'); - expect(board.nextElementSibling).toBe(summary); - expect(summary.nextElementSibling).toContainElement(button); + expect(board.nextElementSibling).toContainElement(button); + expect(button.closest('.round-action')?.nextElementSibling).toBe(summary); await user.click(button); expect(onReturnToLobby).toHaveBeenCalledTimes(1); }); @@ -883,6 +883,9 @@ describe('RoomLobby word entry', () => { ]); 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('timer')).toBeNull(); diff --git a/apps/client/src/components/RoomLobby.tsx b/apps/client/src/components/RoomLobby.tsx index 2abf5a9..e476ae9 100644 --- a/apps/client/src/components/RoomLobby.tsx +++ b/apps/client/src/components/RoomLobby.tsx @@ -632,6 +632,18 @@ export function RoomLobby({ )} + {isConnectedController && roundIsEnded && ( +
+ +
+ )} {roundIsEnded && ( )} - {isConnectedController && roundIsEnded && ( -
- -
- )} {showControllerAdministration && ( { const ended = store.getRoomState('ABC234'); expect(ended?.phase).toBe('ROUND_ENDED'); const version = ended?.stateVersion; - now += 19_999; + now += 29_999; expect(store.advanceDueRounds()).toEqual([]); expect(store.getRoomState('ABC234')?.phase).toBe('ROUND_ENDED'); expect(store.getRoomState('ABC234')?.round).not.toBeNull(); diff --git a/apps/server/tests/server.test.ts b/apps/server/tests/server.test.ts index c6d29d3..97f23c3 100644 --- a/apps/server/tests/server.test.ts +++ b/apps/server/tests/server.test.ts @@ -1759,7 +1759,7 @@ describe('Words Stage 4B server', () => { display, (room) => room.phase === 'LOBBY' && room.round === null, ); - now += 19_999; + now += 29_999; lifecycleSweep?.(); await new Promise((resolve) => setImmediate(resolve)); expect(observedPhases).toHaveLength(broadcastCountBeforeReset); diff --git a/docs/DEVELOPMENT_LOG.md b/docs/DEVELOPMENT_LOG.md index 4b714d8..faaa27c 100644 --- a/docs/DEVELOPMENT_LOG.md +++ b/docs/DEVELOPMENT_LOG.md @@ -4,7 +4,7 @@ - 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 restored to 20 seconds; the +- 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) diff --git a/docs/PLAYTEST_DECISIONS.md b/docs/PLAYTEST_DECISIONS.md index 4a1dedc..a472c4d 100644 --- a/docs/PLAYTEST_DECISIONS.md +++ b/docs/PLAYTEST_DECISIONS.md @@ -42,7 +42,7 @@ display-only rounding. ## TV result presentation -- A TV-only dedicated result presentation lasts 20 seconds. +- A TV-only dedicated result presentation lasts 30 seconds. - During it, the board and QR code disappear; every competitor has an individual card or column, and every tied winner receives a crown. - It will show integer points, accepted-word count, unique-word count, and up @@ -56,10 +56,10 @@ display-only rounding. player's final score and winning score. A positive winner name or all tied winner names appear beneath that score in authoritative result order; a zero-score round retains `No scoring winner`. Detailed results remain TV-only. -- During the 20-second `ROUND_ENDED` presentation, phones will not +- During the 30-second `ROUND_ENDED` presentation, phones will not show game-host controls. After the authoritative return to `LOBBY`, Game Settings and game-host delegation reappear for the current controller only. -- After 20 seconds, the server will authoritatively return to `LOBBY`, using +- After 30 seconds, the server will authoritatively return to `LOBBY`, using the existing `ROUND_ENDED` lifecycle rather than adding a fourth phase. ## Implemented phone information architecture diff --git a/docs/RESULTS.md b/docs/RESULTS.md index b4e90a4..9f1d67a 100644 --- a/docs/RESULTS.md +++ b/docs/RESULTS.md @@ -181,7 +181,7 @@ not publish obsolete results. ## Timed results interface and return to lobby -`ROUND_ENDED` is a 20-second server-authoritative results window, not a fourth +`ROUND_ENDED` is a 30-second server-authoritative results window, not a fourth phase. The display replaces its header with redesigned result cards for every authoritative participant, including departed players, plus the join URL footer. Compact dark cards show final integer points, separate Words and Unique words counts, and a diff --git a/docs/ROUND_LIFECYCLE.md b/docs/ROUND_LIFECYCLE.md index c8f890e..ab5d741 100644 --- a/docs/ROUND_LIFECYCLE.md +++ b/docs/ROUND_LIFECYCLE.md @@ -43,7 +43,7 @@ An active round has `endedAt: null` and `results: null`. At or after its deadline, the server reconciles it once to `ROUND_ENDED`, sets `endedAt` to the official `deadlineAt`, and attaches one non-null finalized result projection. Ending a round does not update room activity or extend room TTL. The server then -keeps that result for a single authoritative 20-second window. The connected +keeps that result for a single authoritative 30-second window. The connected controller may also return to the lobby early. The same 250 ms lifecycle sweep changes it to `LOBBY`, clears the public round and private submission map, and increments the version once without changing activity, @@ -82,7 +82,7 @@ when nobody submitted a scoring word the all-zero round has no winner. An all-shared positive round can have tied winners. Accepted timestamps, paths, private versions, sockets, and credentials remain absent. -The room retains only its current round. After the authoritative 20-second +The room retains only its current round. After the authoritative 30-second window, the lifecycle sweep clears the ended snapshot and private submissions; the next lobby start creates the next numbered round. Only bounded highlights remain, so round history and cumulative scoring cannot grow without bound. diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index 511b137..5fe4ec4 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -16,7 +16,7 @@ describe('product configuration', () => { reconnectGraceSeconds: 300, roomTtlMinutes: 120, }); - expect(productConfig.resultsDisplaySeconds).toBe(20); + expect(productConfig.resultsDisplaySeconds).toBe(30); }); it('supports every planned grid size and no fixed sixteen-tile assumption', () => { diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index b86df6c..8112f89 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -7,7 +7,7 @@ export const productConfig = { supportedRoundDurationsSeconds: [30, 60, 90, 120, 150, 180], defaultGridSize: 5, defaultRoundDurationSeconds: 120, - resultsDisplaySeconds: 20, + resultsDisplaySeconds: 30, defaultScoringMode: 'length-plus-unique', maxPlayers: 8, maximumSubmittedWordLength: 64, From 34866de80d29acc2d415d7fe79ee0d40a12b64bf Mon Sep 17 00:00:00 2001 From: Kevin Atlee Date: Fri, 14 Aug 2026 15:51:09 -0700 Subject: [PATCH 3/3] Top-align display results --- apps/client/src/styles.css | 4 +++- apps/client/src/styles.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index f6a24fb..517dd90 100644 --- a/apps/client/src/styles.css +++ b/apps/client/src/styles.css @@ -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, diff --git a/apps/client/src/styles.test.ts b/apps/client/src/styles.test.ts index 7f0f565..736ce1e 100644 --- a/apps/client/src/styles.test.ts +++ b/apps/client/src/styles.test.ts @@ -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, );