Implement scoresheet page with scoring logic and confirmation dialog

- Add src/game/scoring.ts with computeRunningTotals and isGameOver
- Build ScoreSheet grid: player columns, per-round scores, running totals after round 2+
- Small red "New Game" button in header top-right with full-screen confirmation overlay
- Extend PageFrame with headerAction and overlay props
- Move press animation/shadow from .pressable class to global button selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:34:23 -05:00
parent e0a6aed4cf
commit 813d18e133
11 changed files with 237 additions and 24 deletions
+18 -4
View File
@@ -43,11 +43,16 @@ Navigation is a simple `useState<Page>` state machine in `App.tsx` — no router
|------|-----|-------------|
| Title screen | `'title'` | Shown on first load (no saved game). "New Game" button navigates to setup. |
| Player setup | `'setup'` | Enter 36 player names, then "Start" saves the game and goes to scoresheet. |
| Score sheet | `'scoresheet'` | Shows current scores. "Score Round" → enter scores; "New Game" → clears state, back to title. |
| Score sheet | `'scoresheet'` | Shows per-round scores and running totals. Small red "New Game" button (top-right) opens a confirmation dialog before clearing state and going to setup. "Score Round" (footer) → enter scores. |
| Enter scores | `'enterscores'` | Form to enter each player's score for the round. "Submit" → back to scoresheet. |
On mount, `App.tsx` checks `localStorage` for a saved game and jumps straight to `'scoresheet'` if found.
## Game rules
- Each round every player scores some points; all players' scores in a round must sum to **-110**
- Each player starts at 0; the game ends when any player reaches **-200**
## Data model
```ts
@@ -61,6 +66,13 @@ interface GameState {
Persisted to `localStorage` under the key `currentGame`. No game history yet — only one game at a time.
## Scoring logic
Pure functions live in `src/game/scoring.ts` (no React imports):
- `computeRunningTotals(rounds)` — returns `totals[i][j]`, the cumulative score for player `j` after round `i`
- `isGameOver(totals)` — true if any player's total in the last round is ≤ -200
## Design system
### Layout
@@ -72,8 +84,10 @@ Persisted to `localStorage` under the key `currentGame`. No game history yet —
### PageFrame component
Shared layout for non-title pages. Props: `title`, `children`, `footer?`. Renders:
Shared layout for non-title pages. Props: `title`, `children`, `footer?`, `headerAction?`, `overlay?`. Renders:
- Fixed-position card with header (h1 + diamond rule divider), scrollable content area, optional sticky footer
- `headerAction` — rendered absolutely in the top-right of the header (used for the scoresheet's "New Game" button)
- `overlay` — rendered `position: fixed; inset: 0` over the full viewport with a dim backdrop; the caller provides the dialog box content centered inside it
Diamond rule: CSS `::before` pseudo-element with `content: '◆'` centered on a thin border line. Color `#c8b99a`.
@@ -91,10 +105,10 @@ Diamond rule: CSS `::before` pseudo-element with `content: '◆'` centered on a
### Buttons
All buttons share the same press animation: `translateY(2px)` + shadow reduction, 80ms ease transition.
Shadow and press animation are on the global `button` selector in `src/index.css` — every `<button>` gets them automatically.
- **Resting shadow**: `0 4px 12px rgba(0,0,0,0.25)`
- **Pressed shadow**: `0 2px 6px rgba(0,0,0,0.2)`
- **Pressed shadow**: `0 2px 6px rgba(0,0,0,0.2)` via `button:active:not(:disabled)`
- **`PrimaryButton`**: full-width, blue, `border-radius: 1rem`, `font-size: 1.25rem`, `font-weight: 600`. Accepts `disabled` prop — disabled state: `opacity: 0.45`, no shadow, `cursor: not-allowed`, no press animation.
- **Round buttons**: circular, `border: none`. Add button: `3rem`, blue. Remove button: `2.5rem`, red.
+1 -1
View File
@@ -43,6 +43,6 @@ export default function App() {
if (page === 'title') return <TitleScreen onNewGame={handleNewGame} />
if (page === 'setup') return <PlayerSetup onStart={handleStart} />
if (page === 'scoresheet') return <ScoreSheet onScoreRound={() => setPage('enterscores')} onNewGame={handleNewGame} />
if (page === 'scoresheet') return <ScoreSheet game={game!} onScoreRound={() => setPage('enterscores')} onNewGame={handleNewGame} />
if (page === 'enterscores') return <EnterScores onSubmit={handleSubmitScores} />
}
+17
View File
@@ -4,10 +4,17 @@
}
.header {
position: relative;
padding: 1.25rem 1.5rem 0;
text-align: center;
}
.headerAction {
position: absolute;
top: 0.75rem;
right: 1rem;
}
.title {
margin: 0;
font-size: 1.5rem;
@@ -43,3 +50,13 @@
.footer {
padding: 1rem 1.5rem 1.5rem;
}
.overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
+5 -1
View File
@@ -4,19 +4,23 @@ interface Props {
title: string
children: React.ReactNode
footer?: React.ReactNode
headerAction?: React.ReactNode
overlay?: React.ReactNode
}
export default function PageFrame({ title, children, footer }: Props) {
export default function PageFrame({ title, children, footer, headerAction, overlay }: Props) {
return (
<div className={styles.frame}>
<header className={styles.header}>
<h1 className={styles.title}>{title}</h1>
<div className={styles.rule} role="separator" />
{headerAction && <div className={styles.headerAction}>{headerAction}</div>}
</header>
<div className={styles.content}>
{children}
</div>
{footer && <div className={styles.footer}>{footer}</div>}
{overlay && <div className={styles.overlay}>{overlay}</div>}
</div>
)
}
-1
View File
@@ -1,5 +1,4 @@
.button {
composes: pressable from '../styles/shared.module.css';
width: 100%;
padding: 1rem;
background: var(--color-blue);
+18
View File
@@ -0,0 +1,18 @@
// Returns totals[i][j] = cumulative score for player j after round i.
export function computeRunningTotals(rounds: number[][]): number[][] {
const totals: number[][] = []
for (let i = 0; i < rounds.length; i++) {
if (i === 0) {
totals.push([...rounds[0]])
} else {
totals.push(rounds[i].map((score, j) => totals[i - 1][j] + score))
}
}
return totals
}
// Game ends when any player reaches -200 (checked against the most recent round's totals).
export function isGameOver(totals: number[][]): boolean {
const last = totals[totals.length - 1]
return last?.some((t: number) => t <= -200) ?? false
}
+2 -1
View File
@@ -5,7 +5,8 @@
}
*, *::before, *::after { box-sizing: border-box; }
button { min-height: 2.75rem; font-size: 1rem; font-family: inherit; cursor: pointer; }
button { min-height: 2.75rem; font-size: 1rem; font-family: inherit; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 80ms ease, box-shadow 80ms ease; }
button:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 2px 6px rgba(0,0,0,0.2); }
html, body, #root { margin: 0; height: 100%; }
body {
font-family: sans-serif;
-1
View File
@@ -21,7 +21,6 @@
}
.circleBtn {
composes: pressable from '../styles/shared.module.css';
border-radius: 50%;
border: none;
line-height: 1;
+108
View File
@@ -0,0 +1,108 @@
.tableWrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0 -1.5rem;
padding: 0 1.5rem;
}
.grid {
display: grid;
grid-template-columns: 5rem repeat(var(--player-count), 1fr);
width: max-content;
min-width: 100%;
}
.headerCell {
padding: 0.5rem 0.4rem;
font-weight: 600;
font-size: 0.8rem;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-bottom: 1px solid var(--color-parchment-border);
}
.labelCell {
padding: 0.35rem 0.4rem;
font-size: 0.75rem;
color: #666;
white-space: nowrap;
}
.scoreCell {
padding: 0.35rem 0.4rem;
text-align: right;
font-size: 0.95rem;
font-variant-numeric: tabular-nums;
}
.totalCell {
padding: 0.35rem 0.4rem;
text-align: right;
font-size: 0.9rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.divider {
border-top: 1px solid var(--color-parchment-border);
}
/* Small red "New Game" button in header top-right */
.newGameBtn {
background: #d94f4f;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.7rem;
font-weight: 600;
min-height: unset;
padding: 0.25rem 0.5rem;
line-height: 1.3;
}
/* Confirmation overlay content */
.confirmContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 2rem;
background: var(--color-parchment);
border: 2px solid black;
border-radius: 1.5rem;
width: calc(100% - 4rem);
max-width: 20rem;
}
.confirmText {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.confirmButtons {
display: flex;
gap: 1rem;
width: 100%;
max-width: 16rem;
}
.cancelBtn {
flex: 1;
background: white;
border: 2px solid var(--color-parchment-border);
border-radius: 0.75rem;
font-size: 1rem;
}
.endGameBtn {
flex: 1;
background: #d94f4f;
color: white;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
}
+68 -5
View File
@@ -1,15 +1,78 @@
import { Fragment, useState, type CSSProperties } from 'react'
import { type GameState } from '../types'
import { computeRunningTotals, isGameOver } from '../game/scoring'
import PageFrame from '../components/PageFrame'
import PrimaryButton from '../components/PrimaryButton'
import styles from './ScoreSheet.module.css'
interface Props {
game: GameState
onScoreRound: () => void
onNewGame: () => void
}
export default function ScoreSheet({ onScoreRound, onNewGame }: Props) {
export default function ScoreSheet({ game, onScoreRound, onNewGame }: Props) {
const [confirmingNewGame, setConfirmingNewGame] = useState(false)
const totals = computeRunningTotals(game.rounds)
const gameOver = isGameOver(totals)
const newGameButton = (
<button className={styles.newGameBtn} onClick={() => setConfirmingNewGame(true)}>
New Game
</button>
)
const confirmOverlay = (
<div className={styles.confirmContent}>
<p className={styles.confirmText}>End this game?</p>
<div className={styles.confirmButtons}>
<button className={styles.cancelBtn} onClick={() => setConfirmingNewGame(false)}>
Cancel
</button>
<button className={styles.endGameBtn} onClick={onNewGame}>
End Game
</button>
</div>
</div>
)
return (
<PageFrame title="Score Sheet">
<button onClick={onScoreRound}>Score Round</button>
<button onClick={onNewGame} style={{ fontSize: '0.75em' }}>New Game</button>
<PageFrame
title="Score Sheet"
headerAction={newGameButton}
overlay={confirmingNewGame ? confirmOverlay : undefined}
footer={<PrimaryButton onClick={onScoreRound} disabled={gameOver}>Score Round</PrimaryButton>}
>
<div className={styles.tableWrapper}>
<div
className={styles.grid}
style={{ '--player-count': game.players.length } as CSSProperties}
>
<div className={styles.labelCell} />
{game.players.map((name, j) => (
<div key={j} className={styles.headerCell}>{name}</div>
))}
{game.rounds.map((roundScores, i) => (
<Fragment key={i}>
<div className={styles.labelCell}>Round {i + 1}</div>
{roundScores.map((score, j) => (
<div key={j} className={styles.scoreCell}>{score}</div>
))}
{i > 0 && (
<>
<div className={`${styles.labelCell} ${styles.divider}`} />
{totals[i].map((total, j) => (
<div key={j} className={`${styles.totalCell} ${styles.divider}`}>{total}</div>
))}
</>
)}
</Fragment>
))}
</div>
</div>
</PageFrame>
)
}
}
-10
View File
@@ -7,13 +7,3 @@
flex-direction: column;
overflow: hidden;
}
.pressable {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transition: transform 80ms ease, box-shadow 80ms ease;
}
.pressable:active:not(:disabled) {
transform: translateY(2px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}