From 95d04f8d9e03f25aac34a0b09a24e5fd3c982ee7 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:42:33 -0700 Subject: [PATCH] feat(frontend): end-of-end modal with scoreboard Show modal for all clients when scoreboard grows: end points, next hammer, full table. Auto-dismiss 5s; dismiss button and backdrop. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/game.ts | 20 +++++++ frontend/src/hud.ts | 65 ++++++++++++++++++++ frontend/src/style.css | 131 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 831a8cf..ed3044f 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -36,6 +36,7 @@ export function startGame(): void { hud.setShareLink(shareLink) const model = new GameModel() + let lastScoreboardLen = model.state.scoreboard.length const stored = localStorage.getItem('curltastic-team') const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1' @@ -55,6 +56,24 @@ export function startGame(): void { hud.update(model.state) } + const maybeShowEndModal = () => { + const board = model.state.scoreboard + if (board.length <= lastScoreboardLen) { + lastScoreboardLen = board.length + return + } + const last = board[board.length - 1] + lastScoreboardLen = board.length + if (!last) return + hud.showEndModal({ + end: last.end, + team1: last.team1, + team2: last.team2, + nextHammer: model.state.hammer, + scoreboard: board, + }) + } + hud.teamSelect.addEventListener('change', () => { localStorage.setItem('curltastic-team', hud.teamSelect.value) updateControls() @@ -91,6 +110,7 @@ export function startGame(): void { }, onGameState: (msg) => { model.updateGameState(msg) + maybeShowEndModal() updateControls() }, onTrajectories: (stones) => { diff --git a/frontend/src/hud.ts b/frontend/src/hud.ts index ae52990..e85aa9b 100644 --- a/frontend/src/hud.ts +++ b/frontend/src/hud.ts @@ -26,6 +26,13 @@ export interface Hud { stonesRemaining: number[] scoreboard: EndScore[] }) => void + showEndModal: (payload: { + end: number + team1: number + team2: number + nextHammer: Team + scoreboard: EndScore[] + }) => void showToast: (message: string) => void setShareLink: (link: string) => void } @@ -66,6 +73,21 @@ function buildStoneChipsHtml(team: Team): string { return `` } +function renderScoreboardTable(scoreboard: EndScore[]): string { + if (scoreboard.length === 0) { + return '

No ends scored yet

' + } + const rows = scoreboard + .map( + (e) => + `${e.end}${e.team1}${e.team2}${TEAM_LABELS[e.hammer]}`, + ) + .join('') + return ` + + ${rows} +
EndTeam 1Team 2Hammer
` +} export function createHud(): Hud { const root = document.createElement('div') @@ -113,6 +135,20 @@ export function createHud(): Hud { const stonesHud = root.querySelector('#stones-hud')! const scoreboardStrip = root.querySelector('#scoreboard-strip')! + let endModalEl: HTMLDivElement | null = null + let endModalTimer = 0 + + const dismissEndModal = () => { + if (endModalTimer) { + window.clearTimeout(endModalTimer) + endModalTimer = 0 + } + if (endModalEl) { + endModalEl.remove() + endModalEl = null + } + } + const updateStonesRemaining = (remaining: number[]) => { const teams: Team[] = ['team1', 'team2'] for (let t = 0; t < teams.length; t++) { @@ -169,6 +205,35 @@ export function createHud(): Hud { updateStonesRemaining(state.stonesRemaining) updateScoreboardStrip(state.scoreboard, state.scores) }, + showEndModal: (payload) => { + dismissEndModal() + const modal = document.createElement('div') + modal.id = 'end-modal' + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + modal.setAttribute('aria-labelledby', 'end-modal-title') + modal.innerHTML = ` +
+
+

End ${payload.end} complete

+

+ Team 1 ${payload.team1} + vs + Team 2 ${payload.team2} +

+

Next hammer: ${TEAM_LABELS[payload.nextHammer]}

+
${renderScoreboardTable(payload.scoreboard)}
+ +
+ ` + modal.addEventListener('click', (e) => { + const t = e.target as HTMLElement + if (t.closest('[data-dismiss]')) dismissEndModal() + }) + document.body.appendChild(modal) + endModalEl = modal + endModalTimer = window.setTimeout(dismissEndModal, 5000) + }, showToast: (message: string) => { const toast = document.createElement('div') toast.id = 'toast' diff --git a/frontend/src/style.css b/frontend/src/style.css index 4b5033c..58c9034 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -167,6 +167,130 @@ html, body { padding: 2px 6px; } +/* —— End-of-end modal —— */ +#end-modal { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; +} + +.end-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(5, 14, 28, 0.72); + backdrop-filter: blur(3px); +} + +.end-modal-card { + position: relative; + z-index: 1; + width: min(360px, calc(100vw - 32px)); + max-height: min(80vh, 520px); + overflow: auto; + padding: 20px 18px 16px; + border-radius: 18px; + background: linear-gradient(160deg, rgba(28, 74, 122, 0.96), rgba(11, 31, 58, 0.98)); + border: 1px solid rgba(255, 255, 255, 0.22); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12); + text-align: center; +} + +.end-modal-card h2 { + margin: 0 0 12px; + font-size: 20px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.end-modal-points { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 0 0 8px; + font-size: 15px; +} + +.end-modal-team strong { + font-size: 22px; + margin-left: 4px; +} + +.end-modal-team--team1 strong { + color: #ff6b5c; +} + +.end-modal-team--team2 strong { + color: #ffd666; +} + +.end-modal-vs { + opacity: 0.55; + font-size: 12px; + text-transform: uppercase; +} + +.end-modal-hammer { + margin: 0 0 14px; + font-size: 13px; + opacity: 0.95; +} + +.end-modal-board { + margin-bottom: 14px; +} + +.end-modal-empty { + margin: 0; + font-size: 13px; + opacity: 0.7; +} + +.scoreboard-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.scoreboard-table th, +.scoreboard-table td { + padding: 6px 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +.scoreboard-table th { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10px; + opacity: 0.75; +} + +.end-modal-dismiss { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.35); + background: rgba(255, 255, 255, 0.14); + color: #fff; + font-weight: 700; + font-size: 13px; + padding: 8px 18px; + border-radius: 12px; + cursor: pointer; + pointer-events: auto; +} + +.end-modal-dismiss:hover { + background: rgba(255, 255, 255, 0.22); +} + +.end-modal-dismiss:focus-visible { + outline: 2px solid #00aaff; + outline-offset: 2px; +} #share { display: flex; @@ -351,6 +475,13 @@ html, body { gap: 4px; } + .end-modal-card { + padding: 16px 14px 12px; + } + + .end-modal-card h2 { + font-size: 17px; + } #velocity-control { min-width: 0;