Compare commits

...

10 Commits

Author SHA1 Message Date
randrews efde532885 8 players possible 2026-05-31 20:05:18 -05:00
randrews 14b413ebbd lol score total typo 2026-05-29 22:33:03 -05:00
randrews 5f872d1608 pass rules 2026-05-28 23:29:33 -05:00
randrews 23621da468 typo 2026-05-28 23:23:35 -05:00
randrews 906f106b26 Vary required round sum by player count
3 players → -105, 4-6 → -110, 7-8 → -100. Adds requiredRoundSum()
to scoring.ts and threads it through isRoundScoreValid and the sum
display in EnterScores.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 23:19:05 -05:00
randrews 8cd4bba4fd Add iOS PWA meta tags to index.html
Adds apple-mobile-web-app-capable, status-bar-style, and title tags
for proper fullscreen and home screen behavior on older iOS versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 23:11:50 -05:00
randrews 79832ed818 Add help modal and back button to score entry pages
HelpModal component shows game setup and scoring rules in a scrollable
overlay, triggered by a ? button top-right on Player Setup and Enter
Scores. Enter Scores also gets a ← back button top-left that returns
to the scoresheet without saving. PageFrame gains a headerActionLeft
slot to support the left-side button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 23:06:48 -05:00
randrews aa121bbcf3 Add per-round edit button to scoresheet
A ✎ button next to each round label opens the score entry form
pre-populated with that round's scores. Submitting replaces the round
in place instead of appending a new one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:36:39 -05:00
randrews 35c1694e18 Add sign toggle button for score entry and email to title screen
Replaces type="number" with type="text" + inputMode="numeric" so iOS
shows the numpad. A +/− toggle button beside each field handles the
sign; it is a no-op when the field is empty. Also adds contact email
below the title on the title screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:27:10 -05:00
randrews 9c1bbae359 Replace placeholder icons with custom image and fix PWA manifest colors
Regenerated all icons (favicon, apple-touch-icon, pwa-192, pwa-512) from
manual-icon.png using ImageMagick. Updated manifest and meta theme-color
from black to the app's blue (#a8d5e8), and added start_url to the manifest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:11:26 -05:00
23 changed files with 188 additions and 29 deletions
+2 -2
View File
@@ -42,7 +42,7 @@ Navigation is a simple `useState<Page>` state machine in `App.tsx` — no router
| Page | Key | Description |
|------|-----|-------------|
| 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. |
| Player setup | `'setup'` | Enter 38 player names, then "Start" saves the game and goes to scoresheet. |
| 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. |
@@ -50,7 +50,7 @@ On mount, `App.tsx` checks `localStorage` for a saved game and jumps straight to
## Game rules
- Each round every player scores some points; all players' scores in a round must sum to **-110**
- Each round every player scores some points; scores must sum to **-95** (3 players), **-110** (46 players), or **-100** (78 players)
- Each player starts at 0; the game ends when any player reaches **-200**
## Data model
+4 -1
View File
@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#a8d5e8" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Black 7">
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Black 7</title>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#0a0a0a"/>
<g fill="none" stroke="#f0ede6" stroke-linecap="round" stroke-linejoin="round">
<!-- horizontal top bar + diagonal leg of the 7 -->
<path d="M 90 120 L 408 112 L 206 450" stroke-width="30"/>
<!-- European crossbar -->
<path d="M 248 268 L 354 262" stroke-width="23"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+13 -3
View File
@@ -24,6 +24,7 @@ export default function App() {
const [game, setGame] = useState<GameState | null>(loadGame)
const [page, setPage] = useState<Page>(() => (game ? 'scoresheet' : 'title'))
const [previousPlayers, setPreviousPlayers] = useState<string[] | undefined>(undefined)
const [editingRound, setEditingRound] = useState<number | null>(null)
function handleNewGame() {
setPreviousPlayers(game?.players)
@@ -39,15 +40,24 @@ export default function App() {
setPage('scoresheet')
}
function handleEditRound(i: number) {
setEditingRound(i)
setPage('enterscores')
}
function handleSubmitScores(scores: number[]) {
const updated: GameState = { ...game!, rounds: [...game!.rounds, scores] }
const rounds = editingRound !== null
? game!.rounds.map((r, i) => (i === editingRound ? scores : r))
: [...game!.rounds, scores]
const updated: GameState = { ...game!, rounds }
saveGame(updated)
setGame(updated)
setEditingRound(null)
setPage('scoresheet')
}
if (page === 'title') return <TitleScreen onNewGame={handleNewGame} />
if (page === 'setup') return <PlayerSetup onStart={handleStart} initialNames={previousPlayers} />
if (page === 'scoresheet') return <ScoreSheet game={game!} onScoreRound={() => setPage('enterscores')} onNewGame={handleNewGame} />
if (page === 'enterscores') return <EnterScores game={game!} onSubmit={handleSubmitScores} />
if (page === 'scoresheet') return <ScoreSheet game={game!} onScoreRound={() => setPage('enterscores')} onNewGame={handleNewGame} onEditRound={handleEditRound} />
if (page === 'enterscores') return <EnterScores game={game!} onSubmit={handleSubmitScores} onBack={() => { setEditingRound(null); setPage('scoresheet') }} initialValues={editingRound !== null ? game!.rounds[editingRound] : undefined} />
}
+31
View File
@@ -0,0 +1,31 @@
.box {
display: flex;
flex-direction: column;
background: var(--color-parchment);
border: 2px solid black;
border-radius: 1.5rem;
width: calc(100% - 4rem);
max-width: 22rem;
max-height: 80vh;
overflow: hidden;
}
.body {
flex: 1;
overflow-y: auto;
padding: 1.5rem 1.5rem 1rem;
font-size: 0.95rem;
line-height: 1.5;
}
.dismiss {
margin: 0 1.5rem 1.5rem;
padding: 0.6rem 1rem;
background: var(--color-blue);
color: black;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
flex-shrink: 0;
}
+29
View File
@@ -0,0 +1,29 @@
import styles from './HelpModal.module.css'
interface Props {
onClose: () => void
}
export default function HelpModal({ onClose }: Props) {
return (
<div className={styles.box}>
<div className={styles.body}>
<h3>Setup</h3>
<ul>
<li>3 players, remove all 1s and 2s</li>
<li>4-6 players, use everything</li>
<li>7-8 players, remove non-yellow 2s</li>
<li>Pass 3 cards for 3-5 players, 2 cards for 6-8</li>
</ul>
<h3>Scoring</h3>
<ul>
<li>Gold good! Each gold card is +5 points</li>
<li>Red bad! Each red card -10 points</li>
<li>Black seven <em>very</em> bad: -50 points each</li>
<li>Last trick very good: +50 points</li>
</ul>
</div>
<button className={styles.dismiss} onClick={onClose}>Got it</button>
</div>
)
}
+7 -1
View File
@@ -11,10 +11,16 @@
.headerAction {
position: absolute;
top: 0.75rem;
top: 1rem;
right: 1rem;
}
.headerActionLeft {
position: absolute;
top: 1rem;
left: 1rem;
}
.title {
margin: 0;
font-size: 1.5rem;
+3 -1
View File
@@ -5,16 +5,18 @@ interface Props {
children: React.ReactNode
footer?: React.ReactNode
headerAction?: React.ReactNode
headerActionLeft?: React.ReactNode
overlay?: React.ReactNode
}
export default function PageFrame({ title, children, footer, headerAction, overlay }: Props) {
export default function PageFrame({ title, children, footer, headerAction, headerActionLeft, 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>}
{headerActionLeft && <div className={styles.headerActionLeft}>{headerActionLeft}</div>}
</header>
<div className={styles.content}>
{children}
+8 -1
View File
@@ -2,8 +2,15 @@ export function isIndividualScoreValid(n: number): boolean {
return n % 5 === 0 && n <= 110
}
export function requiredRoundSum(playerCount: number): number {
if (playerCount === 3) return -95
if (playerCount >= 7) return -100
return -110
}
export function isRoundScoreValid(scores: number[]): boolean {
return scores.every(isIndividualScoreValid) && scores.reduce((a, b) => a + b, 0) === -110
return scores.every(isIndividualScoreValid)
&& scores.reduce((a, b) => a + b, 0) === requiredRoundSum(scores.length)
}
// Returns totals[i][j] = cumulative score for player j after round i.
+12
View File
@@ -27,6 +27,18 @@
background: #fff5f5;
}
.signToggle {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
border: 2px solid var(--color-parchment-border);
background: #fdfaf4;
font-size: 1.2rem;
font-weight: 600;
color: #555;
flex-shrink: 0;
}
.sumLine {
margin: 0.5rem 0 0;
text-align: center;
+29 -6
View File
@@ -1,22 +1,39 @@
import { useState } from 'react'
import PageFrame from '../components/PageFrame'
import PrimaryButton from '../components/PrimaryButton'
import { isIndividualScoreValid, isRoundScoreValid } from '../game/scoring'
import HelpModal from '../components/HelpModal'
import { isIndividualScoreValid, isRoundScoreValid, requiredRoundSum } from '../game/scoring'
import { type GameState } from '../types'
import styles from './EnterScores.module.css'
import sharedStyles from '../styles/shared.module.css'
interface Props {
game: GameState
onSubmit: (scores: number[]) => void
onBack: () => void
initialValues?: number[]
}
export default function EnterScores({ game, onSubmit }: Props) {
const [values, setValues] = useState<string[]>(() => game.players.map(() => ''))
export default function EnterScores({ game, onSubmit, onBack, initialValues }: Props) {
const [values, setValues] = useState<string[]>(() =>
game.players.map((_, i) => initialValues ? String(initialValues[i]) : '')
)
const [showHelp, setShowHelp] = useState(false)
function updateValue(i: number, v: string) {
setValues(vs => vs.map((val, idx) => (idx === i ? v : val)))
}
function toggleSign(i: number) {
setValues(vs => vs.map((val, idx) => {
if (idx !== i) return val
const digits = val.startsWith('-') ? val.slice(1) : val
if (!digits) return val
return val.startsWith('-') ? digits : '-' + digits
}))
}
const target = requiredRoundSum(game.players.length)
const parsed = values.map(v => parseInt(v, 10))
const fieldError = values.map((v, i) => v !== '' && (isNaN(parsed[i]) || !isIndividualScoreValid(parsed[i])))
const allFilled = parsed.every(n => !isNaN(n))
@@ -26,6 +43,9 @@ export default function EnterScores({ game, onSubmit }: Props) {
return (
<PageFrame
title="Enter Scores"
headerActionLeft={<button className={sharedStyles.helpBtn} onClick={onBack}></button>}
headerAction={<button className={sharedStyles.helpBtn} onClick={() => setShowHelp(true)}>?</button>}
overlay={showHelp ? <HelpModal onClose={() => setShowHelp(false)} /> : undefined}
footer={
<PrimaryButton onClick={() => onSubmit(parsed)} disabled={!canSubmit}>
Submit
@@ -35,17 +55,20 @@ export default function EnterScores({ game, onSubmit }: Props) {
{game.players.map((name, i) => (
<div key={i} className={styles.row}>
<span className={styles.name}>{name}</span>
<button type="button" className={styles.signToggle} onClick={() => toggleSign(i)}>
{values[i].startsWith('-') ? '' : '+'}
</button>
<input
className={`${styles.scoreInput}${fieldError[i] ? ` ${styles.scoreInputError}` : ''}`}
type="number"
type="text"
inputMode="numeric"
value={values[i]}
onChange={e => updateValue(i, e.target.value)}
/>
</div>
))}
<p className={`${styles.sumLine}${sum === -110 ? ` ${styles.sumValid}` : ''}`}>
Sum: {sum} / 110
<p className={`${styles.sumLine}${sum === target ? ` ${styles.sumValid}` : ''}`}>
Sum: {sum} / {target}
</p>
</PageFrame>
)
+6 -1
View File
@@ -1,7 +1,9 @@
import { useState } from 'react'
import PageFrame from '../components/PageFrame'
import PrimaryButton from '../components/PrimaryButton'
import HelpModal from '../components/HelpModal'
import styles from './PlayerSetup.module.css'
import sharedStyles from '../styles/shared.module.css'
interface Props {
onStart: (players: string[]) => void
@@ -14,6 +16,7 @@ function normalize(s: string) {
export default function PlayerSetup({ onStart, initialNames }: Props) {
const [names, setNames] = useState<string[]>(initialNames ?? ['', '', ''])
const [showHelp, setShowHelp] = useState(false)
function updateName(i: number, value: string) {
setNames(n => n.map((v, idx) => (idx === i ? value : v)))
@@ -36,6 +39,8 @@ export default function PlayerSetup({ onStart, initialNames }: Props) {
return (
<PageFrame
title="Player Setup"
headerAction={<button className={sharedStyles.helpBtn} onClick={() => setShowHelp(true)}>?</button>}
overlay={showHelp ? <HelpModal onClose={() => setShowHelp(false)} /> : undefined}
footer={
<PrimaryButton onClick={() => onStart(names.map(n => n.trim()))} disabled={!isValid}>
Start
@@ -58,7 +63,7 @@ export default function PlayerSetup({ onStart, initialNames }: Props) {
)}
</div>
))}
{names.length < 6 && (
{names.length < 8 && (
<div className={styles.addRow}>
<button className={styles.addBtn} onClick={addName}>+</button>
</div>
+14
View File
@@ -28,6 +28,20 @@
font-size: 0.75rem;
color: #666;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.3rem;
}
.editBtn {
background: none;
border: none;
padding: 0;
min-height: unset;
font-size: 0.85rem;
color: #aaa;
line-height: 1;
box-shadow: none;
}
.scoreCell {
+6 -2
View File
@@ -9,9 +9,10 @@ interface Props {
game: GameState
onScoreRound: () => void
onNewGame: () => void
onEditRound: (i: number) => void
}
export default function ScoreSheet({ game, onScoreRound, onNewGame }: Props) {
export default function ScoreSheet({ game, onScoreRound, onNewGame, onEditRound }: Props) {
const [confirmingNewGame, setConfirmingNewGame] = useState(false)
const totals = computeRunningTotals(game.rounds)
@@ -62,7 +63,10 @@ export default function ScoreSheet({ game, onScoreRound, onNewGame }: Props) {
{game.rounds.map((roundScores, i) => (
<Fragment key={i}>
<div className={styles.labelCell}>Round {i + 1}</div>
<div className={styles.labelCell}>
<button className={styles.editBtn} onClick={() => onEditRound(i)}></button>
Round {i + 1}
</div>
{roundScores.map((score, j) => (
<div key={j} className={styles.scoreCell + (j === winnerIndex ? ' ' + styles.winnerCell : '')}>{score}</div>
))}
+8
View File
@@ -26,6 +26,14 @@
line-height: 0.9;
}
.email {
font-size: 0.8rem;
font-weight: 300;
color: #1a1a1a;
margin-top: 0.75rem;
letter-spacing: 0.03em;
}
.buttonRow {
position: absolute;
bottom: 2rem;
+1
View File
@@ -11,6 +11,7 @@ export default function TitleScreen({ onNewGame }: Props) {
<div className={styles.title}>
<span className={styles.titleWord}>Black</span>
<span className={styles.titleNumber}>7</span>
<span className={styles.email}>ross.andrews@gmail.com</span>
</div>
<div className={styles.buttonRow}>
<PrimaryButton onClick={onNewGame}>New Game</PrimaryButton>
+12
View File
@@ -1,3 +1,15 @@
.helpBtn {
background: none;
border: none;
padding: 0;
min-height: unset;
font-size: 1.25rem;
font-weight: 700;
color: #aaa;
line-height: 1;
box-shadow: none;
}
.card {
position: fixed;
inset: 1rem;
+3 -2
View File
@@ -12,9 +12,10 @@ export default defineConfig({
name: 'Black 7',
short_name: 'Black 7',
description: 'Scoring app for Last Panther',
theme_color: '#000000',
background_color: '#000000',
theme_color: '#a8d5e8',
background_color: '#a8d5e8',
display: 'standalone',
start_url: '/',
icons: [
{
src: 'pwa-192x192.png',