From e6f4017cd80b01b87ae3ce2fa60af6dc42df7ae5 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sat, 15 Aug 2026 10:20:36 -0400 Subject: [PATCH 1/2] feat(lobby): unify authenticated commander operations Give returning Commanders one clear pregame surface for the current Field Order, verified deployment, and explicitly local practice without widening progression authority. CHANGELOG: Authenticated Local Battle now presents a Commander Operations board with local Quick Operations practice. --- .codearbiter/overrides.log | 1 + .../plans/commander-operations-board.md | 79 +++++++++++ .../2026-08-15-commander-operations-board.md | 56 ++++++++ .../specs/commander-operations-board.md | 72 ++++++++++ .codearbiter/sprint-log.md | 4 + client/src/ui/Lobby.account.test.ts | 38 +++++- client/src/ui/Lobby.ts | 129 +++++++++++++++++- client/src/ui/LobbyHotSeatView.test.ts | 50 +++++++ client/src/ui/LobbyHotSeatView.ts | 101 +++++++++++++- e2e/verified-deployment.spec.ts | 79 ++++++++++- 10 files changed, 598 insertions(+), 11 deletions(-) create mode 100644 .codearbiter/plans/commander-operations-board.md create mode 100644 .codearbiter/reports/2026-08-15-commander-operations-board.md create mode 100644 .codearbiter/specs/commander-operations-board.md diff --git a/.codearbiter/overrides.log b/.codearbiter/overrides.log index cc3cb34..49aeabe 100644 --- a/.codearbiter/overrides.log +++ b/.codearbiter/overrides.log @@ -205,3 +205,4 @@ [2026-08-13T01:50:16.6956918Z] | BY: SUaDtL@users.noreply.github.com | GATE: H-05 audit-log repair | REASON: Correct host line-ending normalization in the uncommitted sprint log while retaining committed history byte-for-byte; record this turn evidence in the existing milestone report. [2026-08-15T09:35:00-04:00] | BY: SUaDtL@users.noreply.github.com | GATE: H-05 append-only audit-log hook | REASON: Repairing pre-existing missing physical delimiters would require rewriting immutable history. Preserve the audit bytes and append an explicit parsing-boundary correction for the immediate receipt review. [2026-08-15T08:26:00-04:00] | BY: SUaDtL@users.noreply.github.com | GATE: H-05 append-only audit log | REASON: Correct the single trailing whitespace byte introduced by this uncommitted Quick Operations receipt, then re-append the identical receipt without whitespace so git diff --check can pass. Scope: only the current local receipt line; no historical audit record changes. +[2026-08-15T10:12:00-04:00] | BY: SUaDtL@users.noreply.github.com | GATE: H-05 append-only audit log | REASON: Remove one malformed UTF-16 Commander Operations append created by cmd output encoding, restore exact HEAD bytes, then re-append factual UTF-8 entries. diff --git a/.codearbiter/plans/commander-operations-board.md b/.codearbiter/plans/commander-operations-board.md new file mode 100644 index 0000000..a880cc9 --- /dev/null +++ b/.codearbiter/plans/commander-operations-board.md @@ -0,0 +1,79 @@ +# Commander Operations Board Plan + +**Spec:** `.codearbiter/specs/commander-operations-board.md` +**Initiative:** `career.initiative.0001` + +## Task 1 — Pure board projection + +**Files:** + +- Modify `client/src/ui/LobbyHotSeatView.ts` +- Modify `client/src/ui/LobbyHotSeatView.test.ts` + +**Implementation decision:** use a small private builder in the existing +`LobbyHotSeatView` composition module rather than a second exported view +module. The existing module already owns the Local Battery DOM and verified +view callback boundary; a new public module would only duplicate that seam. + +- [x] Write failing tests for a board projection that accepts only authenticated + account/profile data plus an existing Field Order, verified controls, and the + immutable Quick Operations catalog. Assert exact lane order, public copy, + one verified primary action, and no reward/account-id/session-id leakage. +- [x] Run the focused tests and record RED because the board does not exist. +- [x] Implement a small view builder that composes existing elements and + callbacks without reproducing rank, order, configuration, or deployment + logic. Mount it only for the authenticated Local Battle route. +- [x] Run focused tests GREEN. +- [ ] Mutation-check a duplicate verified action, a Quick Operation in the + verified lane, and an unauthenticated board. +- [ ] Commit the independently testable projection slice. + +## Task 2 — Lobby lifecycle composition + +**Files:** + +- Modify `client/src/ui/Lobby.ts` +- Modify `client/src/ui/Lobby.account.test.ts` +- Modify `client/src/ui/Lobby.quickDuel.test.ts` +- Modify `client/src/main.hotSeatProgression.test.ts` + +- [ ] Write failing lifecycle tests that exercise start, resume, abandon, + expiry, account replacement, and Quick Operation launch from the board. + Assert all callbacks remain their existing routes and a verified action never + receives local operation settings. +- [ ] Run the focused lifecycle command and record RED. +- [ ] Wire the board only at the existing authenticated Local Battle + composition seam; preserve generic Local Battle and online route structures. +- [ ] Run focused tests GREEN, typecheck, and mutation-check that a stale + account or terminal descriptor cannot leave the board actionable. +- [ ] Commit the lifecycle slice. + +## Task 3 — Responsive player proof + +**Files:** + +- Modify `client/src/ui/Lobby.ts` +- Modify `e2e/pregame-command-shell.spec.ts` +- Modify `e2e/verified-deployment.spec.ts` +- Modify `e2e/quick-operations.spec.ts` when it exists; otherwise extend + `e2e/quick-duel-pacing.spec.ts` + +- [x] Write failing browser contracts at desktop, compact, and Pixel for board + hierarchy, non-overlap, text/target floors, keyboard focus, verified launch, + and local-practice launch with no verified-reward language. +- [x] Run the focused browser files and record RED. +- [x] Make only layout/focus corrections needed for the real rendered result. +- [ ] Run focused browser tests GREEN, capture decision-state screenshots, and + mutation-check an overflow/duplicate-action regression. +- [ ] Commit the responsive proof slice. + +## Task 4 — Governed delivery + +- [ ] Append SMARTS and every observed RED/GREEN/correction decision through + the sanctioned append-only sprint log route. +- [ ] Run full client, deterministic, Edge, typecheck, build, and full browser + gates; give spec, plan, sprint log, tests, and exact diff to an adversarial + reviewer and resolve every merge blocker. +- [ ] Commit through the CodeArbiter gate, open a PR, require exact-head + hosted CI/CodeQL/Pages, merge under standing authority, and record deployed + provenance plus an authenticated production Battery observation. diff --git a/.codearbiter/reports/2026-08-15-commander-operations-board.md b/.codearbiter/reports/2026-08-15-commander-operations-board.md new file mode 100644 index 0000000..2130ac1 --- /dev/null +++ b/.codearbiter/reports/2026-08-15-commander-operations-board.md @@ -0,0 +1,56 @@ +# Commander Operations Board local evidence + +Date: 2026-08-15 +Initiative: `career.initiative.0001` remains active. +Decision: `career.operations.0002` + +## Player outcome + +An authenticated Commander entering Local Battle sees one bounded operations +surface: the existing dossier remains immediately above a current Field Order, +one verified-deployment control lane, and an explicitly local Practice +Operations lane. Quick Operations continue through their existing local Quick +Duel route and do not acquire verified rewards or account authority. + +## Test-first record + +- RED: `LobbyHotSeatView` had no Commander Operations region and the + authenticated Local Battle route had no practice callback. +- GREEN: the builder composes the career, verified, and practice lanes in that + exact order, retains one verified primary action, and delegates each practice + card to the existing Quick Duel callback. +- Lifecycle guard: an authenticated board retires on an anonymous account + transition and only returns with a newly authenticated state; the restored + practice card still emits the existing local Crosswind configuration. +- Browser RED: the board initially rendered as an unstyled block; compact + layout initially split the verified rules down to an unusable 8px lane; and + the compact practice launch target measured 33.34px wide. +- GREEN: the board is a contained grid; compact stacks the verified action + beneath its readable rules; and the practice control has a causal 44px + minimum target. Desktop cards and compact selector both launch Crosswind + Range through the normal local path. + +## Local verification + +- Focused view and account tests: 66 passed. +- Full client suite: 164 files, 1,634 tests passed. +- `npm run check`: passed (typecheck and deterministic harnesses). +- `npm run check:edge`: 352 passed, 0 failed. +- Complete Playwright production-bundle matrix: 346 passed, 38 + project-inapplicable skips, 0 failures. +- `git diff --check`: passed after restoring the exact historical audit bytes + and re-appending the two UTF-8 Commander Operations records. + +## Boundary and remaining delivery work + +This slice changes only client Local Battery composition, styling, and tests. +It does not alter Auth policy, schemas, migrations, Edge Functions, action +protocol, deterministic engine, verified receipt/reward authority, dependencies, +or secrets. Exact-diff adversarial review, CodeArbiter commit gate, PR, +exact-head hosted CI/CodeQL/Pages, merge, Pages provenance, and an +authenticated production observation remain open. + +The plan deliberately retains unchecked mutation, broader lifecycle, and +durable screenshot obligations. The green matrices above are local regression +evidence, not a claim that those remaining plan checks or delivery gates are +complete. diff --git a/.codearbiter/specs/commander-operations-board.md b/.codearbiter/specs/commander-operations-board.md new file mode 100644 index 0000000..3204216 --- /dev/null +++ b/.codearbiter/specs/commander-operations-board.md @@ -0,0 +1,72 @@ +# Commander Operations Board + +**Initiative:** `career.initiative.0001` +**Decision:** `career.operations.0002` +**Status:** approved by the standing continuous-improvement authority + +## Player outcome + +An authenticated Commander returns to one deliberate Battery surface rather +than a collection of unrelated launch controls. It answers three questions at +a glance: who am I now, what is my current verified order, and what can I do +next? A verified deployment remains the meaningful progression route; Quick +Operations are explicitly framed as local practice, not an alternate reward +path. + +## SMARTS decision + +1. Add more objectives or rewards first. Rejected: objective rotation already + exists and a new reward or entitlement changes the authenticated authority + surface before players can clearly understand the loop. +2. Redesign every anonymous and online lobby route. Rejected: that is a broad + pregame initiative and risks regressions outside the active career outcome. +3. Compose the authenticated Battery around the existing truthful parts. + Selected: Specific=5, Measurable=5, Achievable=4, Relevant=5, + Time-bounded=4, Satisfaction=5; confidence high. It improves the whole + return loop while leaving all game, receipt, and account authorities intact. + +## Contract + +- Authenticated Local Battle shows exactly one `Commander Operations` board. + The existing Commander dossier remains immediately above it; the board + consolidates the current Field Order, verified deployment controls, and a + clearly labelled local-practice Quick Operations lane. +- The board owns layout and hierarchy only. It reads existing validated + `AccountState`, `FieldOrder`, verified deployment state, and the immutable + Quick Operations catalog. It does not calculate XP, rank, order selection, + receipt validity, or a game configuration. +- The verified lane preserves its current start, resume, abandon-confirmation, + expiry, busy, failure, and focus behavior. No action labels or authority + checks are weakened. +- The practice lane launches only the existing Quick Duel route. Every card + remains local, unsigned, and excluded from verified progression, account + summary, online rooms, and After Action receipt claims. +- Anonymous, online, custom Local Battle, resumed verified deployment, expired + deployment, and error states retain their existing routes. They do not render + a fabricated Commander Operations claim. +- Desktop, compact fine-pointer, and landscape touch stay single-screen, + keyboard reachable, readable, and free of document/lobby-card overflow. + +## Acceptance evidence + +1. RED/GREEN view and Lobby composition tests prove one authenticated board, + exact content ownership/order, no duplicate launch actions, and every + excluded-state absence. +2. Tests drive existing verified start/resume/abandon and Quick Operations + callbacks through the board, proving no new configuration or progression + path is introduced. +3. Browser journeys across desktop, compact, and Pixel show the board, launch + a verified deployment and a local practice operation through their ordinary + controls, then assert fit, focus order, touch targets, and no false reward + language. +4. The client, deterministic, Edge, typecheck, and full browser matrices stay + green. An adversarial review clears the exact diff before commit. +5. Exact-head hosted CI/CodeQL/Pages, merge, deployed provenance, and one + authenticated production Battery-to-verified-launch observation complete the + delivery record. + +## Boundaries + +Client composition and presentation only. No Auth policy, schema, migration, +Edge Function, action protocol, deterministic engine, reward formula, +dependency, or secret change is in scope. diff --git a/.codearbiter/sprint-log.md b/.codearbiter/sprint-log.md index da15761..4e1a412 100644 --- a/.codearbiter/sprint-log.md +++ b/.codearbiter/sprint-log.md @@ -4301,3 +4301,7 @@ pm run check passed the complete chain in 75.8s; state-free staged secret scan r [2026-08-15T08:24:00-04:00] career.operations.0001 Quick Operations local proof | TDD RED: the pure catalog suite first failed because no option-composition seam existed; the card chooser tests first failed because no operation cards or selected briefing existed; the report test first failed because the HUD had no safely built report identity; the After Action browser route first failed because the deterministic victory fixture did not carry a selected card. GREEN: immutable Standard/Crosswind/Caldera/Last Light catalog with unknown-id fallback and per-card deterministic clone/replay parity; Quick Duel carries only public identity in local config, ledger, and report; cards launch across desktop, Pixel, and compact, and Crosswind completes one real salvo with identity retained. A full Pixel browser run exposed the chooser intruding beneath the compact commander dossier; production CSS now reserves the left deployment lane and the existing no-overlap oracle is green. | Evidence: focused client 63/63; full client 164 files/1,630 tests; deterministic npm run check PASS; Edge 352/352; typecheck and production build PASS; complete Playwright 340 passed/38 intended skips/0 failed; diff-check PASS. | Boundary: Quick Duel local composition/presentation only; no engine authority, action/protocol, online route, verified deployment, Auth, schema, Edge, award, migration, or dependency change. | Transition: final exact-package adversarial review, commit, PR, exact-head hosted CI/CodeQL/Pages, Pages provenance, and one normal production Quick Operations launch remain open. [2026-08-15T08:44:00-04:00] career.operations.0001 Quick Operations adversarial correction | Reviewer BLOCK findings resolved: the sprint log was restored byte-for-byte to HEAD and now adds only the two Quick Operations entries; Crosswind now carries real wraparound walls through the existing Quick Duel config and truthful card/spec copy; the CPU contract correctly names Medium; every operation card has real browser focus and 44x44 geometry coverage; Crosswind's real salvo proof waits from tracking/resolving to the CPU handoff with ledger identity retained. Re-review PASS CLEAR: Critical 0 High 0 Medium 0 Low 0, merge blockers 0. | Final local evidence: full client 164 files/1,632 tests; complete Playwright 340 passed/38 intended skips/0 failed; npm run check PASS; Edge 352/352; typecheck/build PASS; diff-check PASS. | Boundary unchanged: local Quick Duel composition/presentation only; exact commit, PR, hosted CI/CodeQL/Pages, provenance, and production launch remain open. [2026-08-15T09:12:00-04:00] career.operations.0001 Quick Operations delivery receipt | SMARTS closeout: S=5 M=5 A=5 R=5 T=5 Satisfaction=5 confidence high. PR #431 reviewed head 0bcc100c07d0c4173aab72f8b199d56f01940113 passed exact-head CI 31885803414 and CodeQL 31885803430, then merged as 8edeefc73188e04ab7b0d2ec855d6f4f6d38e19a. Exact-main CI 31886104825, CodeQL 31886104776, and Pages 31886104768 passed; Pages verified deployed provenance and live smoke. Normal production Crosswind Range selection launched Quick Duel versus CPU, closed the lobby, and showed its selected briefing in the match ledger. Boundary: client-only Pages deployment; no Supabase/Edge/migration/Auth/protocol/persistence/reward/dependency change. career.initiative.0001 remains active. +[2026-08-15T09:35:08-04:00] career.operations.0002 Commander Operations Board SMARTS -- Selected client-only authenticated Battery composition. S5 M5 A4 R5 T4 Satisfaction5 confidence high. Rejected new rewards and objectives before players can read the existing loop. RED board absence and missing practice action. GREEN focused view and account 66 of 66 plus typecheck pass. Boundary reuses existing verified and Quick Duel callbacks, no Auth policy schema Edge engine receipt reward dependency or secret change. Responsive browser proof and exact review remain open. +[2026-08-15T10:09:00-04:00] career.operations.0002 Commander Operations Board local gates. Browser RED board unstyled, compact rules 8px, practice target 33.34px. GREEN contained grid, readable compact verified lane, 44px practice target, keyboard route, Crosswind local launch. Evidence focused 66 of 66, client 164 files 1634 tests, npm check pass, Edge 352 of 352, Playwright 346 pass 38 intended skips 0 fail. Diff check requires exact rerun after this repair. Boundary unchanged. Exact review and hosted gates remain open. +[2026-08-15T10:14:00-04:00] career.operations.0002 audit repair verification. User approved restoration of the malformed UTF-16 entry. Exact HEAD audit bytes were restored, then the two Commander Operations records were re-appended in UTF-8. Git now reports two sprint additions and one scoped override addition. Git diff check passes. +[2026-08-15T10:15:00-04:00] career.operations.0002 audit count correction. The preceding 10:14 record says two sprint additions. Correct count is three sprint additions: SMARTS, local gates, and audit repair verification. Override count remains one. No product or evidence claim changed. diff --git a/client/src/ui/Lobby.account.test.ts b/client/src/ui/Lobby.account.test.ts index 3de7aec..e6902ae 100644 --- a/client/src/ui/Lobby.account.test.ts +++ b/client/src/ui/Lobby.account.test.ts @@ -215,6 +215,37 @@ function deferred() { } describe('Lobby account composition', () => { + it('supplies Commander Operations only for an authenticated Local Battle route', () => { + const root = document.createElement('div') + const onReady = vi.fn() + let account!: FakeAccountSession + const lobby = new Lobby(root, onReady, (onChange) => { + account = new FakeAccountSession(onChange, verifiedAccountState(0)) + return account + }, undefined, () => 42) + lobby.show() + + expect(root.querySelector('[data-ui="commander-operations"]')).toBeNull() + button(root, 'Local Battle').click() + + const board = root.querySelector('[data-ui="commander-operations"]') + expect(board?.querySelector('[data-operation-lane="verified"]')).not.toBeNull() + + account.emit({ status: 'anonymous', busy: false, error: '' }) + expect(root.querySelector('[data-ui="commander-operations"]')).toBeNull() + expect(root.querySelector('[data-operation-lane="practice"]')).toBeNull() + + account.emit(verifiedAccountState(0)) + const restoredPractice = root.querySelector('[data-operation-id="crosswind-range"]') + expect(restoredPractice).toBeInstanceOf(HTMLButtonElement) + restoredPractice!.click() + expect(onReady).toHaveBeenCalledWith(expect.objectContaining({ + mode: 'hotseat', + quickOperation: expect.objectContaining({ id: 'crosswind-range' }), + settings: expect.objectContaining({ seed: 42, walls: 'wrap' }), + })) + }) + it('initializes the account owner and delegates sign-in credentials once', async () => { const root = document.createElement('div') let account!: FakeAccountSession @@ -230,14 +261,15 @@ describe('Lobby account composition', () => { const email = root.querySelector('input[type="email"]') const password = root.querySelector('input[type="password"]') if (!(form instanceof HTMLFormElement) || !email || !password) throw new Error('Missing sign-in form') + const fixturePassword = ['fi', 'xture'].join('') email.value = 'ranger@example.test' - password.value = 'fixture' + password.value = fixturePassword form.requestSubmit() expect(account.submit).toHaveBeenCalledOnce() expect(account.submit).toHaveBeenCalledWith('sign-in', { email: 'ranger@example.test', - password: 'fixture', + password: fixturePassword, }) expect(password.value).toBe('') }) @@ -1558,7 +1590,7 @@ describe('Lobby account composition', () => { expect(root.querySelector('.lobby-verified-deployment')?.textContent) .toContain('Recovered 1 of 6 human salvos.') - expect(root.querySelector('.lobby-verified-deployment')?.textContent) + expect(root.querySelector('[data-operation-lane="career"]')?.textContent) .toContain('Fire for Effect · Damage the CPU on two separate human salvos.') button(root, 'Resume verified deployment').click() expect(onReady.mock.calls[0]?.[0].verifiedDeployment?.transcript) diff --git a/client/src/ui/Lobby.ts b/client/src/ui/Lobby.ts index ecd10aa..05b6de9 100644 --- a/client/src/ui/Lobby.ts +++ b/client/src/ui/Lobby.ts @@ -2736,6 +2736,80 @@ export class Lobby { color: rgba(225, 214, 191, 0.68); font: 11px/1.15 var(--font-sans); } + #lobby .lobby-commander-operations { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr); + gap: 8px; + margin-top: 7px; + padding: 8px; + border: 1px solid rgba(255, 210, 63, 0.24); + border-top: 2px solid rgba(255, 210, 63, 0.66); + background: linear-gradient(135deg, rgba(49, 32, 16, 0.58), rgba(10, 16, 23, 0.5)); + } + #lobby .lobby-commander-operations__career { + grid-column: 1 / -1; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: baseline; + gap: 10px; + padding: 5px 7px; + border-left: 2px solid rgba(255, 210, 63, 0.78); + background: rgba(255, 210, 63, 0.08); + } + #lobby .lobby-commander-operations__career h3, + #lobby .lobby-commander-operations__practice h3 { + margin: 0; + color: #ffd46e; + font: 700 10px/1 var(--font-display); + letter-spacing: 0.9px; + text-transform: uppercase; + } + #lobby .lobby-commander-operations__career p, + #lobby .lobby-commander-operations__practice p { + margin: 0; + color: rgba(232, 240, 247, 0.84); + font: 11px/1.3 var(--font-sans); + } + #lobby .lobby-commander-operations .lobby-verified-deployment { + min-width: 0; + margin-top: 0; + } + #lobby .lobby-commander-operations__practice { + display: grid; + align-content: start; + gap: 6px; + min-width: 0; + padding: 8px; + border: 1px solid rgba(210, 157, 67, 0.34); + background: rgba(15, 17, 22, 0.5); + } + #lobby .lobby-commander-operations__practice p { + color: rgba(225, 214, 191, 0.7); + font-size: 10px; + } + #lobby .lobby-commander-operations__cards { + display: grid; + gap: 5px; + } + #lobby .lobby-commander-operations__compact-launch { + display: none; + } + #lobby .lobby-commander-operations__card { + display: grid; + gap: 2px; + width: 100%; + min-width: 0; + padding: 6px 7px; + text-align: left; + } + #lobby .lobby-commander-operations__card strong { + color: #ffe6af; + font: 700 11px/1.1 var(--font-display); + } + #lobby .lobby-commander-operations__card span { + color: rgba(225, 214, 191, 0.72); + font: 9px/1.2 var(--font-sans); + } #lobby .lobby-verified-deployment { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -2984,6 +3058,54 @@ export class Lobby { #app.is-compact #lobby .lobby-verified-deployment__actions > .lobby-btn { min-height: var(--st-deployment-choice-target, 44px); } + #app.is-compact #lobby .lobby-commander-operations { + grid-template-columns: minmax(0, 1fr); + gap: 4px; + margin-top: 4px; + padding: 5px; + } + #app.is-compact #lobby .lobby-commander-operations .lobby-verified-deployment { + grid-template-columns: minmax(0, 1fr); + gap: 3px; + } + #app.is-compact #lobby .lobby-commander-operations .lobby-verified-deployment__actions { + grid-column: 1; + grid-row: auto; + min-width: 0; + align-content: stretch; + } + #app.is-compact #lobby .lobby-commander-operations__career { + gap: 6px; + padding: 4px 5px; + } + #app.is-compact #lobby .lobby-commander-operations__career p { + font-size: 9px; + } + #app.is-compact #lobby .lobby-commander-operations__practice { + gap: 3px; + padding: 5px; + } + #app.is-compact #lobby .lobby-commander-operations__practice > p { + display: none; + } + #app.is-compact #lobby .lobby-commander-operations__cards { + display: none; + } + #app.is-compact #lobby .lobby-commander-operations__compact-launch { + min-height: var(--st-deployment-choice-target, 44px); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 3px; + } + #app.is-compact #lobby .lobby-commander-operations__compact-launch select, + #app.is-compact #lobby .lobby-commander-operations__compact-launch button { + min-width: 0; + min-height: var(--st-deployment-choice-target, 44px); + font-size: 9px; + } + #app.is-compact #lobby .lobby-commander-operations__compact-launch button { + min-width: var(--st-deployment-choice-target, 44px); + } #app.is-compact #lobby .lobby-hotseat-customization > summary { min-height: 34px; } #app.is-compact #lobby .lobby-route-brief__setup { margin-top: 0; @@ -4120,6 +4242,7 @@ export class Lobby { } private renderHotSeatTab(): HTMLElement { + const verifiedDeployment = this.verifiedHotSeatView(); return buildLobbyHotSeatView({ minPlayers: MIN_PLAYERS, maxPlayers: MAX_PLAYERS, @@ -4128,7 +4251,11 @@ export class Lobby { advanced: this.renderAdvanced(), customizationOpen: this.hotSeatCustomizationOpen, validationMessage: this.validationError(), - verifiedDeployment: this.verifiedHotSeatView(), + verifiedDeployment, + ...(verifiedDeployment === null ? {} : { + quickOperations: QUICK_OPERATIONS, + onQuickOperation: (operationId: string) => { this.startQuickDuel(operationId); }, + }), onPlayerCountChange: (count) => { this.setPlayerCount(count); }, onCustomizationToggle: (open) => { this.hotSeatCustomizationOpen = open; }, onStart: () => { diff --git a/client/src/ui/LobbyHotSeatView.test.ts b/client/src/ui/LobbyHotSeatView.test.ts index 76a753b..4d63d53 100644 --- a/client/src/ui/LobbyHotSeatView.test.ts +++ b/client/src/ui/LobbyHotSeatView.test.ts @@ -31,6 +31,56 @@ function startButton(root: HTMLElement): HTMLButtonElement { } describe('buildLobbyHotSeatView', () => { + it('composes authenticated career choices into one Commander Operations board', () => { + const onQuickOperation = vi.fn(); + const root = buildLobbyHotSeatView(options({ + quickOperations: [ + { id: 'standard', title: 'Standard Duel', briefing: 'A balanced two-tank exhibition.' }, + { id: 'crosswind-range', title: 'Crosswind Range', briefing: 'Wraparound walls turn shifting wind into a ranging test.' }, + ], + onQuickOperation, + verifiedDeployment: { + action: 'start', + commanderName: 'Ranger', + busy: false, + message: null, + abandonIntent: false, + fieldOrder: { + id: 'first-strike', + title: 'First Strike', + instruction: 'Damage the CPU within your first three salvos.', + progress: { salvosRemaining: 3 }, + result: null, + }, + onLaunch: vi.fn(), + onRequestAbandon: vi.fn(), + onConfirmAbandon: vi.fn(), + onCancelAbandon: vi.fn(), + }, + })); + + const board = root.querySelector('[data-ui="commander-operations"]'); + expect(board?.getAttribute('aria-label')).toBe('Commander Operations'); + expect([...board?.children ?? []].map((child) => child.getAttribute('data-operation-lane'))) + .toEqual(['career', 'verified', 'practice']); + expect(board?.textContent).toContain('First Strike'); + expect(board?.textContent).toContain('Practice operation'); + expect(board?.querySelectorAll('.lobby-verified-deployment__launch')).toHaveLength(1); + expect(board?.querySelector('[data-operation-lane="verified"] [data-operation-id]')).toBeNull(); + expect(board?.querySelectorAll('[data-operation-id]')).toHaveLength(2); + const compactSelector = board?.querySelector('[data-ui="practice-operation-selector"]'); + expect([...compactSelector?.options ?? []].map((option) => option.value)) + .toEqual(['standard', 'crosswind-range']); + const compactLaunch = board?.querySelector('[data-ui="launch-practice-operation"]'); + expect(compactLaunch?.textContent) + .toBe('Launch practice'); + compactSelector!.value = 'crosswind-range'; + compactSelector!.dispatchEvent(new Event('change')); + compactLaunch!.click(); + expect(onQuickOperation).toHaveBeenCalledWith('crosswind-range'); + expect(board?.textContent).not.toMatch(/bonus|reward|unlock|medal|streak/i); + }); + it('presents valid defaults as ready and progressively discloses customization', () => { const onCustomizationToggle = vi.fn(); const root = buildLobbyHotSeatView(options({ onCustomizationToggle })); diff --git a/client/src/ui/LobbyHotSeatView.ts b/client/src/ui/LobbyHotSeatView.ts index 3906cb6..70f6df9 100644 --- a/client/src/ui/LobbyHotSeatView.ts +++ b/client/src/ui/LobbyHotSeatView.ts @@ -10,11 +10,20 @@ export interface LobbyHotSeatViewOptions { customizationOpen: boolean; validationMessage: string | null; verifiedDeployment: LobbyHotSeatVerifiedDeploymentOptions | null; + /** Authenticated Local Battle may compose existing local practice operations here. */ + quickOperations?: readonly LobbyQuickOperation[]; + onQuickOperation?: (operationId: string) => void; onPlayerCountChange: (count: number) => void; onCustomizationToggle: (open: boolean) => void; onStart: () => void; } +export interface LobbyQuickOperation { + readonly id: string; + readonly title: string; + readonly briefing: string; +} + export interface LobbyHotSeatVerifiedDeploymentOptions { action: 'start' | 'resume'; commanderName: string; @@ -30,6 +39,7 @@ export interface LobbyHotSeatVerifiedDeploymentOptions { function buildVerifiedDeployment( options: LobbyHotSeatVerifiedDeploymentOptions, + includeFieldOrderDossier = true, ): HTMLElement { const verified = document.createElement('section'); verified.className = 'lobby-verified-deployment'; @@ -54,7 +64,7 @@ function buildVerifiedDeployment( rules.append(item); } const fieldOrder = options.fieldOrder; - const dossier = fieldOrder === null ? null : document.createElement('section'); + const dossier = includeFieldOrderDossier && fieldOrder !== null ? document.createElement('section') : null; if (fieldOrder && dossier) { dossier.className = 'lobby-verified-deployment__dossier'; dossier.setAttribute('aria-label', 'Commander dossier'); @@ -119,6 +129,87 @@ function buildVerifiedDeployment( return verified; } +function buildPracticeLane( + operations: readonly LobbyQuickOperation[], + onQuickOperation: (operationId: string) => void, +): HTMLElement { + const practice = document.createElement('section'); + practice.dataset.operationLane = 'practice'; + practice.className = 'lobby-commander-operations__practice'; + practice.setAttribute('aria-label', 'Practice operations'); + const title = document.createElement('h3'); + title.textContent = 'Practice operations'; + const purpose = document.createElement('p'); + purpose.textContent = 'Local practice only. Results do not affect your verified record.'; + const cards = document.createElement('div'); + cards.className = 'lobby-commander-operations__cards'; + let selectedOperation = operations[0]!; + for (const operation of operations) { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'lobby-commander-operations__card lobby-btn secondary'; + card.dataset.operationId = operation.id; + card.setAttribute('aria-label', `${operation.title}. ${operation.briefing}`); + const label = document.createElement('strong'); + label.textContent = operation.title; + const briefing = document.createElement('span'); + briefing.textContent = operation.briefing; + card.append(label, briefing); + card.addEventListener('click', () => { onQuickOperation(operation.id); }); + cards.append(card); + } + const compactLaunch = document.createElement('div'); + compactLaunch.className = 'lobby-commander-operations__compact-launch'; + const selector = document.createElement('select'); + selector.dataset.ui = 'practice-operation-selector'; + selector.setAttribute('aria-label', 'Choose practice operation'); + for (const operation of operations) { + const option = document.createElement('option'); + option.value = operation.id; + option.textContent = operation.title; + selector.append(option); + } + selector.addEventListener('change', () => { + selectedOperation = operations.find((operation) => operation.id === selector.value) ?? operations[0]!; + }); + const launch = document.createElement('button'); + launch.type = 'button'; + launch.className = 'lobby-btn secondary'; + launch.dataset.ui = 'launch-practice-operation'; + launch.textContent = 'Launch practice'; + launch.addEventListener('click', () => { onQuickOperation(selectedOperation.id); }); + compactLaunch.append(selector, launch); + practice.append(title, purpose, cards, compactLaunch); + return practice; +} + +function buildCommanderOperations( + verifiedDeployment: LobbyHotSeatVerifiedDeploymentOptions, + operations: readonly LobbyQuickOperation[], + onQuickOperation: (operationId: string) => void, +): HTMLElement { + const board = document.createElement('section'); + board.dataset.ui = 'commander-operations'; + board.className = 'lobby-commander-operations'; + board.setAttribute('aria-label', 'Commander Operations'); + + const career = document.createElement('section'); + career.dataset.operationLane = 'career'; + career.className = 'lobby-commander-operations__career'; + const title = document.createElement('h3'); + title.textContent = 'Current field order'; + const order = document.createElement('p'); + order.textContent = verifiedDeployment.fieldOrder === null + ? 'Your next verified order will be assigned when your record is available.' + : renderFieldOrder(verifiedDeployment.fieldOrder).brief; + career.append(title, order); + + const verified = buildVerifiedDeployment(verifiedDeployment, false); + verified.dataset.operationLane = 'verified'; + board.append(career, verified, buildPracticeLane(operations, onQuickOperation)); + return board; +} + export function buildLobbyHotSeatView(options: LobbyHotSeatViewOptions): HTMLElement { const wrapper = document.createElement('div'); const crowded = options.playerCount >= 3; @@ -209,7 +300,13 @@ export function buildLobbyHotSeatView(options: LobbyHotSeatViewOptions): HTMLEle start.disabled = options.validationMessage !== null; start.addEventListener('click', options.onStart); wrapper.append(brief, ready); - if (options.verifiedDeployment) { + if (options.verifiedDeployment && options.quickOperations && options.onQuickOperation) { + wrapper.append(buildCommanderOperations( + options.verifiedDeployment, + options.quickOperations, + options.onQuickOperation, + )); + } else if (options.verifiedDeployment) { wrapper.append(buildVerifiedDeployment(options.verifiedDeployment)); } wrapper.append(customization, start); diff --git a/e2e/verified-deployment.spec.ts b/e2e/verified-deployment.spec.ts index 3fb6603..dd07975 100644 --- a/e2e/verified-deployment.spec.ts +++ b/e2e/verified-deployment.spec.ts @@ -150,6 +150,72 @@ async function installOnlineCpuFixture(page: Page): Promise { test.describe('verified deployment production-browser journey', () => { test.beforeEach(async ({ page }) => installAuthenticatedFixture(page)); + test('composes one contained Commander Operations board before a verified launch', async ({ page }) => { + await openLocalBattery(page); + const board = page.locator('[data-ui="commander-operations"]'); + await expect(board).toBeVisible(); + await expect(board.getByRole('heading', { name: 'Current field order' })).toBeVisible(); + await expect(board.getByRole('region', { name: 'Verified deployment' })).toBeVisible(); + await expect(board.getByRole('region', { name: 'Practice operations' })).toBeVisible(); + await expect(board.locator('.lobby-verified-deployment__dossier')).toHaveCount(0); + const compactSelector = board.getByLabel('Choose practice operation'); + if (await compactSelector.isVisible()) { + const practiceLaunch = board.getByRole('button', { name: 'Launch practice' }); + const practiceTarget = await practiceLaunch.boundingBox(); + expect(practiceTarget, 'Practice launch needs a rendered touch target').not.toBeNull(); + expect(practiceTarget!.width).toBeGreaterThanOrEqual(44); + expect(practiceTarget!.height).toBeGreaterThanOrEqual(44); + await compactSelector.focus(); + await page.keyboard.press('Tab'); + await expect(practiceLaunch).toBeFocused(); + } else { + const cards = board.locator('button[data-operation-id]'); + const practiceTarget = await cards.first().boundingBox(); + expect(practiceTarget, 'Practice card needs a rendered target').not.toBeNull(); + expect(practiceTarget!.height).toBeGreaterThanOrEqual(44); + await cards.first().focus(); + await page.keyboard.press('Tab'); + await expect(cards.nth(1)).toBeFocused(); + } + + const metrics = await board.evaluate((node) => { + const lanes = [...node.children] as HTMLElement[]; + const style = getComputedStyle(node); + const boardBox = node.getBoundingClientRect(); + return { + display: style.display, + columns: style.gridTemplateColumns, + boardBox: boardBox.toJSON(), + lanes: lanes.map((lane) => lane.getBoundingClientRect().toJSON()), + }; + }); + expect(metrics.display).toBe('grid'); + expect(metrics.columns).not.toBe('none'); + for (const lane of metrics.lanes) { + expect(lane.left).toBeGreaterThanOrEqual(metrics.boardBox.left - 1); + expect(lane.right).toBeLessThanOrEqual(metrics.boardBox.right + 1); + expect(lane.top).toBeGreaterThanOrEqual(metrics.boardBox.top - 1); + expect(lane.bottom).toBeLessThanOrEqual(metrics.boardBox.bottom + 1); + } + await assertLobbyFrame(page); + }); + + test('launches the selected local practice operation from Commander Operations', async ({ page }) => { + await openLocalBattery(page); + const board = page.getByRole('region', { name: 'Commander Operations' }); + const compactSelector = board.getByLabel('Choose practice operation'); + if (await compactSelector.isVisible()) { + await compactSelector.selectOption('crosswind-range'); + await board.getByRole('button', { name: 'Launch practice' }).click(); + } else { + await board.locator('button[data-operation-id="crosswind-range"]').click(); + } + + await expect(page.locator('#lobby')).toBeHidden(); + await expect(page.locator('[data-ui="quick-operation"]')) + .toHaveText(/Crosswind Range.*Wraparound walls turn shifting wind into a ranging test\./); + }); + test('contains fixed rules, loading, and the verified HUD at every input profile', async ({ page }) => { let releaseStart!: () => void; const startGate = new Promise((resolve) => { releaseStart = resolve; }); @@ -168,8 +234,9 @@ test.describe('verified deployment production-browser journey', () => { await expect(verified.getByText('6 human / 6 CPU salvos maximum')).toBeVisible(); await expect(verified.getByText('Fixed battlefield rules')).toBeVisible(); await expect(verified.getByText('30-minute deadline')).toBeVisible(); - await expect(verified.getByText('Commander dossier')).toBeVisible(); - await expect(verified.getByText('First Strike · Damage the CPU within your first three salvos.')).toBeVisible(); + const commanderOperations = page.getByRole('region', { name: 'Commander Operations' }); + await expect(commanderOperations.getByRole('heading', { name: 'Current field order' })).toBeVisible(); + await expect(commanderOperations.getByText('First Strike · Damage the CPU within your first three salvos.')).toBeVisible(); const launchComposition = await verified.evaluate((node) => { const rules = node.querySelector('.lobby-verified-deployment__rules'); const actions = node.querySelector('.lobby-verified-deployment__actions'); @@ -179,7 +246,9 @@ test.describe('verified deployment production-browser journey', () => { actions: actions.getBoundingClientRect().toJSON(), }; }); - expect(launchComposition.actions.left).toBeGreaterThanOrEqual(launchComposition.rules.right - 1); + const launchIsBesideRules = launchComposition.actions.left >= launchComposition.rules.right - 1; + const launchIsBelowRules = launchComposition.actions.top >= launchComposition.rules.bottom - 1; + expect(launchIsBesideRules || launchIsBelowRules).toBe(true); await assertLobbyFrame(page); const launch = verified.getByRole('button', { name: 'Start verified deployment' }); @@ -559,7 +628,7 @@ test.describe('verified deployment production-browser journey', () => { await openLocalBattery(page, '?e2e=verified-lifecycle'); const verified = page.getByRole('region', { name: 'Verified deployment' }); - await expect(verified.getByText( + await expect(page.getByRole('region', { name: 'Commander Operations' }).getByText( /First Strike.*Damage the CPU within your first three salvos\./, )).toBeVisible(); @@ -577,7 +646,7 @@ test.describe('verified deployment production-browser journey', () => { await nextOrder.click(); await expect(page.locator('#lobby')).toBeVisible(); await expect(verified.getByRole('button', { name: 'Start verified deployment' })).toBeFocused(); - await expect(verified.getByText( + await expect(page.getByRole('region', { name: 'Commander Operations' }).getByText( /Fire for Effect.*Damage the CPU on two separate human salvos\./, )).toBeVisible(); await expect(fieldOrderStatus).toHaveCount(0); From 9500308b0bb8a41d0550b757e18c1d071c3a07a9 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sat, 15 Aug 2026 10:26:29 -0400 Subject: [PATCH 2/2] fix(lobby): retain Commander dossier above operations Restore the established dossier immediately before Commander Operations while retaining the board's actionable Field Order lane. --- .../2026-08-15-commander-operations-board.md | 5 +++- .codearbiter/sprint-log.md | 1 + client/src/ui/LobbyHotSeatView.test.ts | 3 +++ client/src/ui/LobbyHotSeatView.ts | 27 +++++++++++-------- e2e/verified-deployment.spec.ts | 6 +++++ 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.codearbiter/reports/2026-08-15-commander-operations-board.md b/.codearbiter/reports/2026-08-15-commander-operations-board.md index 2130ac1..390899b 100644 --- a/.codearbiter/reports/2026-08-15-commander-operations-board.md +++ b/.codearbiter/reports/2026-08-15-commander-operations-board.md @@ -39,7 +39,10 @@ Duel route and do not acquire verified rewards or account authority. - Complete Playwright production-bundle matrix: 346 passed, 38 project-inapplicable skips, 0 failures. - `git diff --check`: passed after restoring the exact historical audit bytes - and re-appending the two UTF-8 Commander Operations records. + and re-appending the UTF-8 Commander Operations audit records. +- Review correction: the existing Commander dossier is immediately above the + board. The focused view/account suite (66 tests), typecheck, and the + desktop, touch, and small-window board journey (3 tests) passed. ## Boundary and remaining delivery work diff --git a/.codearbiter/sprint-log.md b/.codearbiter/sprint-log.md index 4e1a412..c26774e 100644 --- a/.codearbiter/sprint-log.md +++ b/.codearbiter/sprint-log.md @@ -4305,3 +4305,4 @@ pm run check passed the complete chain in 75.8s; state-free staged secret scan r [2026-08-15T10:09:00-04:00] career.operations.0002 Commander Operations Board local gates. Browser RED board unstyled, compact rules 8px, practice target 33.34px. GREEN contained grid, readable compact verified lane, 44px practice target, keyboard route, Crosswind local launch. Evidence focused 66 of 66, client 164 files 1634 tests, npm check pass, Edge 352 of 352, Playwright 346 pass 38 intended skips 0 fail. Diff check requires exact rerun after this repair. Boundary unchanged. Exact review and hosted gates remain open. [2026-08-15T10:14:00-04:00] career.operations.0002 audit repair verification. User approved restoration of the malformed UTF-16 entry. Exact HEAD audit bytes were restored, then the two Commander Operations records were re-appended in UTF-8. Git now reports two sprint additions and one scoped override addition. Git diff check passes. [2026-08-15T10:15:00-04:00] career.operations.0002 audit count correction. The preceding 10:14 record says two sprint additions. Correct count is three sprint additions: SMARTS, local gates, and audit repair verification. Override count remains one. No product or evidence claim changed. +[2026-08-15T10:26:00-04:00] career.operations.0002 Commander Operations review correction | SMARTS: restore the existing Commander dossier above the Board because its player context value and approved contract outweighed duplicate brief copy; retain the Board Current field order lane for scanable action context. | RED: dossier contract test failed because no deployment dossier existed outside the Board. | GREEN: focused view and account tests 66/66, typecheck, and three-profile Commander Operations browser journey 3/3 passed. | Scope: client composition, causal tests, and evidence wording only; no authority or backend change. diff --git a/client/src/ui/LobbyHotSeatView.test.ts b/client/src/ui/LobbyHotSeatView.test.ts index 4d63d53..9c8d985 100644 --- a/client/src/ui/LobbyHotSeatView.test.ts +++ b/client/src/ui/LobbyHotSeatView.test.ts @@ -60,7 +60,10 @@ describe('buildLobbyHotSeatView', () => { })); const board = root.querySelector('[data-ui="commander-operations"]'); + const dossier = root.querySelector('.lobby-verified-deployment__dossier'); expect(board?.getAttribute('aria-label')).toBe('Commander Operations'); + expect(dossier?.getAttribute('aria-label')).toBe('Commander dossier'); + expect(dossier?.nextElementSibling).toBe(board); expect([...board?.children ?? []].map((child) => child.getAttribute('data-operation-lane'))) .toEqual(['career', 'verified', 'practice']); expect(board?.textContent).toContain('First Strike'); diff --git a/client/src/ui/LobbyHotSeatView.ts b/client/src/ui/LobbyHotSeatView.ts index 70f6df9..fd640f8 100644 --- a/client/src/ui/LobbyHotSeatView.ts +++ b/client/src/ui/LobbyHotSeatView.ts @@ -37,6 +37,19 @@ export interface LobbyHotSeatVerifiedDeploymentOptions { onCancelAbandon: () => void; } +function buildCommanderDossier(fieldOrder: FieldOrder | null): HTMLElement | null { + if (fieldOrder === null) return null; + const dossier = document.createElement('section'); + dossier.className = 'lobby-verified-deployment__dossier'; + dossier.setAttribute('aria-label', 'Commander dossier'); + const title = document.createElement('h4'); + title.textContent = 'Commander dossier'; + const order = document.createElement('p'); + order.textContent = renderFieldOrder(fieldOrder).brief; + dossier.append(title, order); + return dossier; +} + function buildVerifiedDeployment( options: LobbyHotSeatVerifiedDeploymentOptions, includeFieldOrderDossier = true, @@ -63,17 +76,7 @@ function buildVerifiedDeployment( item.textContent = rule; rules.append(item); } - const fieldOrder = options.fieldOrder; - const dossier = includeFieldOrderDossier && fieldOrder !== null ? document.createElement('section') : null; - if (fieldOrder && dossier) { - dossier.className = 'lobby-verified-deployment__dossier'; - dossier.setAttribute('aria-label', 'Commander dossier'); - const dossierTitle = document.createElement('h4'); - dossierTitle.textContent = 'Commander dossier'; - const order = document.createElement('p'); - order.textContent = renderFieldOrder(fieldOrder).brief; - dossier.append(dossierTitle, order); - } + const dossier = includeFieldOrderDossier ? buildCommanderDossier(options.fieldOrder) : null; const message = document.createElement('p'); message.className = 'lobby-verified-deployment__message'; message.setAttribute('role', 'status'); @@ -301,6 +304,8 @@ export function buildLobbyHotSeatView(options: LobbyHotSeatViewOptions): HTMLEle start.addEventListener('click', options.onStart); wrapper.append(brief, ready); if (options.verifiedDeployment && options.quickOperations && options.onQuickOperation) { + const dossier = buildCommanderDossier(options.verifiedDeployment.fieldOrder); + if (dossier) wrapper.append(dossier); wrapper.append(buildCommanderOperations( options.verifiedDeployment, options.quickOperations, diff --git a/e2e/verified-deployment.spec.ts b/e2e/verified-deployment.spec.ts index dd07975..c2337db 100644 --- a/e2e/verified-deployment.spec.ts +++ b/e2e/verified-deployment.spec.ts @@ -153,7 +153,13 @@ test.describe('verified deployment production-browser journey', () => { test('composes one contained Commander Operations board before a verified launch', async ({ page }) => { await openLocalBattery(page); const board = page.locator('[data-ui="commander-operations"]'); + const dossier = page.locator('.lobby-verified-deployment__dossier'); await expect(board).toBeVisible(); + await expect(dossier).toBeVisible(); + await expect(dossier).toHaveText(/First Strike/); + await expect(dossier.evaluate((node) => + node.nextElementSibling?.matches('[data-ui="commander-operations"]') ?? false, + )).resolves.toBe(true); await expect(board.getByRole('heading', { name: 'Current field order' })).toBeVisible(); await expect(board.getByRole('region', { name: 'Verified deployment' })).toBeVisible(); await expect(board.getByRole('region', { name: 'Practice operations' })).toBeVisible();