feat: tablet-first UX overhaul with wizard improvements

- Tablet-first responsive scoreboard: whole player card is tap target
- Per-step wizard backgrounds and live summary panel
- Larger touch targets, safe-area handling, reduced-motion support
- German UI error strings in App.tsx
- Updated SOUL.md and README.md for current architecture
- Cleanup: removed legacy components, .cursor/, UI_IMPROVEMENTS.md
- Renamed package to bscscore, fixed TOTAL_WIZARD_STEPS
This commit is contained in:
Frank Schwenk
2026-09-21 17:59:31 +02:00
parent 3327f6e19e
commit 678977ab23
92 changed files with 3359 additions and 3063 deletions
+38 -21
View File
@@ -11,6 +11,7 @@ import {
} from '@lib/state';
import { GameService } from '@lib/data/gameService';
import type { StandardGame, Game, EndlosGame } from '@lib/domain/types';
import { isEndlosGame } from '@lib/domain/gameUtils';
import { Layout } from '@lib/ui/Layout';
import GameListScreen from './screens/GameListScreen';
@@ -33,30 +34,46 @@ export default function App() {
const validationModal = useValidationModal();
const completionModal = useCompletionModal();
// Keep viewport height stable on Android tablets when virtual keyboard opens.
// Keep layout height stable when the virtual keyboard opens on cheap Android tablets.
useEffect(() => {
const root = document.documentElement;
let rafId = 0;
let orientationTimer1 = 0;
let orientationTimer2 = 0;
const updateViewportVars = () => {
const viewport = window.visualViewport;
const appHeight = viewport ? viewport.height : window.innerHeight;
const keyboardOffset = viewport
? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
: 0;
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const viewport = window.visualViewport;
const layoutHeight = window.innerHeight;
const appHeight = viewport?.height ?? layoutHeight;
const keyboardOffset = viewport
? Math.max(0, layoutHeight - viewport.height - viewport.offsetTop)
: 0;
root.style.setProperty('--app-height', `${Math.round(appHeight)}px`);
root.style.setProperty('--keyboard-offset', `${Math.round(keyboardOffset)}px`);
root.style.setProperty('--app-height', `${Math.round(appHeight)}px`);
root.style.setProperty('--keyboard-offset', `${Math.round(keyboardOffset)}px`);
});
};
const handleOrientationChange = () => {
updateViewportVars();
orientationTimer1 = window.setTimeout(updateViewportVars, 100);
orientationTimer2 = window.setTimeout(updateViewportVars, 350);
};
updateViewportVars();
window.addEventListener('resize', updateViewportVars);
window.addEventListener('orientationchange', updateViewportVars);
window.addEventListener('orientationchange', handleOrientationChange);
window.visualViewport?.addEventListener('resize', updateViewportVars);
window.visualViewport?.addEventListener('scroll', updateViewportVars);
return () => {
cancelAnimationFrame(rafId);
window.clearTimeout(orientationTimer1);
window.clearTimeout(orientationTimer2);
window.removeEventListener('resize', updateViewportVars);
window.removeEventListener('orientationchange', updateViewportVars);
window.removeEventListener('orientationchange', handleOrientationChange);
window.visualViewport?.removeEventListener('resize', updateViewportVars);
window.visualViewport?.removeEventListener('scroll', updateViewportVars);
};
@@ -70,7 +87,7 @@ export default function App() {
navigation.showGameDetail(gameId);
} catch (error) {
console.error('Failed to create game:', error);
validationModal.showValidation('Failed to create game. Please try again.');
validationModal.showValidation('Spiel konnte nicht erstellt werden.');
}
}, [gameState.addGame, newGameWizard.resetWizard, navigation.showGameDetail, validationModal.showValidation]);
@@ -99,7 +116,7 @@ export default function App() {
}
} catch (error) {
console.error('Failed to update score:', error);
validationModal.showValidation('Failed to update score. Please try again.');
validationModal.showValidation('Punkt konnte nicht gespeichert werden.');
}
}, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, completionModal.openCompletionModal, validationModal.showValidation]);
@@ -120,7 +137,7 @@ export default function App() {
} as Game);
} catch (error) {
console.error('Failed to undo:', error);
validationModal.showValidation('Failed to undo. Please try again.');
validationModal.showValidation('Rückgängig nicht möglich.');
}
}, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, validationModal.showValidation]);
@@ -128,7 +145,7 @@ export default function App() {
if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId);
if (!game || !('players' in game)) return;
if (!game || !isEndlosGame(game)) return;
try {
const currentPlayerIndex = game.currentPlayer;
@@ -147,7 +164,7 @@ export default function App() {
completionModal.openCompletionModal(forfeitedGame);
} catch (error) {
console.error('Failed to forfeit game:', error);
validationModal.showValidation('Failed to forfeit game. Please try again.');
validationModal.showValidation('Aufgeben nicht möglich.');
}
};
@@ -173,7 +190,7 @@ export default function App() {
completionModal.closeCompletionModal();
} catch (error) {
console.error('Failed to complete game:', error);
validationModal.showValidation('Failed to complete game. Please try again.');
validationModal.showValidation('Spiel konnte nicht abgeschlossen werden.');
}
};
@@ -184,7 +201,7 @@ export default function App() {
if (!completedGame) return;
let gameData;
if ('players' in completedGame) {
if (isEndlosGame(completedGame)) {
gameData = {
player1: completedGame.players[0]?.name || '',
player2: completedGame.players[1]?.name || '',
@@ -208,7 +225,7 @@ export default function App() {
navigation.showGameDetail(newGameId);
} catch (error) {
console.error('Failed to create rematch:', error);
validationModal.showValidation('Failed to create rematch. Please try again.');
validationModal.showValidation('Rematch konnte nicht erstellt werden.');
}
};
@@ -224,7 +241,7 @@ export default function App() {
navigation.showGameList();
} catch (error) {
console.error('Failed to delete game:', error);
validationModal.showValidation('Failed to delete game. Please try again.');
validationModal.showValidation('Spiel konnte nicht gelöscht werden.');
}
}
};
@@ -271,7 +288,7 @@ export default function App() {
await gameState.updateGame(navigation.currentGameId!, game);
} catch (error) {
console.error('Failed to update game:', error);
validationModal.showValidation('Failed to update game. Please try again.');
validationModal.showValidation('Spiel konnte nicht aktualisiert werden.');
}
}}
onUndo={handleUndo}
@@ -305,4 +322,4 @@ export default function App() {
<FullscreenToggle />
</Layout>
);
}
}
-34
View File
@@ -1,34 +0,0 @@
---
// This is an Astro component that properly leverages SSR and islands
---
<!-- Use Astro's islands architecture for better performance -->
<!-- Only hydrate the interactive app component when needed -->
<div id="app-root">
<slot name="app-content" />
</div>
<style>
#app-root {
height: 100%;
width: 100%;
overflow: hidden;
}
/* Progressive enhancement styles */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
<script>
// Client-side progressive enhancement
document.addEventListener('DOMContentLoaded', () => {
// Add any progressive enhancement here
console.log('BSC Score App initialized');
});
</script>
+29 -20
View File
@@ -1,38 +1,47 @@
/* FullscreenToggle-specific styles only. */
/* FullscreenToggle — top-right keeps bottom controls clear on scoreboard */
.fullscreenToggle {
position: fixed;
bottom: 20px;
right: 20px;
width: 48px;
height: 48px;
top: calc(var(--space-md) + var(--safe-area-top));
right: calc(var(--space-md) + var(--safe-area-right));
width: var(--touch-target-comfortable);
height: var(--touch-target-comfortable);
border-radius: 50%;
background-color: rgba(52, 152, 219, 0.9);
border: none;
color: white;
background-color: var(--color-secondary);
border: 2px solid var(--color-border);
color: var(--color-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-md);
z-index: 9999;
transition: background-color 0.2s, transform 0.2s;
transition: background-color var(--transition-base), opacity var(--transition-base);
touch-action: manipulation;
}
.fullscreenToggle:hover {
background-color: rgba(52, 152, 219, 1);
transform: scale(1.1);
.fullscreenToggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.fullscreenToggle:active {
transform: scale(0.95);
opacity: 0.8;
background-color: var(--color-secondary-hover);
}
.fullscreenToggle svg {
width: 24px;
height: 24px;
}
@media screen and (max-width: 480px) {
@media (min-width: 768px) {
.fullscreenToggle {
bottom: 15px;
right: 15px;
width: 40px;
height: 40px;
width: var(--touch-target-large);
height: var(--touch-target-large);
}
}
.fullscreenToggle svg {
width: 28px;
height: 28px;
}
}
+1
View File
@@ -28,6 +28,7 @@ const FullscreenToggle = () => {
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg>
<span className="sr-only">Vollbild</span>
</button>
);
};
-63
View File
@@ -1,63 +0,0 @@
import { h } from 'preact';
import modalStyles from '@lib/ui/Modal.module.css';
import styles from './GameCompletionModal.module.css';
import type { Game } from '@lib/domain/types';
interface GameCompletionModalProps {
open: boolean;
game: Game | null;
onConfirm: () => void;
onClose: () => void;
onRematch: () => void;
}
/**
* Modal shown when a game is completed.
*/
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }: GameCompletionModalProps) => {
if (!open || !game) return null;
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
let maxScore, winners, winnerText;
if (game.forfeitedBy) {
winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
} else {
maxScore = Math.max(...scores);
winners = playerNames.filter((name, idx) => scores[idx] === maxScore);
winnerText = winners.length > 1
? `Unentschieden zwischen ${winners.join(' und ')}`
: `${winners[0]} hat gewonnen!`;
}
return (
<div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title">
<div className={modalStyles['modal-content']}>
<div className={modalStyles['modal-header']}>
<span className={modalStyles['modal-title']} id="completion-modal-title">Spiel beendet</span>
<button className={modalStyles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
</div>
<div className={modalStyles['modal-body']}>
<div className={styles['final-scores']}>
{playerNames.map((name, idx) => (
<div className={styles['final-score']} key={name + idx}>
<span className={styles['player-name']}>{name}</span>
<span className={styles['score']}>{scores[idx]}</span>
</div>
))}
</div>
<div className={styles['winner-announcement']}><h3>{winnerText}</h3></div>
</div>
<div className={modalStyles['modal-footer']}>
<button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button>
<button className={styles['btn'] + ' ' + styles['btn--primary']} onClick={onRematch} aria-label="Rematch">Rematch</button>
<button className={styles['btn']} onClick={onClose} aria-label="Abbrechen">Abbrechen</button>
</div>
</div>
</div>
);
};
export default GameCompletionModal;
-105
View File
@@ -1,105 +0,0 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import type { Game, EndlosGame } from '@lib/domain/types';
interface GameDetailProps {
game: Game | undefined;
onFinishGame: () => void;
onUpdateScore: (player: number, change: number) => void;
onUpdateGame?: (game: EndlosGame) => void;
onUndo?: () => void;
onForfeit?: () => void;
onBack: () => void;
}
/**
* Game detail view for a single game.
*/
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }: GameDetailProps) => {
if (!game) return null;
const handleScoreUpdate = (playerIndex: number, change: number) => {
onUpdateScore(playerIndex, change);
// Silent update; toast notifications removed
};
const isCompleted = game.status === 'completed';
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
return (
<div className={styles['game-detail']}>
<div className={styles['game-title']}>
{game.gameType}{game.raceTo ? ` | Race to ${game.raceTo}` : ''}
</div>
<div className={styles['scores-container']}>
{playerNames.map((name, idx) => {
const currentScore = scores[idx];
const progressPercentage = game.raceTo ? Math.min((currentScore / game.raceTo) * 100, 100) : 0;
return (
<div
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
key={name + idx}
>
<span className={styles['player-name']}>
{name}
{(() => {
const order = (game as any).breakOrder as number[] | undefined;
const breakerIdx = (game as any).currentBreakerIdx as number | undefined;
if (order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1) {
return <span title="Break" aria-label="Break" style={{ display: 'inline-block', width: '1em', height: '1em', borderRadius: '50%', background: '#fff', marginLeft: 6, verticalAlign: 'middle' }} />;
}
return null;
})()}
</span>
<div className={styles['progress-bar']}>
<div
className={styles['progress-fill']}
style={{ width: `${progressPercentage}%` }}
/>
</div>
<span
className={styles['score']}
id={`score${idx + 1}`}
onClick={() => !isCompleted && onUpdateScore(idx + 1, 1)}
onKeyDown={(e) => {
if (!isCompleted && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onUpdateScore(idx + 1, 1);
}
}}
role="button"
tabIndex={isCompleted ? -1 : 0}
aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken oder Enter drücken zum Erhöhen.`}
>
{scores[idx]}
</span>
{/* +/- buttons removed per issue #29. Tap score to +1; use Undo to revert. */}
</div>
);
})}
</div>
<div className={styles['game-detail-controls']}>
<button className="btn" onClick={onBack} aria-label="Zurück zur Liste">Zurück zur Liste</button>
{onUndo && (
<button
className="btn btn--secondary"
onClick={() => {
onUndo();
}}
aria-label="Rückgängig"
>
↶ Rückgängig
</button>
)}
<button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
</div>
</div>
);
};
export default GameDetail;
-129
View File
@@ -1,129 +0,0 @@
import { h } from 'preact';
import { Card } from '@lib/ui/Card';
import { Button } from '@lib/ui/Button';
import styles from './GameList.module.css';
import type { Game, GameFilter, StandardGame } from '@lib/domain/types';
interface GameListProps {
games: Game[];
filter: GameFilter;
setFilter: (filter: GameFilter) => void;
onShowGameDetail: (gameId: number) => void;
onDeleteGame: (gameId: number) => void;
}
export default function GameList({
games,
filter = 'all',
setFilter,
onShowGameDetail,
onDeleteGame
}: GameListProps) {
const filteredGames = games
.filter(game => {
if (filter === 'active') return game.status === 'active';
if (filter === 'completed') return game.status === 'completed';
return true;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const getPlayerNames = (game: Game): string => {
if ('players' in game) {
return game.players.map(p => p.name).join(' vs ');
} else {
const standardGame = game as StandardGame;
return standardGame.player3
? `${standardGame.player1} vs ${standardGame.player2} vs ${standardGame.player3}`
: `${standardGame.player1} vs ${standardGame.player2}`;
}
};
const getScores = (game: Game): string => {
if ('players' in game) {
return game.players.map(p => p.score).join(' - ');
} else {
const standardGame = game as StandardGame;
return standardGame.player3
? `${standardGame.score1} - ${standardGame.score2} - ${standardGame.score3}`
: `${standardGame.score1} - ${standardGame.score2}`;
}
};
const filterButtons = [
{ key: 'all' as const, label: 'Alle', ariaLabel: 'Alle Spiele anzeigen' },
{ key: 'active' as const, label: 'Aktiv', ariaLabel: 'Nur aktive Spiele anzeigen' },
{ key: 'completed' as const, label: 'Abgeschlossen', ariaLabel: 'Nur abgeschlossene Spiele anzeigen' },
];
return (
<div className={styles['game-list']}>
<div className={styles['filter-buttons']}>
{filterButtons.map(({ key, label, ariaLabel }) => (
<Button
key={key}
variant={filter === key ? 'primary' : 'secondary'}
size="small"
onClick={() => setFilter(key)}
aria-label={ariaLabel}
>
{label}
</Button>
))}
</div>
<div className={styles['games-container']}>
{filteredGames.length === 0 ? (
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
) : (
filteredGames.map(game => {
const playerNames = getPlayerNames(game);
const scores = getScores(game);
return (
<Card
key={game.id}
variant="elevated"
className={
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
}
>
<div
className={styles['game-info']}
onClick={() => onShowGameDetail(game.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onShowGameDetail(game.id);
}
}}
role="button"
tabIndex={0}
aria-label={`Details für Spiel ${playerNames}`}
aria-describedby={`game-${game.id}-description`}
>
<div className={styles['game-type']}>
{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}
</div>
<div className={styles['player-names']}>{playerNames}</div>
<div className={styles['game-scores']}>{scores}</div>
<div id={`game-${game.id}-description`} className="sr-only">
{game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}.
{game.status === 'completed' ? 'Abgeschlossen' : 'Aktiv'}
</div>
</div>
<Button
variant="danger"
size="small"
onClick={() => onDeleteGame(game.id)}
aria-label={`Spiel löschen: ${playerNames}`}
>
🗑
</Button>
</Card>
);
})
)}
</div>
</div>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function GameDetailScreen({
if (!game) {
return (
<Screen>
<div>Game not found</div>
<div>Spiel nicht gefunden</div>
</Screen>
);
}
+3 -3
View File
@@ -3,6 +3,7 @@ import { Button } from '@lib/ui/Button';
import { Screen } from '@lib/ui/Layout';
import GameList from '@lib/features/game-list/GameList';
import type { Game, GameFilter } from '@lib/domain/types';
import styles from '@lib/features/game-list/GameList.module.css';
interface GameListScreenProps {
games: Game[];
@@ -23,13 +24,12 @@ export default function GameListScreen({
}: GameListScreenProps) {
return (
<Screen>
<div style={{ flexShrink: 0 }}>
<div className={styles.newGameWrapper}>
<Button
variant="primary"
size="large"
onClick={onShowNewGame}
aria-label="Neues Spiel starten"
style={{ width: '100%', marginBottom: '24px' }}
>
+ Neues Spiel
</Button>
@@ -44,4 +44,4 @@ export default function GameListScreen({
/>
</Screen>
);
}
}
+9
View File
@@ -127,6 +127,7 @@ export default function NewGameScreen({
const playerStepConfig: PlayerStepConfigMap = {
player1: {
stepNumber: 1,
stepClassName: newGameStyles['step-player1'],
title: 'Name Spieler 1',
label: 'Spieler 1',
placeholder: 'Name Spieler 1',
@@ -145,6 +146,7 @@ export default function NewGameScreen({
},
player2: {
stepNumber: 2,
stepClassName: newGameStyles['step-player2'],
title: 'Name Spieler 2',
label: 'Spieler 2',
placeholder: 'Name Spieler 2',
@@ -165,6 +167,7 @@ export default function NewGameScreen({
},
player3: {
stepNumber: 3,
stepClassName: newGameStyles['step-player3'],
title: 'Name Spieler 3 (optional)',
label: 'Spieler 3 (optional)',
placeholder: 'Name Spieler 3 (optional)',
@@ -193,6 +196,8 @@ export default function NewGameScreen({
key={step}
{...playerStepConfig[step]}
playerNameHistory={playerNameOptions}
data={data}
currentStep={step}
/>
)}
@@ -201,6 +206,7 @@ export default function NewGameScreen({
onNext={handleGameTypeNext}
onCancel={handleStepBack}
initialValue={data.gameType}
data={data}
/>
)}
@@ -212,6 +218,7 @@ export default function NewGameScreen({
onCancel={handleStepBack}
initialValue={data.raceTo}
gameType={data.gameType}
data={data}
/>
)}
@@ -220,6 +227,7 @@ export default function NewGameScreen({
onNext={handleBreakRuleNext}
onCancel={handleStepBack}
initialValue={(data.breakRule as any) || (typeof window !== 'undefined' ? (localStorage.getItem('lastBreakRule') as any) : 'winnerbreak') || 'winnerbreak'}
data={data}
/>
)}
@@ -231,6 +239,7 @@ export default function NewGameScreen({
onCancel={handleStepBack}
initialFirst={(typeof window !== 'undefined' && localStorage.getItem('lastBreakFirst')) ? parseInt(localStorage.getItem('lastBreakFirst')!, 10) : 1}
initialSecond={(typeof window !== 'undefined' && localStorage.getItem('lastBreakSecond')) ? parseInt(localStorage.getItem('lastBreakSecond')!, 10) : undefined}
data={data}
/>
)}
</Screen>