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
+1 -1
View File
@@ -4,7 +4,7 @@ Pure domain model primitives for BSC Score. Everything here is framework-agnosti
## Exports
- `types.ts` – canonical TypeScript shapes (`Game`, `StandardGame`, `EndlosGame`, `NewGameData`, etc.).
- `types.ts` – canonical TypeScript shapes (`Game`, `StandardGame`, `NewGameData`, etc.).
- `constants.ts` – configuration, validation copy, UI sizing tokens.
- `validation.ts` – safe helpers for validating/sanitising player & game inputs.
- `gameUtils.ts` – derived state utilities (duration, winner detection, type guards).
+1 -1
View File
@@ -49,7 +49,7 @@ export const BREAKPOINTS = {
export const UI_CONSTANTS = {
// Progress indicators
TOTAL_WIZARD_STEPS: 5,
TOTAL_WIZARD_STEPS: 7,
// Input styling
INPUT_FONT_SIZE: '1.2rem',
+1 -1
View File
@@ -105,4 +105,4 @@ export function validateGameData(data: any): boolean {
data.raceTo &&
parseInt(data.raceTo) > 0
);
}
}
+1 -1
View File
@@ -69,6 +69,6 @@ export interface NewGameData {
breakSecond?: number | '';
}
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'breakRule' | 'breakOrder' | 'raceTo' | null;
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'raceTo' | 'breakRule' | 'breakOrder' | null;
export type GameFilter = 'all' | 'active' | 'completed';
+314 -411
View File
@@ -1,473 +1,376 @@
/* GameDetail-specific styles only. Shared utility classes are now in global CSS. */
.screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
min-height: var(--app-height);
display: none;
opacity: 0;
transform: translateX(100%);
transition: transform 0.3s ease, opacity 0.3s ease;
}
.screen.active {
display: block;
opacity: 1;
transform: translateX(0);
position: relative;
}
.screen-content {
/* Scoreboard — primary screen, optimized for landscape tablets */
.gameDetail {
display: flex;
flex-direction: column;
height: 100%;
padding: 20px;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
min-height: 0;
}
.game-detail {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-md);
min-height: 0;
padding-bottom: calc(var(--space-md) + var(--safe-area-bottom));
user-select: none;
-webkit-user-select: none;
}
.game-title {
font-size: 24px;
color: #ccc;
flex-shrink: 0;
}
.game-header {
.gameHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
width: 100%;
flex-shrink: 0;
}
.scores-container {
display: flex;
justify-content: space-between;
gap: 32px;
min-height: 0;
flex-shrink: 0;
}
.player-score {
flex: 1;
text-align: center;
padding: 30px 20px;
border-radius: 20px;
display: flex;
flex-direction: column;
min-height: 0;
margin: 0 8px;
position: relative;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
transition: all 0.3s ease;
margin-bottom: var(--space-md);
gap: var(--space-md);
}
.player-score:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
.gameHeaderInner {
display: flex;
align-items: center;
gap: var(--space-md);
min-width: 0;
}
.player-score:first-child {
background-color: #43a047;
.bscMark {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-primary) 0%, #e65100 100%);
color: #1a1a1a;
font-size: var(--font-size-xs);
font-weight: 900;
letter-spacing: 0.08em;
box-shadow: 0 0 12px rgba(255, 152, 0, 0.35);
}
.player-score:nth-child(2) {
background-color: #1565c0;
}
.player-score:nth-child(3) {
background-color: #333;
}
.player-name {
font-size: 28px;
.gameTitle {
font-size: clamp(1rem, 2.5vh, 1.5rem);
color: var(--color-text);
font-weight: 700;
margin-bottom: 15px;
color: #fff;
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
letter-spacing: 1px;
}
.progress-bar {
width: 100%;
height: 8px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
margin: 10px 0;
.statusBadge {
flex-shrink: 0;
padding: var(--space-xs) var(--space-md);
background: var(--color-primary);
color: #1a1a1a;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.scoresContainer {
display: flex;
flex: 1;
justify-content: stretch;
align-items: stretch;
gap: var(--space-md);
min-height: 0;
}
.playerScore {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: var(--space-lg) var(--space-md);
border-radius: var(--radius-xl);
position: relative;
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.08);
min-width: 0;
min-height: 0;
border: 3px solid transparent;
transition: border-color var(--transition-base), transform var(--transition-fast);
overflow: hidden;
cursor: pointer;
touch-action: manipulation;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #fff 0%, #f0f0f0 100%);
border-radius: 4px;
transition: width 0.5s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
.player1 {
background: linear-gradient(180deg, #3a5a3d 0%, var(--color-player-1) 100%);
}
.game-status {
position: absolute;
top: 10px;
right: 10px;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
.player2 {
background: linear-gradient(180deg, #354d6a 0%, var(--color-player-2) 100%);
}
.game-status.active {
background: #4caf50;
.player3 {
background: linear-gradient(180deg, #444 0%, var(--color-player-3) 100%);
}
.playerScore.completed {
opacity: 0.85;
cursor: default;
}
.playerScore:focus-visible {
outline: 3px solid #fff;
outline-offset: 4px;
}
.playerScore:active:not([aria-disabled="true"]) {
transform: scale(0.99);
}
.playerScore.winner {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary), 0 0 28px rgba(255, 152, 0, 0.45), var(--shadow-lg);
}
.playerScore.winner .playerName {
color: var(--color-primary);
}
.playerNameRow {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
width: 100%;
margin-bottom: var(--space-sm);
flex-shrink: 0;
}
.playerName {
font-size: clamp(1.1rem, 3vh, 1.75rem);
font-weight: 700;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.game-status.completed {
background: #ff9800;
color: #222;
.breakIndicator {
display: inline-block;
width: clamp(14px, 2.5vh, 22px);
height: clamp(14px, 2.5vh, 22px);
border-radius: 50%;
background: #fff;
flex-shrink: 0;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
animation: breakPulse 2s ease-in-out infinite;
}
@keyframes breakPulse {
0%, 100% {
opacity: 1;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.8);
}
50% {
opacity: 0.65;
box-shadow: 0 0 14px rgba(255, 255, 255, 0.95), 0 0 20px rgba(255, 152, 0, 0.4);
}
}
.winnerBadge {
font-size: clamp(1rem, 2.5vh, 1.5rem);
color: var(--color-primary);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.progressBar {
width: 80%;
height: 6px;
background: rgba(255, 255, 255, 0.25);
border-radius: var(--radius-sm);
margin-bottom: var(--space-sm);
overflow: hidden;
flex-shrink: 0;
}
.progressFill {
height: 100%;
background: rgba(255, 255, 255, 0.9);
border-radius: var(--radius-sm);
transition: width var(--transition-slow);
}
.score {
font-size: 40vh;
font-size: clamp(4.5rem, 30vh, 14rem);
font-weight: 900;
margin: 20px 0 30px 0;
line-height: 1;
color: #fff;
text-shadow: 0 4px 16px rgba(0,0,0,0.6);
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
text-align: center;
display: block;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.score-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: auto;
}
.score-button {
background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
color: #222;
border: none;
border-radius: 50%;
padding: 0;
font-size: 2.5rem;
font-weight: 900;
cursor: pointer;
transition: all 0.3s ease;
width: 80px;
height: 80px;
margin: 0 8px;
box-shadow: 0 4px 16px rgba(255, 152, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
}
.score-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 152, 0, 0.4);
background: linear-gradient(135deg, #ffa726 0%, #ffb74d 100%);
}
.score-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.3);
}
.score-button:disabled {
background: #666;
color: #999;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.game-controls {
display: flex;
gap: 10px;
margin-top: 20px;
flex: 1 1 0;
width: 100%;
min-height: 0;
border-radius: var(--radius-lg);
padding: var(--space-xs);
font-variant-numeric: tabular-nums;
position: relative;
pointer-events: none;
}
.control-button {
flex: 1;
padding: 30px;
background: #333;
color: white;
border: none;
border-radius: 0;
font-size: 24px;
cursor: pointer;
touch-action: manipulation;
.score::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.35) 0%, transparent 70%);
opacity: 0;
transform: scale(0.5);
pointer-events: none;
}
.control-button.delete {
background: #f44336;
.scoreTap::after {
animation: scoreRipple 0.35s ease-out forwards;
}
.game-detail-controls {
.scoreBump {
animation: scoreBump 0.25s ease-out;
}
@keyframes scoreRipple {
0% { opacity: 0.6; transform: scale(0.4); }
100% { opacity: 0; transform: scale(1.4); }
}
@keyframes scoreBump {
0% { transform: scale(1); }
40% { transform: scale(1.12); }
100% { transform: scale(1); }
}
.gameDetailControls {
display: flex;
flex-direction: row;
gap: 24px;
margin: 40px 0 0 0;
padding-bottom: var(--space-xl);
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-lg);
padding-bottom: calc(var(--space-md) + var(--keyboard-offset));
width: 100%;
justify-content: center;
flex-shrink: 0;
}
.franky .player-name {
font-weight: bold;
color: #ff8c00; /* Example color */
}
.active-player {
position: relative;
border: 4px solid #ff9800;
box-shadow: 0 0 32px 8px #ff9800, 0 0 0 8px rgba(255,152,0,0.15);
background: linear-gradient(90deg, #ff9800 0 8px, rgba(255,152,0,0.15) 8px 100%);
color: #222 !important;
animation: activePulse 1.2s infinite alternate;
z-index: 2;
}
.active-player .player-name, .active-player .score {
color: #222 !important;
text-shadow: 0 2px 8px rgba(255,255,255,0.25);
}
.active-player::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 8px;
background: #ff9800;
border-radius: 8px 0 0 8px;
}
@keyframes activePulse {
0% { box-shadow: 0 0 32px 8px #ff9800, 0 0 0 8px rgba(255,152,0,0.15); }
100% { box-shadow: 0 0 48px 16px #ff9800, 0 0 0 16px rgba(255,152,0,0.22); }
}
.turn-indicator {
margin: 20px 0;
font-size: 1.2rem;
text-align: center;
}
.potted-balls-container {
margin-top: 2rem;
padding: 1rem;
background: #2a2a2a;
border-radius: 8px;
}
.potted-balls-header {
text-align: center;
font-size: 1.1rem;
margin-bottom: 1rem;
font-weight: 600;
}
.potted-balls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 10px;
}
.potted-ball-btn {
padding: 1rem;
font-size: 1.2rem;
font-weight: bold;
border-radius: 8px;
border: 1px solid #444;
background-color: #333;
color: #fff;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
}
.potted-ball-btn:hover:not(:disabled) {
background-color: #45a049;
transform: translateY(-2px);
}
.potted-ball-btn:disabled {
background-color: #222;
color: #555;
cursor: not-allowed;
}
.rerack-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.rerack-btn {
padding: 0.8rem 1.5rem;
font-size: 1rem;
font-weight: bold;
border-radius: 8px;
border: 1px solid #444;
background-color: #3a539b;
color: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
.rerack-btn:hover {
background-color: #4a6fbf;
.gameDetailControls > * {
flex: 1;
min-width: min(160px, 100%);
max-width: 280px;
}
.foul-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
.finishButton {
border: 2px solid var(--color-border) !important;
opacity: 0.85;
max-width: 220px;
}
.foul-btn {
flex-grow: 1;
background-color: #ffc107; /* Amber */
color: #212529;
border: none;
padding: 0.75rem;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
/* Landscape tablet — maximize score visibility */
@media (orientation: landscape) and (min-width: 768px) {
.gameDetail {
padding: var(--space-lg);
}
.scoresContainer {
gap: var(--space-lg);
}
.playerScore {
padding: var(--space-xl) var(--space-lg);
}
}
.foul-btn:hover {
background-color: #ffca2c;
/* Narrow portrait — stack players */
@media (max-width: 600px) {
.scoresContainer {
flex-direction: column;
}
.score {
font-size: clamp(3rem, 20vw, 6rem);
}
}
.foul-btn:disabled {
background-color: #e0e0e0;
color: #9e9e9e;
cursor: not-allowed;
/* Landscape phone — keep scores readable, compact chrome */
@media (max-height: 500px) and (orientation: landscape) {
.gameDetail {
padding: var(--space-xs) var(--space-sm);
padding-bottom: calc(var(--space-xs) + var(--safe-area-bottom));
}
.gameHeader {
margin-bottom: var(--space-xs);
}
.bscMark {
width: 28px;
height: 28px;
font-size: 0.6rem;
}
.gameTitle {
font-size: clamp(0.85rem, 2.2vh, 1.1rem);
}
.scoresContainer {
flex-direction: row;
gap: var(--space-xs);
flex: 1 1 0;
min-height: 0;
}
.playerScore {
padding: var(--space-xs);
}
.playerNameRow {
margin-bottom: var(--space-xs);
}
.playerName {
font-size: clamp(0.75rem, 2.2vh, 1rem);
}
.progressBar {
height: 4px;
margin-bottom: var(--space-xs);
width: 90%;
}
.score {
font-size: clamp(2rem, min(36vh, 24vw), 4.5rem);
padding: 0;
flex: 1 1 0;
}
.gameDetailControls {
margin-top: var(--space-xs);
gap: var(--space-xs);
padding-bottom: calc(var(--space-xs) + var(--keyboard-offset));
}
.gameDetailControls > * {
min-width: 0;
max-width: none;
flex: 1;
}
}
.foul-indicator {
font-size: 1rem;
font-weight: bold;
color: #fff;
background-color: #c0392b;
padding: 4px 8px;
border-radius: 4px;
margin-top: 8px;
display: inline-block;
}
@media (prefers-reduced-motion: reduce) {
.breakIndicator {
animation: none;
}
.foul-warning {
background-color: #f39c12;
color: #000;
.scoreTap::after,
.scoreBump {
animation: none;
}
}
/* Game Log Styles */
.game-log {
width: 100%;
margin-top: 1.5rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: var(--border-radius);
border: 1px solid #dee2e6;
}
.log-title {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 1.2rem;
color: #495057;
}
.log-list {
list-style-type: none;
padding: 0;
margin: 0;
max-height: 200px;
overflow-y: auto;
font-size: 0.9rem;
}
.log-entry {
padding: 0.5rem 0.25rem;
border-bottom: 1px solid #e9ecef;
color: #6c757d;
}
.log-entry:last-child {
border-bottom: none;
}
.game-log-table-container {
margin: 2.5rem auto 0 auto;
max-width: 100vw;
width: 100%;
overflow-x: auto;
background: #181818;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,0.12);
padding: 1.5rem 1rem;
}
.game-log-table {
width: 100%;
border-collapse: collapse;
font-size: 1.05rem;
background: #222;
color: #fff;
}
.game-log-table th, .game-log-table td {
border: 1px solid #444;
padding: 0.5rem 0.7rem;
text-align: center;
}
.game-log-table th {
background: #333;
font-weight: 700;
font-size: 1.1rem;
}
.game-log-table tr:nth-child(even) td {
background: #232323;
}
.game-log-table tr:nth-child(odd) td {
background: #181818;
}
.game-log-table .log-player-col {
background: #222;
color: #ff9800;
font-size: 1.15rem;
border-bottom: 2px solid #ff9800;
}
.turn-change-controls {
display: flex;
justify-content: center;
margin: 2.5rem 0 1.5rem 0;
}
.turn-change-btn {
background: #ff9800;
color: #222;
font-size: 2.2rem;
font-weight: 900;
padding: 2rem 4rem;
border: none;
border-radius: 18px;
box-shadow: 0 4px 32px rgba(255,152,0,0.18), 0 2px 8px rgba(0,0,0,0.12);
cursor: pointer;
transition: background 0.2s, color 0.2s, box-shadow 0.2s;
letter-spacing: 1px;
margin: 0 auto;
display: block;
}
.turn-change-btn:hover, .turn-change-btn:focus {
background: #ffa726;
color: #111;
box-shadow: 0 6px 40px rgba(255,152,0,0.28), 0 2px 8px rgba(0,0,0,0.18);
}
.pending-foul-info {
margin-left: 1.5rem;
font-size: 1.2rem;
color: #ffc107;
font-weight: 700;
}
.selected {
outline: 3px solid #ff9800 !important;
background: #fff3e0 !important;
color: #222 !important;
}
+146 -64
View File
@@ -1,5 +1,8 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import { Button } from '@lib/ui/Button';
import { getGameWinner, isStandardGame } from '@lib/domain/gameUtils';
import type { Game, EndlosGame } from '@lib/domain/types';
interface GameDetailProps {
@@ -12,93 +15,172 @@ interface GameDetailProps {
onBack: () => void;
}
interface AnimatedScoreProps {
value: number;
tapping: boolean;
}
function AnimatedScore({ value, tapping }: AnimatedScoreProps) {
const [bumping, setBumping] = useState(false);
const prevValue = useRef(value);
useEffect(() => {
if (prevValue.current !== value) {
setBumping(true);
const timer = window.setTimeout(() => setBumping(false), 250);
prevValue.current = value;
return () => window.clearTimeout(timer);
}
}, [value]);
const className = [
styles.score,
tapping ? styles.scoreTap : '',
bumping ? styles.scoreBump : '',
].filter(Boolean).join(' ');
return (
<span className={className} aria-hidden="true">
{value}
</span>
);
}
/**
* Game detail view for a single game.
*/
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }: GameDetailProps) => {
if (!game) return null;
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack }: GameDetailProps) => {
const [tappingPlayer, setTappingPlayer] = useState<number | null>(null);
const handleScoreUpdate = (playerIndex: number, change: number) => {
onUpdateScore(playerIndex, change);
// Silent update; toast notifications removed
};
if (!game || !isStandardGame(game)) return null;
const standardGame = game;
const isCompleted = standardGame.status === 'completed';
const winner = getGameWinner(standardGame);
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]);
const playerNames = [standardGame.player1, standardGame.player2, standardGame.player3].filter(Boolean);
const scores = [standardGame.score1, standardGame.score2, standardGame.score3].filter((_, i) => playerNames[i]);
const order = standardGame.breakOrder;
const breakerIdx = standardGame.currentBreakerIdx;
const titleParts = [
standardGame.gameType,
standardGame.raceTo && standardGame.raceTo !== Infinity
? `Race to ${standardGame.raceTo}`
: standardGame.raceTo === Infinity
? 'Endlos'
: null,
].filter(Boolean);
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']}>
<div className={styles.gameDetail}>
<header className={styles.gameHeader}>
<div className={styles.gameHeaderInner}>
<span className={styles.bscMark} aria-hidden="true">BSC</span>
<div className={styles.gameTitle}>{titleParts.join(' · ')}</div>
</div>
{isCompleted && (
<span className={styles.statusBadge} aria-label="Spiel abgeschlossen">Abgeschlossen</span>
)}
</header>
<div className={styles.scoresContainer} aria-label="Punktestand">
{playerNames.map((name, idx) => {
const currentScore = scores[idx];
const progressPercentage = game.raceTo ? Math.min((currentScore / game.raceTo) * 100, 100) : 0;
const currentScore = scores[idx] ?? 0;
const playerName = name as string;
const progressPercentage = standardGame.raceTo && standardGame.raceTo !== Infinity
? Math.min((currentScore / standardGame.raceTo) * 100, 100)
: 0;
const isBreaker = order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1;
const isWinner = isCompleted && winner === playerName;
const playerClass = [
styles.playerScore,
styles[`player${idx + 1}` as keyof typeof styles],
isWinner ? styles.winner : '',
isCompleted ? styles.completed : '',
].filter(Boolean).join(' ');
const handleScore = () => {
if (isCompleted) return;
setTappingPlayer(idx);
window.setTimeout(() => setTappingPlayer((current) => (current === idx ? null : current)), 350);
onUpdateScore(idx + 1, 1);
};
return (
<div
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
key={name + idx}
className={playerClass}
key={playerName + idx}
id={`score${idx + 1}`}
onClick={handleScore}
onKeyDown={(e) => {
if (!isCompleted && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleScore();
}
}}
role="button"
tabIndex={isCompleted ? -1 : 0}
aria-label={`Punkt für ${playerName}. Aktueller Stand: ${currentScore}`}
aria-disabled={isCompleted}
>
<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 className={styles.playerNameRow}>
<span className={styles.playerName}>{playerName}</span>
{isBreaker && !isCompleted && (
<span
className={styles.breakIndicator}
title="Break"
aria-label={`${playerName} hat den Break`}
/>
)}
{isWinner && (
<span className={styles.winnerBadge} aria-label="Gewinner">★</span>
)}
</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. */}
{standardGame.raceTo && standardGame.raceTo !== Infinity && (
<div className={styles.progressBar} aria-hidden="true">
<div
className={styles.progressFill}
style={{ width: `${progressPercentage}%` }}
/>
</div>
)}
<AnimatedScore value={currentScore} tapping={tappingPlayer === idx} />
</div>
);
})}
</div>
<div className={styles['game-detail-controls']}>
<button className="btn" onClick={onBack} aria-label="Zurück zur Liste">Zurück zur Liste</button>
<div className={styles.gameDetailControls}>
<Button variant="secondary" size="medium" onClick={onBack} aria-label="Zurück zur Liste">
Zurück
</Button>
{onUndo && (
<button
className="btn btn--secondary"
onClick={() => {
onUndo();
}}
aria-label="Rückgängig"
<Button
variant="secondary"
size="medium"
onClick={onUndo}
aria-label="Letzten Punkt rückgängig machen"
>
↶ Rückgängig
</button>
</Button>
)}
<button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
<Button
variant="secondary"
size="medium"
disabled={isCompleted}
onClick={onFinishGame}
aria-label={isCompleted ? 'Spiel bereits abgeschlossen' : 'Spiel beenden'}
className={styles.finishButton}
>
{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}
</Button>
</div>
</div>
);
};
export default GameDetail;
export default GameDetail;
+1 -1
View File
@@ -7,7 +7,7 @@
- `game: Game`
- `onUpdateScore(playerIndex: number, delta: number): void`
- `onFinishGame(): void`
- `onUpdateGame?(game: EndlosGame): void`
- `onUpdateGame?(game: Game): void`
- `onUndo?(): void`
- `onForfeit?(): void`
- `onBack(): void`
@@ -1,160 +1,152 @@
/* Only GameCompletionModal-specific styles. Shared modal styles are now in Modal.module.css */
.final-scores {
margin: 20px 0;
.modalWrapper {
position: relative;
overflow: hidden;
}
.final-score {
.confetti {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 1;
}
.confettiPiece {
position: absolute;
top: -12px;
width: 8px;
height: 8px;
border-radius: 2px;
opacity: 0;
animation: confettiFall 1.5s ease-out forwards;
animation-delay: calc(var(--i, 0) * 0.06s);
left: calc(8% + var(--i, 0) * 7%);
}
.confettiPiece:nth-child(3n) {
background: var(--color-primary);
}
.confettiPiece:nth-child(3n + 1) {
background: #ffc107;
}
.confettiPiece:nth-child(3n + 2) {
background: #ffeb3b;
}
@keyframes confettiFall {
0% {
opacity: 1;
transform: translateY(0) rotate(0deg) scale(1);
}
100% {
opacity: 0;
transform: translateY(280px) rotate(540deg) scale(0.6);
}
}
.finalScores {
margin: var(--space-md) 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
position: relative;
z-index: 2;
}
.finalScore {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 0;
margin-bottom: 8px;
background: #333;
border-radius: 8px;
font-size: 1.2rem;
color: #fff;
padding: var(--space-md) var(--space-lg);
background: var(--color-background);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
min-height: var(--touch-target-comfortable);
}
.final-score .player-name {
font-size: 1.2rem;
font-weight: bold;
color: #fff;
.finalScoreWinner {
border-color: var(--color-primary);
background: var(--color-primary-light);
box-shadow: 0 0 0 2px var(--color-primary);
}
.final-score .score {
font-size: 1.2rem;
font-weight: bold;
color: #fff;
}
.winner-announcement {
text-align: center;
margin: 20px 0 0 0;
padding: 32px 16px 24px 16px; /* extra top padding to keep icons inside */
background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
border-radius: 16px;
font-size: 1.2rem;
color: #222;
.finalScore .playerName {
font-size: var(--font-size-lg);
font-weight: 700;
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
animation: celebrationPulse 2s ease-in-out infinite;
position: relative;
overflow: visible; /* avoid clipping decorative icons */
}
.winner-announcement::before {
content: '🎉';
position: absolute;
top: 6px;
left: 20px;
font-size: 24px;
animation: bounce 1s ease-in-out infinite;
}
.winner-announcement::after {
content: '🏆';
position: absolute;
top: 6px;
right: 20px;
font-size: 24px;
animation: bounce 1s ease-in-out infinite 0.5s;
}
.winner-announcement h3 {
margin: 0;
font-size: 1.8rem;
color: #222;
text-align: center;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
color: var(--color-text);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 900;
letter-spacing: 0.04em;
}
@keyframes celebrationPulse {
.finalScore .score {
font-size: clamp(1.75rem, 5vw, 2.5rem);
font-weight: 800;
color: var(--color-primary);
font-variant-numeric: tabular-nums;
}
.winnerAnnouncement {
text-align: center;
margin-top: var(--space-lg);
padding: var(--space-xl) var(--space-lg);
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
border-radius: var(--radius-lg);
color: #1a1a1a;
font-weight: 700;
box-shadow: var(--shadow-md), 0 0 24px rgba(255, 152, 0, 0.35);
position: relative;
z-index: 2;
animation: winnerGlow 2s ease-in-out infinite;
}
@keyframes winnerGlow {
0%, 100% {
transform: scale(1);
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
box-shadow: var(--shadow-md), 0 0 20px rgba(255, 152, 0, 0.3);
}
50% {
transform: scale(1.02);
box-shadow: 0 12px 40px rgba(255, 152, 0, 0.4);
box-shadow: var(--shadow-md), 0 0 32px rgba(255, 152, 0, 0.55);
}
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
.btn {
flex: 1;
padding: 18px 0;
font-size: 1.1rem;
border: none;
border-radius: 8px;
cursor: pointer;
color: #fff;
background: #333;
font-weight: 600;
transition: background 0.2s;
}
.btn--warning {
background: #f44336;
}
.btn:not(.btn--warning):hover {
background: #444;
}
.btn--warning:hover {
background: #d32f2f;
}
@media (max-width: 600px) {
.btn {
font-size: 1rem;
padding: 14px 0;
}
}
.stats-container {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #dee2e6;
}
.stats-title {
.winnerAnnouncement h3 {
margin: 0;
font-size: clamp(1.1rem, 3vw, 1.6rem);
color: #1a1a1a;
text-align: center;
font-size: 1.3rem;
color: #495057;
margin-bottom: 1rem;
font-weight: 800;
line-height: 1.3;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);
}
.player-stats {
margin-bottom: 1rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.player-name-stats {
font-weight: bold;
margin-bottom: 0.5rem;
color: #343a40;
}
.stat-item {
.modalActions {
display: flex;
justify-content: space-between;
font-size: 0.95rem;
color: #6c757d;
flex-direction: column;
gap: var(--space-md);
margin-top: var(--space-lg);
position: relative;
z-index: 2;
}
.stat-item strong {
color: #212529;
@media (min-width: 480px) {
.modalActions {
flex-direction: row;
flex-wrap: wrap;
}
.modalActions > * {
flex: 1;
min-width: 120px;
}
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
.confettiPiece {
display: none;
}
.winnerAnnouncement {
animation: none;
}
}
@@ -1,7 +1,9 @@
import { h } from 'preact';
import modalStyles from '@lib/ui/Modal.module.css';
import styles from './GameCompletionModal.module.css';
import { Button } from '@lib/ui/Button';
import type { Game } from '@lib/domain/types';
import { getGamePlayers, isEndlosGame } from '@lib/domain/gameUtils';
interface GameCompletionModalProps {
open: boolean;
@@ -17,47 +19,72 @@ interface GameCompletionModalProps {
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;
const players = getGamePlayers(game);
const playerNames = players.map((player) => player.name);
const scores = players.map((player) => player.score);
const maxScore = Math.max(...scores);
const winners = playerNames.filter((name, idx) => (scores[idx] ?? 0) === maxScore);
let winnerText: string;
if (game.forfeitedBy) {
if (isEndlosGame(game) && 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
id="game-completion-modal"
className={`${modalStyles.modal} ${modalStyles.show}`}
role="dialog"
aria-modal="true"
aria-labelledby="completion-modal-title"
>
<div className={`${modalStyles['modal-content']} ${styles.modalWrapper}`}>
<div className={styles.confetti} aria-hidden="true">
{Array.from({ length: 12 }, (_, i) => (
<span key={i} className={styles.confettiPiece} style={{ '--i': i } as Record<string, number>} />
))}
</div>
<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>
<button className={modalStyles['close-button']} onClick={onClose} aria-label="Schließen" type="button">×</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 className={styles.finalScores}>
{playerNames.map((name, idx) => {
const isWinner = !(isEndlosGame(game) && game.forfeitedBy) && winners.includes(name) && winners.length === 1;
return (
<div
className={`${styles.finalScore} ${isWinner ? styles.finalScoreWinner : ''}`}
key={`${name}-${idx}`}
>
<span className={styles.playerName}>{name}</span>
<span className={styles.score}>{scores[idx] ?? 0}</span>
</div>
);
})}
</div>
<div className={styles.winnerAnnouncement}>
<h3>{winnerText}</h3>
</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 className={styles.modalActions}>
<Button variant="primary" size="large" onClick={onRematch} aria-label="Rematch starten">
Rematch
</Button>
<Button variant="secondary" size="large" onClick={onConfirm} aria-label="Spiel bestätigen und abschließen">
Bestätigen
</Button>
<Button variant="secondary" size="large" onClick={onClose} aria-label="Abbrechen">
Abbrechen
</Button>
</div>
</div>
</div>
);
};
export default GameCompletionModal;
export default GameCompletionModal;
+169 -219
View File
@@ -1,284 +1,234 @@
/* GameList-specific styles using design system tokens */
.screen.active {
display: block;
opacity: 1;
transform: translateX(0);
position: relative;
}
.screen-content {
display: flex;
flex-direction: column;
min-height: var(--app-height);
padding: var(--space-lg);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.screen-title {
font-size: var(--font-size-xxl);
margin-bottom: var(--space-lg);
font-weight: 700;
}
.game-list {
.gameList {
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
/* Filter buttons with improved symmetry */
.filter-buttons {
display: grid;
grid-template-columns: repeat(3, 1fr);
.newGameWrapper {
flex-shrink: 0;
margin-bottom: var(--space-lg);
}
.filterButtons {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin: var(--space-lg) 0 var(--space-md) 0;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-lg);
flex-shrink: 0;
}
.filter-button {
background: var(--color-secondary);
color: var(--color-text);
border: none;
font-size: var(--font-size-base);
padding: var(--space-md) 0;
cursor: pointer;
font-weight: 500;
transition: all var(--transition-base);
min-height: var(--touch-target-comfortable);
display: flex;
align-items: center;
justify-content: center;
.filterButtons > * {
flex: 1 1 auto;
min-width: min(160px, calc(50% - var(--space-sm)));
min-height: var(--touch-target-large);
}
.filter-button:hover {
background: var(--color-secondary-hover);
transform: translateY(-1px);
}
.filter-button.active {
background: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
}
/* Games container with improved spacing */
.games-container {
.gamesContainer {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-top: var(--space-lg);
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
padding-bottom: var(--space-md);
padding-bottom: calc(var(--space-md) + var(--keyboard-offset));
-webkit-overflow-scrolling: touch;
}
/* Game item with better symmetry and spacing */
.game-item {
.gameItem {
display: grid;
grid-template-columns: 1fr auto;
align-items: stretch;
gap: var(--space-md);
overflow: hidden;
width: 100%;
transition: transform var(--transition-fast), box-shadow var(--transition-base);
}
.gameItem:active {
transform: scale(0.99);
}
.gameItem.active {
border-left: 5px solid var(--color-primary);
box-shadow: var(--shadow-md);
}
.gameItem.completed {
border-left: 5px solid var(--color-text-muted);
opacity: 0.88;
}
.gameInfo {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-lg);
cursor: pointer;
min-width: 0;
touch-action: manipulation;
}
.gameInfo:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.gameMeta {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
cursor: pointer;
background: var(--color-surface);
flex-wrap: wrap;
}
.statusChip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
min-height: var(--touch-target-min);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.liveDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
animation: livePulse 1.5s ease-in-out infinite;
}
@keyframes livePulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
.statusActive {
background: var(--color-primary);
color: #1a1a1a;
}
.statusCompleted {
background: var(--color-secondary);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.game-item:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
.gameType {
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.game-item.active {
background: var(--color-success);
border-color: var(--color-success);
}
.game-item.completed {
background: var(--color-surface);
opacity: 0.8;
border-color: var(--color-border);
}
/* Game info with improved layout */
.game-info {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-lg);
width: 100%;
cursor: pointer;
}
.game-type {
font-weight: 700;
font-size: var(--font-size-lg);
color: var(--color-text);
white-space: nowrap;
min-width: 120px;
text-align: center;
background: var(--color-secondary);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
}
.player-names {
.playerNames {
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 500;
text-align: center;
flex: 1;
font-weight: 600;
line-height: 1.3;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.game-scores {
font-size: var(--font-size-xl);
font-weight: 700;
text-align: center;
.gameScores {
font-size: var(--font-size-xxl);
font-weight: 800;
color: var(--color-primary);
min-width: 120px;
background: var(--color-background);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
/* Delete button with improved touch target */
.delete-button {
width: var(--touch-target-comfortable);
height: var(--touch-target-comfortable);
border: none;
background: var(--color-danger);
color: white;
border-radius: 50%;
font-size: var(--font-size-lg);
.deleteWrapper {
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-base);
cursor: pointer;
box-shadow: var(--shadow-sm);
padding: var(--space-md);
flex-shrink: 0;
}
.delete-button::before {
content: '🗑️';
font-size: var(--font-size-lg);
.deleteButton {
min-width: 100px !important;
width: auto !important;
}
.delete-button:hover {
background: #cc0000;
transform: scale(1.05);
box-shadow: var(--shadow-md);
}
.delete-button:active {
transform: scale(0.95);
}
/* Empty state styling */
.empty-state {
.emptyState {
text-align: center;
padding: var(--space-xxl);
padding: var(--space-xxl) var(--space-lg);
color: var(--color-text-muted);
font-size: var(--font-size-lg);
background: var(--color-surface);
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
border-radius: var(--radius-lg);
border: 2px dashed var(--color-border);
}
/* Page header */
.page-header {
font-size: var(--font-size-xxxl);
font-weight: 700;
color: var(--color-text);
background: var(--color-surface);
padding: var(--space-lg) 0 var(--space-md) 0;
margin-bottom: var(--space-sm);
text-align: left;
width: 100%;
letter-spacing: 0.5px;
border-radius: var(--radius-lg);
}
/* Tablet-specific improvements */
@media (min-width: 768px) and (max-width: 1024px) {
.screen-content {
padding: var(--space-xl);
}
.filter-buttons {
.emptyBall {
font-size: 3rem;
line-height: 1;
margin-bottom: var(--space-md);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
.emptyHint {
margin-top: var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
@media (min-width: 768px) {
.filterButtons {
gap: var(--space-md);
margin: var(--space-xl) 0 var(--space-lg) 0;
}
.filter-button {
font-size: var(--font-size-lg);
padding: var(--space-lg) 0;
min-height: var(--touch-target-comfortable);
}
.game-item {
.gameInfo {
padding: var(--space-xl);
gap: var(--space-lg);
gap: var(--space-md);
}
.game-info {
gap: var(--space-xl);
}
.game-type {
font-size: var(--font-size-xl);
min-width: 150px;
padding: var(--space-md) var(--space-lg);
}
.player-names {
.playerNames {
font-size: var(--font-size-xl);
}
.game-scores {
font-size: var(--font-size-xxl);
min-width: 150px;
padding: var(--space-md) var(--space-lg);
.gameScores {
font-size: var(--font-size-xxxl);
}
.delete-button {
width: 64px;
height: 64px;
font-size: var(--font-size-xl);
}
.empty-state {
font-size: var(--font-size-xl);
padding: var(--space-xxl) var(--space-xl);
.deleteButton {
min-width: 120px !important;
}
}
/* Mobile adjustments */
@media (max-width: 767px) {
.screen-content {
padding: var(--space-md);
}
.game-info {
@media (max-width: 600px) {
.gameItem {
grid-template-columns: 1fr;
gap: var(--space-md);
text-align: center;
}
.game-type,
.game-scores {
min-width: auto;
width: 100%;
}
}
.filterButtons > * {
min-width: calc(33% - var(--space-sm));
}
.deleteWrapper {
padding: 0 var(--space-lg) var(--space-lg);
}
.deleteButton {
width: 100% !important;
}
}
@media (prefers-reduced-motion: reduce) {
.liveDot {
animation: none;
}
.gameItem:active {
transform: none;
}
}
+45 -39
View File
@@ -28,25 +28,17 @@ export default function GameList({
.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 g = game as StandardGame;
return g.player3
? `${g.player1} vs ${g.player2} vs ${g.player3}`
: `${g.player1} vs ${g.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 g = game as StandardGame;
return g.player3
? `${g.score1} – ${g.score2} – ${g.score3}`
: `${g.score1} – ${g.score2}`;
};
const filterButtons = [
@@ -56,39 +48,44 @@ export default function GameList({
];
return (
<div className={styles['game-list']}>
<div className={styles['filter-buttons']}>
<div className={styles.gameList}>
<div className={styles.filterButtons} role="group" aria-label="Spiele filtern">
{filterButtons.map(({ key, label, ariaLabel }) => (
<Button
key={key}
variant={filter === key ? 'primary' : 'secondary'}
size="small"
size="medium"
onClick={() => setFilter(key)}
aria-label={ariaLabel}
aria-pressed={filter === key}
>
{label}
</Button>
))}
</div>
<div className={styles['games-container']}>
<div className={styles.gamesContainer}>
{filteredGames.length === 0 ? (
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
<div className={styles.emptyState} role="status">
<div className={styles.emptyBall} aria-hidden="true">🎱</div>
<p>Keine Spiele vorhanden</p>
<p className={styles.emptyHint}>Tippe oben auf „+ Neues Spiel"</p>
</div>
) : (
filteredGames.map(game => {
const playerNames = getPlayerNames(game);
const scores = getScores(game);
const isActive = game.status === 'active';
return (
<Card
key={game.id}
variant="elevated"
className={
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
}
padding="none"
className={`${styles.gameItem} ${isActive ? styles.active : styles.completed}`}
>
<div
className={styles['game-info']}
className={styles.gameInfo}
onClick={() => onShowGameDetail(game.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -101,24 +98,33 @@ export default function GameList({
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 className={styles.gameMeta}>
<span className={`${styles.statusChip} ${isActive ? styles.statusActive : styles.statusCompleted}`}>
{isActive && <span className={styles.liveDot} aria-hidden="true" />}
{isActive ? 'Live' : 'Fertig'}
</span>
<div className={styles.gameType}>
{game.gameType}{game.raceTo && game.raceTo !== Infinity ? ` · ${game.raceTo}` : game.raceTo === Infinity ? ' · Endlos' : ''}
</div>
</div>
<div className={styles['player-names']}>{playerNames}</div>
<div className={styles['game-scores']}>{scores}</div>
<div className={styles.playerNames}>{playerNames}</div>
<div className={styles.gameScores}>{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'}
{isActive ? 'Aktiv' : 'Abgeschlossen'}
</div>
</div>
<Button
variant="danger"
size="small"
onClick={() => onDeleteGame(game.id)}
aria-label={`Spiel löschen: ${playerNames}`}
>
🗑
</Button>
<div className={styles.deleteWrapper}>
<Button
variant="danger"
size="medium"
onClick={() => onDeleteGame(game.id)}
aria-label={`Spiel löschen: ${playerNames}`}
className={styles.deleteButton}
>
Löschen
</Button>
</div>
</Card>
);
})
@@ -126,4 +132,4 @@ export default function GameList({
</div>
</div>
);
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ Single component `GameList` renders the scoreboard overview with filter tabs.
## Behaviour
- Sorts games by `createdAt` (desc) and filters according to `filter`.
- Derives player names/scores for both `StandardGame` and `EndlosGame`.
- Derives player names/scores for `StandardGame`.
- Uses `@lib/ui` primitives (`Button`, `Card`) for visuals.
## Example
+330 -94
View File
@@ -122,20 +122,109 @@
text-align: center;
font-weight: 500;
}
.new-game-form {
/* NewGame-specific styles only. Shared utility classes are now in global CSS. */
.wizardShell {
flex: 1;
display: flex;
align-items: stretch;
justify-content: center;
width: 100%;
max-width: 600px;
margin: var(--space-xl) auto 0 auto;
background: var(--color-surface);
min-height: 0;
max-height: 100%;
overflow: hidden;
padding: var(--space-sm) 0;
}
.new-game-form {
width: min(94vw, 720px);
max-height: 100%;
margin: 0 auto;
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.06);
padding: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
flex: 1;
flex-shrink: 1;
min-height: 0;
overflow: hidden;
animation: wizardStepIn var(--transition-slow) ease-out;
}
/* Per-step wizard backgrounds — visually distinct per step */
.new-game-form.step-player1 {
background: linear-gradient(180deg, #3a5a3d 0%, #2e4631 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-player2 {
background: linear-gradient(180deg, #354d6a 0%, #2a3b52 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-player3 {
background: linear-gradient(180deg, #444 0%, #333 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-gameType {
background: linear-gradient(180deg, #5a2323 0%, #4a1c1c 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-raceTo {
background: linear-gradient(180deg, #3d245e 0%, #2d1b4e 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-breakRule {
background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%);
border-color: rgba(255, 255, 255, 0.12);
}
.new-game-form.step-breakOrder {
background: linear-gradient(180deg, #5a3a10 0%, #3d260a 100%);
border-color: rgba(255, 152, 0, 0.25);
}
.new-game-form[class*="step-"] .form-footer {
background: rgba(0, 0, 0, 0.35);
border-top-color: rgba(255, 255, 255, 0.12);
}
.new-game-form[class*="step-"] .wizard-summary {
background: rgba(0, 0, 0, 0.35);
border-color: rgba(255, 255, 255, 0.15);
}
.new-game-form[class*="step-"] .screen-title {
color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.new-game-form[class*="step-"] .player-input {
background: rgba(0, 0, 0, 0.25);
border-color: rgba(255, 255, 255, 0.15);
}
.new-game-form[class*="step-"] .name-input,
.new-game-form[class*="step-"] .game-type-btn,
.new-game-form[class*="step-"] .race-to-btn,
.new-game-form[class*="step-"] .quick-pick-btn {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
}
@keyframes wizardStepIn {
from {
opacity: 0;
transform: translateX(12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.form-header {
@@ -143,6 +232,55 @@
padding: clamp(0.4rem, 1.5vh, 1.25rem) var(--space-lg) clamp(0.2rem, 0.8vh, 0.75rem) var(--space-lg);
}
.wizard-summary {
margin-top: clamp(0.25rem, 0.8vh, 0.5rem);
margin-bottom: clamp(0.25rem, 0.8vh, 0.5rem);
padding: clamp(0.5rem, 1.2vh, 0.75rem) var(--space-md);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.wizard-summary-hint {
margin: 0;
font-size: clamp(0.85rem, 2.2vw, 0.95rem);
line-height: 1.35;
color: var(--color-text-muted, #aaa);
}
.wizard-summary-list {
margin: 0;
display: flex;
flex-direction: column;
gap: clamp(0.2rem, 0.6vh, 0.35rem);
}
.wizard-summary-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.5rem;
min-height: 1.35em;
}
.wizard-summary-label {
margin: 0;
flex: 0 0 auto;
font-size: clamp(0.8rem, 2vw, 0.9rem);
font-weight: 600;
color: var(--color-text-muted, #aaa);
text-transform: none;
}
.wizard-summary-value {
margin: 0;
flex: 1 1 auto;
font-size: clamp(0.95rem, 2.5vw, 1.05rem);
line-height: 1.35;
color: var(--color-text, #eee);
font-weight: 500;
}
.form-content {
flex: 1;
overflow-y: auto;
@@ -160,7 +298,7 @@
bottom: 0;
z-index: 2;
background: var(--color-surface);
padding: var(--space-sm) var(--space-lg) calc(var(--space-sm) + var(--keyboard-offset)) var(--space-lg);
padding: var(--space-sm) var(--space-lg) calc(var(--space-sm) + var(--safe-area-bottom) + var(--keyboard-offset)) var(--space-lg);
border-top: 1px solid var(--color-border);
}
.progress-indicator {
@@ -171,19 +309,24 @@
margin-bottom: clamp(0.5rem, 2vh, 1.5rem);
}
.progress-dot {
width: clamp(10px, 2vh, 16px);
height: clamp(10px, 2vh, 16px);
width: clamp(12px, 2vh, 18px);
height: clamp(12px, 2vh, 18px);
border-radius: 50%;
background: var(--color-border);
opacity: 0.4;
transition: all var(--transition-base);
transition: background var(--transition-slow), opacity var(--transition-slow), transform var(--transition-slow), box-shadow var(--transition-slow);
position: relative;
}
.progress-dot.completed {
background: var(--color-primary);
opacity: 0.55;
transform: scale(0.9);
}
.progress-dot.active {
background: var(--color-primary);
opacity: 1;
transform: scale(1.2);
box-shadow: 0 0 0 4px var(--color-primary-light);
transform: scale(1.25);
box-shadow: 0 0 0 4px var(--color-primary-light), 0 0 12px rgba(255, 152, 0, 0.4);
}
.quick-pick-container {
flex: 1;
@@ -194,24 +337,35 @@
}
.quick-pick-btn {
min-width: 60px;
min-height: 32px;
font-size: clamp(0.75rem, 2vw, 1rem);
min-width: 80px;
min-height: var(--touch-target-comfortable);
font-size: var(--font-size-base);
border-radius: var(--radius-md);
background: var(--color-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border: 2px solid var(--color-border);
cursor: pointer;
padding: 0.3rem 0.7rem;
transition: all var(--transition-base);
font-weight: 500;
padding: var(--space-md) var(--space-lg);
transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
font-weight: 600;
font-family: inherit;
flex-shrink: 0;
touch-action: manipulation;
}
.quick-pick-btn:hover, .quick-pick-btn:focus {
background: var(--color-secondary-hover);
.quick-pick-btn:active:not(.selected) {
transform: scale(0.97);
}
.quick-pick-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary);
transform: translateY(-1px);
outline: none;
}
.choice-btn {
min-width: 160px;
min-height: var(--touch-target-large);
font-size: var(--font-size-lg);
flex: 1;
}
.arrow-nav {
display: flex;
@@ -228,34 +382,34 @@
}
.arrow-btn {
font-size: 28px;
width: 44px;
height: 44px;
width: var(--touch-target-comfortable);
height: var(--touch-target-comfortable);
border-radius: 50%;
background: var(--color-secondary);
color: var(--color-text);
border: 2px solid var(--color-border);
box-shadow: var(--shadow-md);
box-shadow: var(--shadow-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-base);
transition: background-color var(--transition-base), border-color var(--transition-base);
font-weight: bold;
font-family: inherit;
touch-action: manipulation;
flex-shrink: 0;
}
.arrow-btn:disabled {
opacity: 0.5;
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.arrow-btn:hover, .arrow-btn:focus {
background: var(--color-secondary-hover);
.arrow-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
outline: none;
}
.arrow-btn:active {
transform: translateY(0);
.arrow-btn:active:not(:disabled) {
background: var(--color-secondary-hover);
}
.clear-input-btn {
position: absolute;
@@ -277,10 +431,16 @@
align-items: center;
justify-content: center;
}
.clear-input-btn:hover, .clear-input-btn:focus {
.clear-input-btn:focus-visible {
color: var(--color-text);
background: var(--color-secondary);
outline: none;
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.clear-input-btn:active {
color: var(--color-text);
background: var(--color-secondary-hover);
}
.name-input-wrapper {
position: relative;
@@ -302,8 +462,12 @@
border-color: var(--color-primary);
color: #fff;
}
.quick-pick-action-btn:hover,
.quick-pick-action-btn:focus {
.quick-pick-action-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.quick-pick-action-btn:active {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
@@ -331,25 +495,31 @@
background: var(--color-background);
border: 2px solid var(--color-border);
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
font-size: var(--font-size-xl);
font-weight: 700;
padding: var(--space-xl);
border-radius: var(--radius-md);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: center;
transition: all var(--transition-base);
min-height: var(--touch-target-comfortable);
transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
min-height: var(--touch-target-large);
font-family: inherit;
touch-action: manipulation;
}
.game-type-btn:hover {
background: var(--color-surface);
border-color: var(--color-primary);
transform: translateY(-1px);
.game-type-btn:active:not(.selected) {
transform: scale(0.97);
}
.game-type-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.game-type-btn.selected {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
color: #fff;
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
transform: scale(1.03);
}
.race-to-selection {
display: grid;
@@ -362,35 +532,43 @@
background: var(--color-background);
border: 2px solid var(--color-border);
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
padding: var(--space-lg) var(--space-sm);
border-radius: var(--radius-md);
font-size: var(--font-size-xl);
font-weight: 700;
padding: var(--space-lg) var(--space-md);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: center;
transition: all var(--transition-base);
min-height: 80px;
transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
min-height: var(--touch-target-large);
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
touch-action: manipulation;
}
.race-to-btn:hover {
background: var(--color-surface);
border-color: var(--color-primary);
transform: translateY(-1px);
.race-to-btn:active:not(.selected) {
transform: scale(0.97);
}
.race-to-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.race-to-btn.selected {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
color: #fff;
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
transform: scale(1.03);
}
/* Match selected styling for quick pick buttons used in BreakRuleStep */
.quick-pick-btn.selected {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
color: #fff;
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
font-weight: 700;
transform: scale(1.02);
}
.custom-race-to {
display: flex;
@@ -414,32 +592,19 @@
.endlos-btn {
width: 100%;
}
.player1-input.player-input {
border-color: var(--color-success);
background: linear-gradient(135deg, var(--color-success) 0%, rgba(76, 175, 80, 0.1) 100%);
}
.player2-input.player-input {
border-color: #1565c0;
background: linear-gradient(135deg, #1565c0 0%, rgba(21, 101, 192, 0.1) 100%);
}
.player1-input.player-input,
.player2-input.player-input,
.player3-input.player-input {
border-color: var(--color-secondary);
background: linear-gradient(135deg, var(--color-secondary) 0%, rgba(51, 51, 51, 0.1) 100%);
}
.player1-input.player-input input,
.player2-input.player-input input,
.player3-input.player-input input {
background: #fff;
color: #222;
border: 1px solid #ccc;
border-color: var(--color-border);
background: var(--color-surface);
}
@media (min-width: 768px) and (max-width: 1024px) {
.screen-content {
padding: var(--space-xl);
.wizardShell {
padding: var(--space-md) 0;
}
.new-game-form {
max-width: 700px;
padding: var(--space-xxl) var(--space-xl) var(--space-xl) var(--space-xl);
min-height: min(82vh, 680px);
max-height: 100%;
}
.screen-title {
font-size: var(--font-size-xxxl);
@@ -461,13 +626,20 @@
padding: var(--space-md) var(--space-lg);
}
}
@media (min-height: 720px) {
.new-game-form {
min-height: min(88vh, 720px);
}
}
@media (max-width: 767px) {
.screen-content {
padding: var(--space-md);
.wizardShell {
padding: var(--space-xs) 0;
align-items: stretch;
}
.new-game-form {
margin: var(--space-lg) auto 0 auto;
padding: var(--space-sm);
width: min(96vw, 720px);
min-height: 0;
max-height: 100%;
}
.game-type-selection {
grid-template-columns: 1fr;
@@ -479,12 +651,23 @@
gap: var(--space-md);
}
.arrow-btn {
width: 40px;
height: 40px;
width: var(--touch-target-min);
height: var(--touch-target-min);
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.new-game-form {
animation: none;
}
.game-type-btn.selected,
.race-to-btn.selected,
.quick-pick-btn.selected {
transform: none;
}
}
@media (max-height: 560px) {
.screen-title {
margin-bottom: 0.35rem;
@@ -492,6 +675,13 @@
.progress-indicator {
margin-bottom: 0.35rem;
}
.wizard-summary {
padding: 0.35rem var(--space-sm);
margin-bottom: 0.25rem;
}
.wizard-summary-value {
font-size: clamp(0.9rem, 2.2vw, 1rem);
}
.player-input {
padding: var(--space-sm);
}
@@ -501,6 +691,52 @@
}
.form-footer {
padding-top: 0.3rem;
padding-bottom: calc(0.35rem + var(--keyboard-offset));
padding-bottom: calc(0.35rem + var(--safe-area-bottom) + var(--keyboard-offset));
}
}
}
@media (max-height: 500px) {
.wizardShell {
padding: 0;
}
.new-game-form {
min-height: 0;
max-height: 100%;
width: min(98vw, 720px);
}
.form-header {
padding: var(--space-xs) var(--space-md) var(--space-xs);
}
.screen-title {
font-size: clamp(1rem, 3.5vh, 1.25rem);
margin-bottom: 0.25rem;
}
.wizard-summary {
margin-top: 0.15rem;
margin-bottom: 0.15rem;
padding: 0.25rem var(--space-sm);
}
.choice-heading {
margin-top: 0.5rem;
margin-bottom: 0.35rem;
font-size: clamp(0.95rem, 3vh, 1.1rem);
}
.quick-picks-row {
margin-top: 0;
}
.quick-pick-btn.choice-btn {
min-height: var(--touch-target-min);
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-base);
}
.form-footer {
padding: var(--space-xs) var(--space-md) calc(var(--space-xs) + var(--safe-area-bottom) + var(--keyboard-offset));
}
}
@@ -1,48 +1,62 @@
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--space-lg);
padding-top: calc(var(--space-lg) + var(--safe-area-top));
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
}
.modalContent {
background: #2c2c2c;
padding: 24px;
border-radius: 12px;
width: 90%;
max-width: 400px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
background: var(--color-surface);
padding: var(--space-xl);
border-radius: var(--radius-xl);
width: min(480px, 100%);
max-height: calc(var(--app-height) - var(--space-xl) * 2);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.modalHeader h3 {
margin: 0;
font-size: 1.5rem;
color: #fff;
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--color-text);
}
.closeButton {
background: none;
border: none;
font-size: 2rem;
color: #aaa;
cursor: pointer;
padding: 0;
background: var(--color-secondary);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--font-size-xxl);
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
min-width: var(--touch-target-min);
min-height: var(--touch-target-min);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
}
.closeButton:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.playerList {
@@ -50,21 +64,33 @@
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--space-sm);
-webkit-overflow-scrolling: touch;
}
.playerItem {
background: #444;
color: #fff;
border: none;
padding: 16px;
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
border: 2px solid var(--color-border);
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-md);
text-align: left;
font-size: 1.2rem;
font-size: var(--font-size-lg);
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background-color 0.2s;
min-height: var(--touch-target-comfortable);
touch-action: manipulation;
transition: border-color var(--transition-base), background-color var(--transition-base);
}
.playerItem:hover {
background: #555;
}
.playerItem:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary);
}
.playerItem:active {
background: var(--color-secondary);
border-color: var(--color-primary);
}
@@ -16,12 +16,23 @@ export function ProgressIndicator({
const activeIndex = Math.min(Math.max(currentStep, 1), totalSteps) - 1;
return (
<div className={styles['progress-indicator']} style={style}>
<div
className={styles['progress-indicator']}
style={style}
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={totalSteps}
aria-label={`Schritt ${currentStep} von ${totalSteps}`}
>
{Array.from({ length: totalSteps }, (_, index) => {
const isActive = index === activeIndex;
const className = isActive
? `${styles['progress-dot']} ${styles['active']}`
: styles['progress-dot'];
const isCompleted = index < activeIndex;
const className = [
styles['progress-dot'],
isCompleted ? styles.completed : '',
isActive ? styles.active : '',
].filter(Boolean).join(' ');
return <span key={index} className={className} />;
})}
@@ -1,66 +1,81 @@
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
padding: var(--space-lg);
padding-top: calc(var(--space-lg) + var(--safe-area-top));
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
}
.modalContent {
width: min(420px, 100%);
background: var(--color-surface);
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 16px;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.06);
padding: var(--space-xl);
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--space-md);
}
.modalTitle {
margin: 0;
font-size: 1.1rem;
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--color-text);
}
.modalInput {
width: 100%;
min-height: var(--touch-target-comfortable);
font-size: max(16px, var(--font-size-base));
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 8px;
justify-content: stretch;
gap: var(--space-md);
flex-wrap: wrap;
}
.actionButton {
border: 1px solid var(--color-border);
background: var(--color-secondary);
color: var(--color-text);
border-radius: var(--radius-md);
padding: 10px 14px;
cursor: pointer;
min-height: var(--touch-target-min);
}
.actionButton:hover,
.actionButton:focus {
border-color: var(--color-primary);
outline: none;
}
.confirmButton {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
.modalActionButton {
flex: 1;
min-width: 120px;
}
.errorText {
color: var(--color-danger);
font-size: 0.9rem;
font-size: var(--font-size-sm);
margin: 0;
font-weight: 500;
}
@media (max-width: 600px) {
.modalOverlay {
padding: 16px;
padding-top: calc(16px + var(--safe-area-top));
padding-bottom: calc(16px + var(--safe-area-bottom));
align-items: stretch;
}
.modalContent {
width: 100%;
flex: 1;
max-height: none;
justify-content: center;
}
.modalActions {
flex-direction: column;
}
.modalActionButton {
width: 100%;
min-width: 0;
}
}
@@ -2,6 +2,7 @@ import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import styles from './NameEntryModal.module.css';
import newGameStyles from '../NewGame.module.css';
import { Button } from '@lib/ui/Button';
interface NameEntryModalProps {
open: boolean;
@@ -37,6 +38,11 @@ export function NameEntryModal({
if (!open) return null;
const handleConfirm = () => {
const validationError = onConfirm(name);
if (validationError) setError(validationError);
};
return (
<div className={styles.modalOverlay} onClick={onClose} role="dialog" aria-modal="true" aria-labelledby={`${inputId}-title`}>
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
@@ -52,7 +58,7 @@ export function NameEntryModal({
placeholder={placeholder}
autoComplete="off"
autoCapitalize="words"
spellCheck={false}
spellcheck={false}
enterKeyHint={enterKeyHint}
onInput={(e: Event) => {
setName((e.target as HTMLInputElement).value);
@@ -61,27 +67,19 @@ export function NameEntryModal({
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
const validationError = onConfirm(name);
if (validationError) setError(validationError);
handleConfirm();
}}
/>
{error && <p className={styles.errorText}>{error}</p>}
<div className={styles.modalActions}>
<button type="button" className={styles.actionButton} onClick={onClose}>
<Button variant="secondary" size="medium" onClick={onClose} className={styles.modalActionButton}>
Abbrechen
</button>
<button
type="button"
className={`${styles.actionButton} ${styles.confirmButton}`}
onClick={() => {
const validationError = onConfirm(name);
if (validationError) setError(validationError);
}}
>
</Button>
<Button variant="primary" size="medium" onClick={handleConfirm} className={styles.modalActionButton}>
Übernehmen
</button>
</Button>
</div>
</div>
</div>
@@ -9,8 +9,10 @@ import {
} from '@lib/domain/constants';
import { PlayerSelectModal } from '../steps/PlayerSelectModal';
import { NameEntryModal } from './NameEntryModal';
import type { NewGameData, NewGameStep } from '@lib/domain/types';
import { WizardNav } from './WizardNav';
import { WizardStepForm } from './WizardStepForm';
import { WizardSummary } from './WizardSummary';
interface PlayerNameStepProps {
stepNumber: number;
@@ -19,6 +21,7 @@ interface PlayerNameStepProps {
placeholder: string;
inputId: string;
playerInputClassName: string;
stepClassName?: string;
playerNameHistory: string[];
onNext: (name: string) => void;
onCancel: () => void;
@@ -31,6 +34,8 @@ interface PlayerNameStepProps {
showMorePlayersModal?: boolean;
prefillNameEntry?: boolean;
onCreateTemporaryName?: (name: string) => void;
data?: NewGameData;
currentStep?: NewGameStep;
}
export function PlayerNameStep({
@@ -40,6 +45,7 @@ export function PlayerNameStep({
placeholder,
inputId,
playerInputClassName,
stepClassName,
playerNameHistory,
onNext,
onCancel,
@@ -52,6 +58,8 @@ export function PlayerNameStep({
showMorePlayersModal = false,
prefillNameEntry = true,
onCreateTemporaryName,
data,
currentStep,
}: PlayerNameStepProps) {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
@@ -122,7 +130,13 @@ export function PlayerNameStep({
ariaLabel={ARIA_LABELS.PLAYER_INPUT(label)}
title={title}
currentStep={stepNumber}
stepClassName={stepClassName}
onSubmit={handleSubmit}
summary={
data && currentStep ? (
<WizardSummary data={data} currentStep={currentStep} />
) : undefined
}
footer={
<WizardNav
onBack={onCancel}
@@ -9,8 +9,10 @@ interface WizardStepFormProps {
title: string;
currentStep: number;
onSubmit?: (e: Event) => void;
summary?: ComponentChildren;
children: ComponentChildren;
footer: ComponentChildren;
stepClassName?: string;
}
export function WizardStepForm({
@@ -18,19 +20,29 @@ export function WizardStepForm({
title,
currentStep,
onSubmit,
summary,
children,
footer,
stepClassName,
}: WizardStepFormProps) {
return (
<form className={styles['new-game-form']} onSubmit={onSubmit} aria-label={ariaLabel} autoComplete="off">
<div className={styles['form-header']}>
<div className={styles['screen-title']}>{title}</div>
<ProgressIndicator currentStep={currentStep} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }} />
</div>
<div className={styles.wizardShell}>
<form
className={`${styles['new-game-form']} ${stepClassName ?? ''}`}
onSubmit={onSubmit}
aria-label={ariaLabel}
autoComplete="off"
>
<div className={styles['form-header']}>
<div className={styles['screen-title']}>{title}</div>
<ProgressIndicator currentStep={currentStep} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }} />
{summary}
</div>
<div className={styles['form-content']}>{children}</div>
<div className={styles['form-content']}>{children}</div>
<div className={styles['form-footer']}>{footer}</div>
</form>
<div className={styles['form-footer']}>{footer}</div>
</form>
</div>
);
}
@@ -0,0 +1,134 @@
import { h } from 'preact';
import type { NewGameData, NewGameStep } from '@lib/domain/types';
import { GAME_TYPES, RACE_TO_INFINITY } from '@lib/domain/constants';
import styles from '../NewGame.module.css';
const STEP_ORDER: NewGameStep[] = [
'player1',
'player2',
'player3',
'gameType',
'raceTo',
'breakRule',
'breakOrder',
];
function stepIndex(step: NewGameStep): number {
if (!step) return -1;
return STEP_ORDER.indexOf(step);
}
function isPast(current: NewGameStep, target: NewGameStep): boolean {
return stepIndex(current) > stepIndex(target);
}
function getPlayerNames(data: NewGameData): string[] {
return [data.player1, data.player2, data.player3].filter(Boolean);
}
function getPriorPlayerSummary(data: NewGameData, currentStep: NewGameStep): string | null {
if (currentStep === 'player1') return null;
let names: string[] = [];
if (currentStep === 'player2') {
names = data.player1 ? [data.player1] : [];
} else if (currentStep === 'player3') {
names = [data.player1, data.player2].filter(Boolean);
} else if (isPast(currentStep, 'player3')) {
names = getPlayerNames(data);
}
return names.length > 0 ? names.join(' vs ') : null;
}
function getGameTypeLabel(gameType: NewGameData['gameType']): string | null {
if (!gameType) return null;
const match = GAME_TYPES.find((item) => item.value === gameType);
return match?.label ?? gameType;
}
function formatRaceTo(raceTo: string): string | null {
if (!raceTo) return null;
if (raceTo === RACE_TO_INFINITY || raceTo === 'Infinity') return 'Endlos';
return `Race to ${raceTo}`;
}
function formatBreakRule(rule: NewGameData['breakRule']): string | null {
if (rule === 'winnerbreak') return 'Winnerbreak';
if (rule === 'wechselbreak') return 'Wechselbreak';
return null;
}
function formatBreakOrder(data: NewGameData, breakFirst?: number): string | null {
const idx =
breakFirst ??
(typeof data.breakFirst === 'number' ? data.breakFirst : 0);
if (!idx) return null;
const name = getPlayerNames(data)[idx - 1];
return name ? `${name} breakt zuerst` : null;
}
interface SummaryItem {
label: string;
value: string;
}
interface WizardSummaryProps {
data: NewGameData;
currentStep: NewGameStep;
breakFirst?: number;
}
export function WizardSummary({ data, currentStep, breakFirst }: WizardSummaryProps) {
const items: SummaryItem[] = [];
const players = getPriorPlayerSummary(data, currentStep);
if (players) {
items.push({ label: 'Spieler', value: players });
}
if (isPast(currentStep, 'gameType')) {
const gameType = getGameTypeLabel(data.gameType);
if (gameType) items.push({ label: 'Spielart', value: gameType });
}
if (isPast(currentStep, 'raceTo')) {
const raceTo = formatRaceTo(data.raceTo);
if (raceTo) items.push({ label: 'Race to', value: raceTo });
}
if (isPast(currentStep, 'breakRule')) {
const breakRule = formatBreakRule(data.breakRule);
if (breakRule) items.push({ label: 'Break-Regel', value: breakRule });
}
if (currentStep === 'breakOrder') {
const breakOrder = formatBreakOrder(data, breakFirst);
if (breakOrder) items.push({ label: 'Break-Reihenfolge', value: breakOrder });
}
if (items.length === 0) {
if (currentStep === 'player1') {
return (
<div className={styles['wizard-summary']} aria-live="polite">
<p className={styles['wizard-summary-hint']}>Spieler und Einstellungen werden hier angezeigt.</p>
</div>
);
}
return null;
}
return (
<div className={styles['wizard-summary']} aria-label="Bisherige Auswahl" aria-live="polite">
<dl className={styles['wizard-summary-list']}>
{items.map((item) => (
<div key={item.label} className={styles['wizard-summary-row']}>
<dt className={styles['wizard-summary-label']}>{item.label}</dt>
<dd className={styles['wizard-summary-value']}>{item.value}</dd>
</div>
))}
</dl>
</div>
);
}
@@ -2,8 +2,10 @@ import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import type { BreakRule } from '@lib/domain/types';
import type { NewGameData } from '@lib/domain/types';
import { WizardNav } from '../components/WizardNav';
import { WizardStepForm } from '../components/WizardStepForm';
import { WizardSummary } from '../components/WizardSummary';
interface BreakOrderStepProps {
players: string[];
@@ -12,9 +14,10 @@ interface BreakOrderStepProps {
onCancel: () => void;
initialFirst?: number;
initialSecond?: number;
data: NewGameData;
}
export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst = 1, initialSecond }: BreakOrderStepProps) => {
export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst = 1, initialSecond, data }: BreakOrderStepProps) => {
const playerCount = players.filter(Boolean).length;
const [first, setFirst] = useState<number>(initialFirst);
const [second, setSecond] = useState<number | undefined>(initialSecond);
@@ -60,7 +63,9 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
ariaLabel="Break-Reihenfolge wählen"
title="Wer hat den ersten Anstoss?"
currentStep={7}
stepClassName={styles['step-breakOrder']}
onSubmit={handleSubmit}
summary={<WizardSummary data={data} currentStep="breakOrder" breakFirst={first} />}
footer={
<WizardNav
onBack={onCancel}
@@ -76,10 +81,9 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
<button
key={`first-${idx}`}
type="button"
className={`${styles['quick-pick-btn']} ${first === (idx + 1) ? styles['selected'] : ''}`}
className={`${styles['quick-pick-btn']} ${styles['choice-btn']} ${first === (idx + 1) ? styles['selected'] : ''}`}
onClick={() => handleFirst(idx + 1)}
aria-label={`Zuerst: ${name}`}
style={{ minWidth: 160, minHeight: 64 }}
>
{name}
</button>
@@ -94,10 +98,9 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
<button
key={`second-${idx}`}
type="button"
className={`${styles['quick-pick-btn']} ${second === (idx + 1) ? styles['selected'] : ''}`}
className={`${styles['quick-pick-btn']} ${styles['choice-btn']} ${second === (idx + 1) ? styles['selected'] : ''}`}
onClick={() => handleSecond(idx + 1)}
aria-label={`Zweites Break: ${name}`}
style={{ minWidth: 160, minHeight: 64 }}
>
{name}
</button>
@@ -2,16 +2,19 @@ import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import type { BreakRule } from '@lib/domain/types';
import type { NewGameData } from '@lib/domain/types';
import { WizardNav } from '../components/WizardNav';
import { WizardStepForm } from '../components/WizardStepForm';
import { WizardSummary } from '../components/WizardSummary';
interface BreakRuleStepProps {
onNext: (rule: BreakRule) => void;
onCancel: () => void;
initialValue?: BreakRule;
data: NewGameData;
}
export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }: BreakRuleStepProps) => {
export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak', data }: BreakRuleStepProps) => {
const [rule, setRule] = useState<BreakRule>(initialValue ?? 'winnerbreak');
const handleSelect = (nextRule: BreakRule) => {
@@ -29,7 +32,9 @@ export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }
ariaLabel="Break-Regel wählen"
title="Break-Regel wählen"
currentStep={6}
stepClassName={styles['step-breakRule']}
onSubmit={handleSubmit}
summary={<WizardSummary data={data} currentStep="breakRule" />}
footer={<WizardNav onBack={onCancel} />}
>
<div className={styles['quick-picks-row']}>
@@ -40,10 +45,9 @@ export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }
<button
key={opt.key}
type="button"
className={`${styles['quick-pick-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
className={`${styles['quick-pick-btn']} ${styles['choice-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
onClick={() => handleSelect(opt.key as BreakRule)}
aria-label={`Break-Regel wählen: ${opt.label}`}
style={{ minWidth: 160, minHeight: 64 }}
>
{opt.label}
</button>
@@ -2,16 +2,19 @@ import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import { GAME_TYPES } from '@lib/domain/constants';
import type { NewGameData } from '@lib/domain/types';
import { WizardNav } from '../components/WizardNav';
import { WizardStepForm } from '../components/WizardStepForm';
import { WizardSummary } from '../components/WizardSummary';
interface GameTypeStepProps {
onNext: (type: string) => void;
onCancel: () => void;
initialValue?: string;
data: NewGameData;
}
export const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeStepProps) => {
export const GameTypeStep = ({ onNext, onCancel, initialValue = '', data }: GameTypeStepProps) => {
const [gameType, setGameType] = useState(initialValue);
const handleSelect = (selectedType: string) => {
@@ -31,7 +34,9 @@ export const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeSt
ariaLabel="Spielart auswählen"
title="Spielart auswählen"
currentStep={4}
stepClassName={styles['step-gameType']}
onSubmit={handleSubmit}
summary={<WizardSummary data={data} currentStep="gameType" />}
footer={<WizardNav onBack={onCancel} nextDisabled={!gameType} />}
>
<div className={styles['game-type-selection']}>
@@ -8,15 +8,34 @@ interface PlayerSelectModalProps {
}
export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectModalProps) => (
<div className={modalStyles.modalOverlay} onClick={onClose}>
<div
className={modalStyles.modalOverlay}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="player-select-title"
>
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}>
<h3>Alle Spieler</h3>
<button className={modalStyles.closeButton} onClick={onClose}>×</button>
<h3 id="player-select-title">Alle Spieler</h3>
<button
type="button"
className={modalStyles.closeButton}
onClick={onClose}
aria-label="Schließen"
>
×
</button>
</div>
<div className={modalStyles.playerList}>
{players.map(player => (
<button key={player} className={modalStyles.playerItem} onClick={() => onSelect(player)}>
<button
key={player}
type="button"
className={modalStyles.playerItem}
onClick={() => onSelect(player)}
aria-label={`Spieler wählen: ${player}`}
>
{player}
</button>
))}
@@ -24,5 +43,3 @@ export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectMo
</div>
</div>
);
@@ -6,17 +6,20 @@ import {
RACE_TO_DEFAULT,
RACE_TO_INFINITY,
} from '@lib/domain/constants';
import type { NewGameData } from '@lib/domain/types';
import { WizardNav } from '../components/WizardNav';
import { WizardStepForm } from '../components/WizardStepForm';
import { WizardSummary } from '../components/WizardSummary';
interface RaceToStepProps {
onNext: (raceTo: string | number) => void;
onCancel: () => void;
initialValue?: string | number;
gameType?: string;
data: NewGameData;
}
export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToStepProps) => {
export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType, data }: RaceToStepProps) => {
const quickPicks = [...RACE_TO_QUICK_PICKS];
const defaultValue = RACE_TO_DEFAULT;
const [raceTo, setRaceTo] = useState<string | number>(
@@ -57,7 +60,9 @@ export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: Ra
ariaLabel="Race To auswählen"
title="Race To auswählen"
currentStep={5}
stepClassName={styles['step-raceTo']}
onSubmit={handleSubmit}
summary={<WizardSummary data={data} currentStep="raceTo" />}
footer={<WizardNav onBack={onCancel} nextDisabled={String(raceTo).trim() === ''} />}
>
<div className={styles['endlos-container']}>
+37 -57
View File
@@ -1,10 +1,10 @@
/* Use CSS custom properties from global design system */
.button {
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 600;
transition: var(--transition-base);
font-family: inherit;
transition: background-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-fast), opacity var(--transition-base);
touch-action: manipulation;
display: inline-flex;
align-items: center;
@@ -12,25 +12,9 @@
gap: var(--space-sm);
text-decoration: none;
user-select: none;
font-family: inherit;
position: relative;
overflow: hidden;
}
.button::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%);
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.button:hover::before {
transform: translateX(100%);
-webkit-user-select: none;
width: 100%;
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.button:focus-visible {
@@ -38,53 +22,47 @@
outline-offset: 2px;
}
.button:active {
transform: translateY(1px);
.button:active:not(.disabled) {
transform: scale(0.97);
opacity: 0.92;
}
/* Variants */
.primary {
background: var(--color-primary);
color: white;
box-shadow: var(--shadow-md);
background: linear-gradient(180deg, #ffb74d 0%, var(--color-primary) 100%);
color: #fff;
box-shadow: var(--shadow-sm), 0 0 0 1px rgba(255, 152, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.primary:hover:not(.disabled) {
.primary:not(.disabled):active {
background: var(--color-primary-hover);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
}
.secondary {
background: var(--color-secondary);
background: linear-gradient(180deg, #3a3a3a 0%, var(--color-secondary) 100%);
color: var(--color-text);
box-shadow: var(--shadow-sm);
border: 2px solid var(--color-border);
}
.secondary:hover:not(.disabled) {
.secondary:not(.disabled):active {
background: var(--color-secondary-hover);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.danger {
background: var(--color-danger);
color: white;
box-shadow: var(--shadow-md);
background: linear-gradient(180deg, #ef5350 0%, var(--color-danger) 100%);
color: #fff;
}
.danger:hover:not(.disabled) {
background: #ef5350;
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
.danger:not(.disabled):active {
background: #d32f2f;
}
/* Sizes with improved touch targets for tablets */
/* Sizes */
.small {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
min-height: var(--touch-target-min);
border-radius: var(--radius-sm);
border-radius: var(--radius-md);
}
.medium {
@@ -96,39 +74,41 @@
.large {
padding: var(--space-lg) var(--space-xl);
font-size: var(--font-size-lg);
min-height: 56px;
font-size: var(--font-size-xl);
min-height: var(--touch-target-large);
border-radius: var(--radius-lg);
font-weight: 700;
}
/* Tablet-specific size adjustments */
@media (min-width: 768px) and (max-width: 1024px) {
/* Tablet */
@media (min-width: 768px) {
.small {
min-height: var(--touch-target-comfortable);
padding: var(--space-md) var(--space-lg);
font-size: var(--font-size-base);
}
.medium {
min-height: var(--touch-target-comfortable);
min-height: var(--touch-target-large);
padding: var(--space-lg) var(--space-xl);
font-size: var(--font-size-lg);
}
.large {
min-height: 64px;
min-height: 72px;
padding: var(--space-xl) var(--space-xxl);
font-size: var(--font-size-xl);
font-size: var(--font-size-xxl);
}
}
/* States */
.disabled {
opacity: 0.5;
opacity: 0.45;
cursor: not-allowed;
transform: none !important;
}
.disabled:hover::before {
transform: translateX(-100%);
}
@media (prefers-reduced-motion: reduce) {
.button:active:not(.disabled) {
transform: none;
}
}
+2
View File
@@ -8,6 +8,7 @@ export function Button({
disabled = false,
children,
onClick,
className = '',
'aria-label': ariaLabel,
...rest
}: ButtonProps) {
@@ -16,6 +17,7 @@ export function Button({
styles[variant],
styles[size],
disabled && styles.disabled,
className,
].filter(Boolean).join(' ');
return (
+22 -15
View File
@@ -1,21 +1,22 @@
.card {
border-radius: var(--border-radius);
transition: var(--transition);
border-radius: var(--radius-lg);
transition: border-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-fast);
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.default {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--color-border);
}
.elevated {
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.outlined {
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.2);
border: 2px solid var(--color-border);
}
.clickable {
@@ -23,11 +24,12 @@
border: none;
text-align: left;
width: 100%;
font-family: inherit;
color: inherit;
}
.clickable:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
.clickable:active {
transform: scale(0.99);
}
.clickable:focus-visible {
@@ -35,19 +37,24 @@
outline-offset: 2px;
}
/* Padding variants */
.padding-none {
padding: 0;
}
.padding-small {
padding: 8px;
padding: var(--space-sm);
}
.padding-medium {
padding: 16px;
padding: var(--space-md);
}
.padding-large {
padding: 24px;
}
padding: var(--space-lg);
}
@media (prefers-reduced-motion: reduce) {
.clickable:active {
transform: none;
}
}
+22 -13
View File
@@ -9,9 +9,10 @@
.content {
flex: 1;
max-width: 1200px;
max-width: 1100px;
margin: 0 auto;
padding: var(--space-md);
padding-bottom: calc(var(--space-md) + var(--safe-area-bottom));
width: 100%;
display: flex;
flex-direction: column;
@@ -26,26 +27,34 @@
flex-direction: column;
overflow: hidden;
min-height: 0;
animation: screenFadeIn var(--transition-slow) ease-out;
}
/* Tablet optimizations */
@media (min-width: 768px) and (max-width: 1024px) {
@keyframes screenFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.screen {
animation: none;
}
}
@media (min-width: 768px) {
.content {
padding: var(--space-lg) var(--space-xl);
max-width: 900px;
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
}
}
/* Large tablet and small desktop */
@media (min-width: 1025px) {
.content {
padding: var(--space-xl);
}
}
/* Mobile adjustments */
@media (max-width: 767px) {
.content {
padding: var(--space-sm) var(--space-md);
padding-bottom: calc(var(--space-sm) + var(--safe-area-bottom));
}
}
}
+97 -31
View File
@@ -1,66 +1,132 @@
/* Consolidated modal styles for all modals */
/* Consolidated modal styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
inset: 0;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000;
padding: var(--space-lg);
padding-top: calc(var(--space-lg) + var(--safe-area-top));
padding-bottom: calc(var(--space-lg) + var(--safe-area-bottom));
padding-left: calc(var(--space-lg) + var(--safe-area-left));
padding-right: calc(var(--space-lg) + var(--safe-area-right));
}
.modal.show {
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background-color: #2a2a2a;
padding: 20px;
border-radius: 10px;
width: 90%;
max-width: 500px;
background-color: var(--color-surface);
padding: var(--space-xl);
border-radius: var(--radius-xl);
width: min(500px, 100%);
max-height: calc(var(--app-height) - var(--space-xl) * 2);
overflow-y: auto;
position: relative;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.modal-title {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--color-text);
}
.close-button {
font-size: 24px;
font-size: var(--font-size-xxl);
line-height: 1;
cursor: pointer;
color: #888;
background: none;
border: none;
color: var(--color-text-muted);
background: var(--color-secondary);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
min-width: var(--touch-target-min);
min-height: var(--touch-target-min);
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
flex-shrink: 0;
}
.close-button:hover {
color: white;
.close-button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
color: var(--color-text);
}
.modal-body {
margin-bottom: 20px;
margin-bottom: var(--space-lg);
}
.modal-message {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
line-height: 1.5;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-direction: column;
justify-content: stretch;
gap: var(--space-md);
}
@media (min-width: 480px) {
.modal-footer {
flex-direction: row;
justify-content: flex-end;
flex-wrap: wrap;
}
}
.modal-button {
padding: 8px 16px;
min-height: var(--touch-target-large);
padding: var(--space-md) var(--space-xl);
font-size: var(--font-size-lg);
border: none;
border-radius: 5px;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 16px;
font-size: var(--font-size-base);
font-weight: 600;
font-family: inherit;
touch-action: manipulation;
flex: 1;
min-width: 120px;
}
.modal-button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.modal-button.cancel {
background-color: #444;
color: white;
background-color: var(--color-secondary);
color: var(--color-text);
border: 2px solid var(--color-border);
}
.modal-button.confirm {
background-color: #e74c3c;
color: white;
background-color: var(--color-danger);
color: #fff;
}
.modal-button.confirm:active {
background-color: #d32f2f;
}
.modal-button.cancel:active {
background-color: var(--color-secondary-hover);
}
.modal-button:hover {
opacity: 0.9;
}
+3 -3
View File
@@ -19,14 +19,14 @@ const Modal = ({ open, title, message, onCancel, onConfirm }: ModalProps) => {
<div className={styles['modal-content']}>
<div className={styles['modal-header']}>
<span className={styles['modal-title']} id="modal-title">{title}</span>
<button className={styles['close-button']} onClick={onCancel} aria-label="Schließen">×</button>
<button type="button" className={styles['close-button']} onClick={onCancel} aria-label="Schließen">×</button>
</div>
<div className={styles['modal-body']}>
<div className={styles['modal-message']}>{message}</div>
</div>
<div className={styles['modal-footer']}>
<button className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onCancel} aria-label="Abbrechen">Abbrechen</button>
<button className={styles['modal-button'] + ' ' + styles['confirm']} onClick={onConfirm} aria-label="Löschen">Löschen</button>
<button type="button" className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onCancel} aria-label="Abbrechen">Abbrechen</button>
<button type="button" className={styles['modal-button'] + ' ' + styles['confirm']} onClick={onConfirm} aria-label="Löschen">Löschen</button>
</div>
</div>
</div>
+2 -2
View File
@@ -17,13 +17,13 @@ const ValidationModal = ({ open, message, onClose }: ValidationModalProps) => {
<div className={styles['modal-content']}>
<div className={styles['modal-header']}>
<span className={styles['modal-title']} id="validation-modal-title">Fehler</span>
<button className={styles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
<button type="button" className={styles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
</div>
<div className={styles['modal-body']}>
<div className={styles['modal-message']}>{message}</div>
</div>
<div className={styles['modal-footer']}>
<button className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onClose} aria-label="OK">OK</button>
<button type="button" className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onClose} aria-label="OK">OK</button>
</div>
</div>
</div>