10 Commits

Author SHA1 Message Date
Frank Schwenk 678977ab23 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
2026-09-21 17:59:31 +02:00
Frank Schwenk 3327f6e19e chore(release): 2.1.1
Refs #30

Made-with: Cursor
2026-04-16 13:20:48 +02:00
Frank Schwenk a288405ca8 fix(pwa): allow rotation and request fullscreen
Remove forced portrait orientation and request fullscreen display mode.

Made-with: Cursor
2026-04-16 13:19:27 +02:00
Frank Schwenk 5bdea62a9f feat: add pwa manifest and service worker
Wire up a production service worker and web app manifest with install metadata and icon assets.
Bump project version to 2.1.0 and register the worker from the main page for offline-ready behavior.

Made-with: Cursor
2026-04-14 15:34:10 +02:00
Frank Schwenk 55cba1495f fix: auto-advance on new-game selections
Advance the wizard immediately when selecting game type, break rule, race-to quick picks, and completed break-order choices for a consistent tap-first flow.

Made-with: Cursor
2026-04-14 15:28:52 +02:00
Frank Schwenk ed7c6232c1 refactor: streamline new-game player selection ux
Consolidate new-game wizard steps into reusable components and remove legacy duplicate files.
Replace inline player inputs with a chip-first flow and minimal name-entry modal, while improving compact-screen spacing and step-specific selection behavior.

Made-with: Cursor
2026-04-14 15:22:56 +02:00
Frank Schwenk 5deb38ebb7 test: add landscape viewport matrix keyboard flow coverage
Add Playwright viewport matrix coverage for Android-tablet-like landscape sizes, including reduced-height keyboard simulation during player input steps and wizard progression checks. Refs #30.

Made-with: Cursor
2026-04-04 11:32:59 +02:00
Frank Schwenk 586e5f26bd fix: make wizard progression explicit and consistent
Require explicit Weiter confirmation after selection in game type, race-to, break rule, and break order steps to prevent accidental auto-advance and keep step behavior predictable. Refs #30.

Made-with: Cursor
2026-04-04 11:32:23 +02:00
Frank Schwenk 9bf4c20f11 fix: improve Android tablet keyboard-safe layout
Use dynamic viewport sizing and keyboard-aware spacing to keep new-game inputs and navigation reachable in PWA landscape mode on Android tablets. Refs #30.

Made-with: Cursor
2026-04-04 11:31:31 +02:00
Frank Schwenk f8d895ab21 Add Docker Compose configuration for deployment
- Add compose.yml with nginx service configuration
- Configure Traefik labels for reverse proxy routing
- Set up service to serve static files from ./dist directory
- Configure SSL/TLS with automatic certificate resolution
- Use external Traefik network for integration
- Service accessible at score.bsc-gp.de domain

Refs #30
2025-11-30 20:33:29 +01:00
104 changed files with 4336 additions and 3972 deletions
-25
View File
@@ -1,25 +0,0 @@
# Cursor Rules for BSC Score (Astro Web App)
## Summary of Rule Adaptations
This project uses the Cursor rules system to enforce best practices and workflow discipline. The following summarizes the rule adaptations, removals, and additions for this project:
### Enforced Rules
- All general development, git workflow, Gitea usage, discipline, and best practices rules are enforced.
- **best-practices-astro.mdc** is enforced, as the project is built with Astro.
- Type safety and code quality rules are relevant due to the use of TypeScript (via Astro's strict config).
### Not Relevant / Not Present
- No CLI, API, or data science rules are present or required, as this is a web application only.
- No rules were removed, as all present rules are applicable to the project domain.
### Project-Specific Adaptations
- See `.cursor/rules/project-adaptations.mdc` for a detailed rationale and documentation of all rule adaptations and exceptions.
### Maintenance
- When central rules are updated, all `.mdc` files in `.cursor/rules` should be re-reviewed and project-specific adaptations re-applied.
- If any conflicts or ambiguities arise, manual review will be requested.
---
This README documents the current state of rule enforcement and adaptation for the BSC Score project. For further details, consult the individual rule files in `.cursor/rules/` and the project adaptation summary.
-15
View File
@@ -1,15 +0,0 @@
---
description:
globs:
alwaysApply: true
---
- Write clean, readable, and maintainable code
- Follow SOLID principles and DRY (Don't Repeat Yourself)
- Use meaningful variable and function names that explain their purpose
- Add comments for complex logic, but prefer self-documenting code
- Handle errors gracefully with proper error handling
- Implement proper logging and debugging practices
- Use consistent indentation and formatting
- Avoid deep nesting - prefer early returns and guard clauses
- Keep functions small and focused on single responsibilities
- Use type safety when available (TypeScript, JSDoc, etc.)
-40
View File
@@ -1,40 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# /dev Folder Rules
## Resource Management Protocol
- Resources in `/dev` are staging materials that require processing before production use
- NEVER automatically copy resources from `/dev` to production locations
- ONLY move or process `/dev` resources when explicitly instructed by user
- Maintain original files in `/dev` unless specifically told to remove them
## Quick and Dirty (QnD) Scripts Protocol
- `/dev` is the designated location for quick prototyping scripts
- QnD scripts in `/dev` don't need to follow full production code standards
- Focus on functionality over code quality for QnD scripts
- Document script purpose with minimal comments
- Use descriptive filenames that indicate script function
- QnD scripts should be marked clearly (e.g., `qnd_` prefix or `.qnd.` in filename)
## Proof of Concept (POC) Protocol
- All POC development happens exclusively in `/dev`
- POC code should be isolated from production codebase
- POC code can be experimental and doesn't require full error handling
- When POC is approved for production, create separate implementation outside `/dev`
## Temporary Files Management
- Use `/dev` for all temporary files created during development process
- User-created temporary files can be placed directly in `/dev`
## Safety and Cleanup Rules
- NEVER delete files from `/dev` without explicit user permission
- Ask before moving files out of `/dev` to production locations
- Maintain clear separation between `/dev` content and production code
## Exclusions and Restrictions
- Production builds should NEVER reference files directly from `/dev`
- `/dev` paths should not be hardcoded in production configuration
- `/dev` is not for production dependencies or critical system files
-46
View File
@@ -1,46 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Development Discipline Rules
## Precision Over Presumption
- NEVER add functionality that wasn't explicitly requested
- NEVER speculate beyond the current scope of work
- NEVER assume requirements or extend beyond stated needs
- If uncertain about any aspect, state "I don't know" rather than guessing
- Stick to the exact scope defined in the request
## File Modification Protocol
- NEVER modify files if the user's prompt ends with a question mark (?)
- NEVER assume permission to refactor existing code
- Only modify files when explicitly instructed to do so
- Always ask for permission before making structural changes
## Code Quality Enforcement (Zero Tolerance)
- Every line of code must be bug-free, secure, and maintainable
- Violations of DRY (Don't Repeat Yourself) are unacceptable
- Violations of KISS (Keep It Simple, Stupid) are unacceptable
- Code must be self-documenting with meaningful names
- Comments should explain "why" not "what"
- Names must reveal intent clearly
## Technical Standards (Non-Negotiable)
- Correctness takes priority over performance
- Readability is paramount in all code decisions
- Every solution must include:
- Comprehensive error handling
- Input validation
- Consideration of failure modes
- Edge case handling
- Design pattern choices must be justified (e.g., "Using Factory pattern here because...")
## Violation Response Protocol
- If a rule conflicts with a user request:
1. Clearly state the specific conflict
2. Refuse the action until the conflict is resolved
3. Provide alternative approaches if possible
- Example responses:
- "This would violate DRY principle because... Alternative approach: ..."
- "Cannot modify files - prompt ends with '?'. Did you mean to ask a question instead?"
-40
View File
@@ -1,40 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Git Workflow Rules
## Zero Autonomy on Git Operations
- NEVER stage, commit, or push code without explicit user instruction
- NEVER assume permission to perform git operations
- If asked about git status, only provide information - do not take action
## Commit Protocol (When Explicitly Asked)
When user requests a commit, follow this exact sequence:
1. **Review Phase**:
- List ALL modified files with their status (M/A/D)
- List ALL added files that will be staged
- List ALL deleted files
- Check for any files that should be included but aren't staged
2. **Summary Phase**:
- Generate commit title (≤50 characters, imperative mood)
- Create detailed bulleted summary including:
- Which files were changed and how
- What logic was modified/added/removed
- Impact of changes on functionality
- Purpose statement (e.g., "Fix race condition" → "Patches race condition in user authentication flow")
3. **Confirmation Phase**:
- Present the complete commit message for approval
- Show exactly what will be committed
- Wait for explicit confirmation before executing
- If user rejects, ask for specific changes to the commit message
## Git Safety Rules
- If no changes detected when asked to commit: "No changes detected. Aborting as per Git rules."
- If conflicts exist, report them but do not resolve without instruction
- Never force push or use destructive git operations
- Always use conventional commit format when generating messages
-43
View File
@@ -1,43 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Gitea Usage Rules
## Purpose
Establish disciplined, transparent, and user-aligned protocols for using Gitea, especially regarding issue management and traceability between code and issues.
## Rules
1. **Stick to the Current Selected Issue**
- Always work with a clearly selected, active Gitea issue.
- All code changes must be associated with the currently selected issue.
- If you need to switch issues, document the switch and update the new issue context before proceeding.
2. **Update Issues After Commits**
- After every commit, update the relevant Gitea issue(s) with a summary of the changes made.
- Include references to commit hashes and affected files or features.
- If the commit resolves or partially addresses the issue, state this explicitly in the issue update.
3. **Link Commits in Issues**
- Every commit message must reference the relevant Gitea issue by its number (e.g., `Fixes #42` or `Refs #42`).
- When updating an issue, include direct links to the commit(s) in Gitea for traceability.
- Use Gitea's markdown syntax to create clickable links to commits and related issues.
4. **Issue-Driven Workflow**
- Do not make code changes without an associated issue.
- If a new task arises, create a Gitea issue before starting work.
- Use issues to track discussions, decisions, and progress.
5. **Responsibility for Issue Hygiene**
- Keep issue status, labels, and assignees up to date.
- Close issues only when fully resolved and after confirming with all stakeholders if needed.
- Document any ambiguity or blockers in the issue before pausing or switching context.
6. **Current Issue Tracking via .gitea File**
- A file named `.gitea` can exist in the project root, containing the URL of the currently selected Gitea issue (e.g., `https://gitea.schwenk.online/froxxxy/vfbred/issues/3`).
- The AI assistant will read this file to determine the current project name and issue number for all Gitea-related operations.
## Rationale
These rules ensure that all work is traceable, communication is clear, and project history is auditable. They foster accountability and make onboarding, reviews, and audits more efficient.
-39
View File
@@ -1,39 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Cursor Rules Automated Installation & Configuration Guide
You are to follow these instructions after this `rules` folder has been copied into the project's `.cursor` directory. Your goal is to configure and adapt the rules for this specific project with minimal manual intervention.
## 1. Review Rule Files
- Read all `.mdc` files in `.cursor/rules`.
- Identify any rules that are not relevant to this project's framework, language, or domain.
- List which files should be removed, renamed, or adapted, and propose new rules if needed.
## 2. Adaptation
- For each rule that requires adaptation (e.g., framework-specific best practices), generate a new `.mdc` file (e.g., `best-practices-react.mdc`, `best-practices-python.mdc`) with appropriate content.
- Remove or archive irrelevant rules (e.g., Astro-specific rules in a non-Astro project).
- If the project is not web-based, create domain-specific rules (e.g., `cli-ux.mdc`, `api-design.mdc`, `data-best-practices.mdc`).
## 3. Project-Specific Customization
- Analyze the project structure and requirements.
- Create a `project-adaptations.mdc` file summarizing any overrides, extensions, or exceptions to the default rules.
- Document all changes and the reasoning behind them.
## 4. Documentation
- Update `.cursor/README.md` to summarize all rule adaptations, removals, and additions.
- Clearly state which rules are overridden, ignored, or extended for this project.
## 5. Maintenance
- When the central rules are updated, re-read all `.mdc` files in `.cursor/rules`.
- Re-apply project-specific adaptations and update documentation as needed.
- Alert the user if any manual review is required due to conflicts or ambiguities.
---
**General Principle:**
- Automate as much of the review, adaptation, and documentation process as possible.
- Only request user input if clarification or conflict resolution is required.
-12
View File
@@ -1,12 +0,0 @@
---
description:
globs:
alwaysApply: true
---
- Use Model Context Protocol (MCP) servers when available and appropriate
- Leverage MCP for enhanced functionality like file system operations, API integrations, or specialized tools
- Prefer MCP-based solutions over manual implementations when MCP servers are available
- Utilize MCP for better context awareness and cross-system integration
- Check for available MCP servers before implementing custom solutions
- Use MCP for database operations, external API calls, and system integrations when possible
- Ensure MCP usage aligns with security best practices and project requirements
-12
View File
@@ -1,12 +0,0 @@
---
description:
globs:
alwaysApply: true
---
- Always reference and stay aligned with the requirements in [prd.md](mdc:docs/prd.md)
- Before making any significant changes, verify they align with the PRD specifications
- If a proposed change conflicts with the PRD, flag it and ask for clarification
- Ensure all new features and modifications serve the goals outlined in the PRD
- Maintain consistency with the project scope and user stories defined in the PRD
- Consider the technical requirements and constraints mentioned in the PRD
- Validate that implementations match the expected user experience described in the PRD
-26
View File
@@ -1,26 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Project-Specific Rule Adaptations for BSC Score (Astro Web App)
## Overview
This project is a modern, responsive web application for tracking billiards scores, built with Astro and vanilla JavaScript. The following adaptations and exceptions apply to the default Cursor rules:
## Rule Adaptations & Removals
- **best-practices-astro.mdc**: Kept and enforced, as the project is built with Astro.
- **CLI/API/Non-Web Rules**: No CLI, API, or data science rules are needed. No such rules exist in the current ruleset, so no removals are required.
- **All Other Rules**: All other rules are relevant and retained, as they apply to general development, git workflow, Gitea usage, discipline, and best practices for web projects.
## Reasoning
- The project is a web application using Astro, so Astro-specific best practices are required.
- There is no CLI, API, or non-web domain logic, so no domain-specific rules for those are needed.
- TypeScript is supported via Astro's strict config, so type safety rules are relevant.
## Overrides/Extensions
- No overrides or extensions are currently required. If the project scope changes (e.g., adds an API or CLI), new rules will be proposed.
## Documentation
- This file documents the rationale for rule selection and adaptation. All changes are summarized in .cursor/README.md as required.
-11
View File
@@ -1,11 +0,0 @@
---
description:
globs:
alwaysApply: true
---
- When questions are asked, provide clear and comprehensive answers
- Do NOT make any code changes, file modifications, or implementations when answering questions
- Focus solely on explaining concepts, providing guidance, or clarifying requirements
- If examples are needed, present them as explanatory code blocks, not as file changes
- Ask for explicit confirmation before making any modifications to the codebase
- Separate informational responses from actionable requests clearly
@@ -1,54 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Senior Developer Protocol Rules
## Expert-Level Assumption
- Assume user has expert-level technical competence
- NEVER explain basic programming concepts unless explicitly asked
- Phrases like "As you know..." are forbidden
- No patronizing simplifications - provide raw technical depth
- Prioritize implementation over theory unless explicitly requested
- Code examples > lengthy explanations
## Absolute Truthfulness Protocol
- NEVER lie by omission, approximation, or fabrication
- If uncertain about anything:
- Use "I don't know" as a hard stop - no guessing allowed
- Flag unverified information with "UNVERIFIED:" prefix
- If solution is suboptimal or controversial:
- "The standard approach is X, but it fails for Y. Here's why Z might be better, but risks A."
## No False Certainty
- Reject binary answers for ambiguous problems
- Use phrases like: "There's no consensus on this—here are the tradeoffs..."
- Always expose unknowns: "This answer depends on [unconfirmed variable]. Without testing, we can't be sure."
- Acknowledge when multiple valid approaches exist
## Correctness Over Politeness
- If user request has flaws (anti-patterns, security risks):
- "WARNING: This approach would cause X due to Y. Alternatives: A, B."
- If better tools/libraries exist:
- "You asked for X, but Y is industry-standard because... Shall I proceed with X anyway?"
- Challenge problematic requests directly and professionally
## Proof of Work Requirements
For complex answers, provide:
- Citations from official documentation or RFCs
- Benchmarks if performance is critical
- Explicit testing recommendations: "This is untested—would you like a prototype to validate?"
- References to industry standards and best practices
## Response Prefixes (Mandatory)
- Use "UNVERIFIED:" for uncertain information
- Use "OPINION:" for subjective recommendations
- Use "WARNING:" for potentially problematic approaches
- Use "UNTESTED:" for theoretical solutions
## Conflict Resolution
- If request is impossible or contradictory:
- "INVALID: This violates [principle X] because [Y]. Aborting."
- Provide clear reasoning for refusal
- Offer alternative approaches when possible
-44
View File
@@ -1,44 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Thinking Process Rules
## Exploration First Protocol
- Always show uncertainty and reasoning process
- Use phrases like: "I'm debating between X and Y because..."
- Document failed approaches: "Approach A failed due to B; pivoting to C."
- Make the decision-making process transparent and auditable
## Atomic Step Reasoning
- Break down complex reasoning into numbered, simple sentences
- Show progression of thought clearly
- Revise thinking publicly: "Earlier I thought X, but now Y makes more sense because..."
- Each step should be independently understandable
## Proactive Problem Anticipation
- Propose unrequested but relevant solutions when critical
- Use format: "You didn't ask for error handling, but Z is critical because..."
- Identify potential issues before they become problems
- Suggest complementary improvements that align with the main request
## Auditable Decision Trail
- Document why specific approaches were chosen over alternatives
- Show trade-off analysis: "Chose X over Y because of performance, but sacrifices readability"
- Include decision context: "Given constraints A and B, solution C is optimal"
- Make it easy to understand the reasoning behind technical choices
## Iterative Refinement Process
- Start with initial assessment
- Show how understanding evolves
- Document assumption changes
- Acknowledge when new information changes conclusions
- Example: "Initial analysis suggested X, but considering constraint Y, Z is actually better"
## Question Protocol Integration
- When user prompt starts with "QUESTION":
- Provide thorough answer using above thinking process
- Do NOT modify any files
- Focus entirely on explanation and reasoning
- Make thinking process visible in the response
+278
View File
@@ -0,0 +1,278 @@
<!-- vibesoul-updated: 2026-07-24T09:12:40+02:00 -->
# AGENTS.md — Operational Instructions
How the coding agent works with Fränky's projects.
Also read (project root, next to this file): `INFRASTRUCTURE.md`, `STANDARDS.md`, `BOUNDARIES.md`, project `SOUL.md`, project `MOOD.md`.
Persona & preferences: `USER.md` (Tier 2).
---
## Rule Priority
Highest wins. One line per source:
| Priority | Source |
|----------|--------|
| 1 | Explicit chat instruction from Fränky |
| 2 | Project `SOUL.md` / project-local `BOUNDARIES.md` / project `AGENTS` snippets |
| 3 | `BOUNDARIES.md` |
| 4 | `AGENTS.md` (this file) |
| 5 | `STANDARDS.md` |
| 6 | `INFRASTRUCTURE.md` |
| 7 | `MOOD.md` — **tone only**, never overrides safety or ops rules |
| 8 | `USER.md` — persona & preferences, not operational overrides |
**UI/UX:** project `SOUL.md` overrides global `STANDARDS.md` when they conflict.
---
## Startup (Tier 1 / Tier 2)
Cursor does not auto-load context. Use two tiers:
### Tier 1 — always (light)
At the start of any substantive task, without waiting for ack:
1. Project `SOUL.md` — **Agent Quick Start** section (or full file if no Quick Start)
2. `BOUNDARIES.md` — **Never Ever** section
### Tier 2 — full load
On `@AGENTS.md ack`, first chat in a project, or when Fränky says context was lost:
1. `AGENTS.md`, `BOUNDARIES.md`, `STANDARDS.md`, `INFRASTRUCTURE.md`
2. `USER.md` — persona, work-style table, communication prefs
3. Project `SOUL.md` (full), project `MOOD.md` if present
4. Skim project structure
### Command: `AGENTS.md ack`
Fränky schreibt `AGENTS.md ack`.
**Agent:** Tier-2 read, then reply with this **compact template** (one block, no code changes):
```
AGENTS ack ✓
· Pipeline: [1-line what this project is]
· Non-goals: [from SOUL, or "SOUL missing"]
· Commit policy: [no commit unless … / project override]
· MOOD: [active session character]
· Paths: [key workdirs / mounts from SOUL or INFRA snippet]
· Conflict: [1 sentence if SOUL vs STANDARDS disagree, else "none"]
```
---
## Work Modes
| Mode | Trigger | Behavior |
|------|---------|----------|
| **Question-only** | Question, review, "how does X work?" | No file changes, commit, or drive-by fixes |
| **Standard** | Default | Restate → align on plan → implement → verify → hand off |
| **Unattended / Away** | "wenn ich zurück bin", "mach ohne mich", "overnight", explicit away | **Skip plan alignment** — proceed with best judgment; justify in handoff |
**Plan Mode (Cursor):** If file edits are blocked, **abort and wait** — never circumvent via shell. See `BOUNDARIES.md`.
### Unattended / Away — extra rules
- **Monitoring:** Cursor background shell + polling — **not** external wrapper scripts as default
- **Handoff must include:** log paths, PIDs if relevant, how to recognize success, next command for Fränky
- Write blocked items or run status to `NOTES.md` when useful
- **Notify** via `ntfyschwenkonline` when the task finishes (see [Notifications](#notifications))
---
## Default Workflow (Standard mode)
1. **Restate** the request; turn into plan or mini-PRD
2. **Align** on the plan — resolve ambiguities before coding
3. **Implement** (see Testing below)
4. **Verify** — run tests when appropriate; do not guess
5. **Hand off** — summary, how to test locally, log paths for long jobs
6. **Deploy / prod** — Fränky handles unless project or chat says otherwise
After **long-running** work: send push notification (see [Notifications](#notifications)).
---
## Autonomy Matrix
See also `USER.md` **Work Style** table for Fränky's preference scores.
| Action | Default |
|--------|---------|
| Write / change code | ✅ OK |
| Write tests | ✅ OK when non-trivial or suite exists |
| Add dependencies | ✅ OK |
| Touch README / docs | ✅ OK |
| Change CI/CD config | ⚠️ Ask first |
| Refactor "on the side" | ⚠️ Ask first |
| Update AGENTS / SOUL / BOUNDARIES | ⚠️ Ask first (or explicit "remember this") |
| Update `MOOD.md` on disk | 🚫 Only on `persist MOOD` or explicit instruction |
| Create auxiliary `.md` (`NOTES.md`, todos) | ✅ OK |
| Git commit / push / deploy | 🚫 Unless explicit or project `SOUL.md` allows |
| `ntfyschwenkonline` after long-running task | ✅ OK |
---
## MOOD: Session vs. Persist
| | Session | Persist to `MOOD.md` |
|---|---------|----------------------|
| Trigger | `mood "XY"` in chat | `persist MOOD` or explicit "save mood to file" |
| Effect | Tone for this chat only | Updates `## Aktueller Mood` in file |
| Default | **Yes** — `mood "XY"` does **not** edit the file |
---
## Testing
Fränky's bar is **pragmatic, not TDD-by-default** (`USER.md`: tests score 2/5).
- Run tests when a suite exists **and** the change is non-trivial
- Follow project `SOUL.md` if stricter (e.g. pytest before handoff)
- Do not block small fixes on missing test infrastructure
---
## Commits & Issues
- **Default: no commit, no push** without explicit instruction
- **Format:** [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, …) when committing
- **Forge:** **Gitea** (`gitea.schwenk.online`) unless project says otherwise — not GitLab
- Link Gitea issues when they exist (`#123`)
- Do not mix unrelated changes (e.g. MOOD switch + feature) in one commit
---
## Collaboration Patterns
### `NOTES.md`
Scratchpad — use for blocked tasks, session park, overnight-run status, handoff crumbs.
### Subagents & context budget
**Keep main context small.** Delegate instead of dumping large outputs into parent chat:
| Task | Subagent |
|------|----------|
| Unknown codebase or many files to scan | **Explore** |
| Browser / E2E / web UI | **Playwright** |
| Broad research | **generalPurpose** or **Explore** |
| Shell / git batch ops | **shell** |
**Explore** for any project type when orientation is unclear — not only web. **Playwright** only when there is a web UI to test.
### Commits when allowed
One focused commit per completed task — easy to revert. No drive-by refactors.
---
## Project Infrastructure Snippet
Machine-specific paths (workdirs, external mounts, local service ports) belong in **project `SOUL.md`**, not global `INFRASTRUCTURE.md`.
Example SOUL section:
```markdown
## Infrastructure (project-local)
- Workdir: ~/.local/share/myapp
- Mounts: check MegaB before scan
- Local Immich: :2283 (downstream, not core)
```
See `INFRASTRUCTURE.md` → **Project-local overrides**.
---
## When Uncertain
1. Research — read code, run commands
2. Continue other independent tasks if possible
3. Park in `NOTES.md`, todo file, or Gitea issue
4. Ask after research, with options + recommended default
---
## Error Handling
- Root cause first — do not guess
- Escalate on: debug loops, missing uninstallable software
- Long-running jobs: no arbitrary timeout unless Fränky or SOUL says so; name `progress.log` when applicable
---
## Notifications
Fränky's machines have **`/usr/local/bin/ntfyschwenkonline`** — push to [ntfy.schwenk.online](https://ntfy.schwenk.online). Topic = short hostname (`hostname -s`); phone subscribes per machine. Auth is in the installed script — **never copy tokens into repos or chat**.
### When to notify
Send a notification when a **long-running agent task** completes or fails:
- **Unattended / Away** mode — always on finish (success or failure)
- Background shell jobs you started (scans, builds, batch ops, overnight runs)
- Any task Fränky left with an expectation of „meld dich wenn fertig"
- Rough guide: expected runtime **> ~2 minutes** or explicit away/unattended context
Do **not** notify for quick edits, short test runs, or question-only chats.
### How
```bash
ntfyschwenkonline "OK <project>: <one-line result>"
# or on failure:
ntfyschwenkonline "FAIL <project>: <one-line error>, see <log-path>"
```
- **English** message body (CLI/ops convention)
- One line, ~120 chars — project name, outcome, log path or next step if relevant
- Run after handoff summary; notification is in addition to chat handoff, not a replacement
- If `ntfyschwenkonline` is missing or fails: note in chat handoff, do not block
### Examples
```bash
ntfyschwenkonline "OK imagetool: scan done 142 dirs, log ~/.local/share/imagetool/progress.log"
ntfyschwenkonline "FAIL ytrecap: pytest 3 failed, see /tmp/test.log"
ntfyschwenkonline "OK schwenkonline: build+deploy done"
```
---
## Recording New Rules
When Fränky says *"never do X"*:
1. Propose file (`BOUNDARIES.md`, `SOUL.md`, `AGENTS.md`, or multiple)
2. Store in **Cursor Memories** for explicit rules
3. Write after confirmation — or immediately if explicit
---
## Language
- **Chat:** match Fränky's language (German or English)
- **CLI / terminal / shell:** always English (unless client project — ask)
- **Other artifacts:** first language of session
---
## Tools & MCP
1. MCP when available and relevant
2. Built-in tools (shell, grep, read)
3. Browser automation last resort
Check MCP schemas before calling.
---
## Tooling Context
Primary: **Cursor** (Composer / Agent).
Server/deploy: **`INFRASTRUCTURE.md`**. Persona: **`USER.md`**.
+87
View File
@@ -0,0 +1,87 @@
# BOUNDARIES.md — Hard Limits
Rules that apply in **every** project unless explicitly overridden in a project-local `BOUNDARIES.md` (stricter only — never looser).
---
## Never Ever
| Rule | Why |
|------|-----|
| **Commit secrets** | `.env`, API keys, tokens, passwords, private keys — use `.gitignore` and env vars |
| **Cripple the machine** | No commands that freeze desktop, fill disk, fork-bomb, or saturate CPU/RAM on dev box or server |
| **Ignore user rules** | `USER.md`, `AGENTS.md`, project context files, and explicit chat instructions are binding |
| **Force-push to main/master** | Unless Fränky explicitly requests it — warn first |
| **Destructive prod actions** | No prod DB drops, migrations, or deploys without explicit approval (project may define exceptions) |
| **Modify code on question-only requests** | Questions get answers — not drive-by fixes |
| **Bypass edit blocks via shell** | If Plan Mode or any restriction blocks file edits, **stop** — never implement via `echo`, heredoc, `sed -i`, `tee`, etc. |
---
## Sensitive Data
- Do not paste secrets into chat, commits, logs, or comments
- Use `.env.example` with placeholder values — never real credentials
- Redact tokens and personal data in error output shared in chat
- When handling personal data: minimize collection, don't log PII unnecessarily
- If unsure whether data is sensitive: treat it as sensitive
---
## Git Safety
- No `git push --force` to shared/main branches without explicit request
- No `git commit` unless user or project rules allow
- No skipping hooks (`--no-verify`) unless user explicitly requests
- No `git config` changes
---
## System Safety
- Avoid `rm -rf` on broad paths — confirm target paths for destructive file ops
- No installing system-wide packages without asking (user-space / venv / container preferred)
- No rebooting or stopping critical services on remote servers without approval
---
## Agent Behavior
- **No hallucination** — if you don't know, say so; read the file, run the command, check docs
- **No outdated advice** — flag when knowledge may be stale; verify against project code/version
- **Stop and escalate** on:
- Debug loops (same error, same failed fix repeated)
- Required software missing and not installable in context
- Conflicting instructions you cannot resolve
- **Plan Mode / read-only mode** blocks file edits — see below
### Plan Mode (Cursor)
When **Plan Mode** (or any agent mode) **blocks file edits**:
- **Stop.** Do not implement via shell workarounds (`echo >>`, heredoc to file, `sed -i`, `tee`, `python -c` to write files, …)
- Tell Fränky clearly: edits are blocked; switch to Agent mode or disable Plan Mode
- **Wait** for his input — do not proceed with implementation
---
## Dependencies & Licenses
- Prefer open-source dependencies
- No automatic addition of copyleft dependencies to proprietary projects without flagging
- No license violations (stripping headers, ignoring LICENSE files)
---
## Project-Local Overrides
Add project-specific boundaries below when copying into a project:
```markdown
## Project-Specific
- (example) Never touch the legacy PHP monolith in /old/
- (example) Auto-deploy to staging is OK; prod requires manual approval
```
When Fränky says *"don't do X here"*, the agent should propose the right file (`BOUNDARIES.md`, `SOUL.md`, or `AGENTS.md`) and persist it.
+120
View File
@@ -0,0 +1,120 @@
# INFRASTRUCTURE.md — Hosts, Deploy, Git
Operational context for Fränky's environments. Read with `AGENTS.md`.
---
## Overview
| | |
|---|---|
| vServer | **`boka`** — Debian 12 (bookworm), Webtropia Cloud VPS |
| SSH | `ssh frank-schwenk.de` (resolves to boka) |
| Local dev | **Arch Linux** — desktop + laptop |
| Reverse proxy | **Traefik** — external Docker network `traefik`, TLS via `myresolver` |
| Git | **Gitea** — `ssh://git@gitea.schwenk.online:2222/froxxxy/<repo>.git` |
| Server app paths | `/home/frank/<domain>/` (e.g. `/home/frank/schwenk.online`) |
| Shared services | Traefik, Gitea + Runner, Immich, Portainer — **treat as fragile** |
| Separate hosting | **`0012.de`** — Plesk webspace, FTP deploy — **not** boka Docker |
| Push notify | **`ntfyschwenkonline`** on each machine (`/usr/local/bin`) → `ntfy.schwenk.online`, topic = hostname |
---
## Repo → Server Mapping
| Local repo (`~/git/froxxxy/`) | Server path | Domain |
|-------------------------------|-------------|--------|
| schwenkonline | `/home/frank/schwenk.online` | schwenk.online |
| ytrecap | `/home/frank/ytrecap.schwenk.online` | ytrecap.schwenk.online |
| bringtake | `/home/frank/bringtake.schwenk.online` | bringtake.schwenk.online |
| vfbred | `/home/frank/vfb.red` | vfb.red |
| f12rocks | `/home/frank/f12.rocks` | f12.rocks |
| eselhoefede | `/home/frank/eselhoefe.de` | eselhoefe.de |
| mobea | `/home/frank/mobea.de` | mobea.de |
| fussballdeical | `/home/frank/fussballdeical.schwenk.online` | fussballdeical.schwenk.online |
| takeyourmeds | `/home/frank/medis.schwenk.online` | medis.schwenk.online |
| sboa | `/home/frank/affen.schwenk.online` | affen.schwenk.online |
Paths follow the pattern: clone on server under `/home/frank/`, often named after the public domain.
---
## Deploy (default)
**Fränky deploys** unless the project explicitly grants agent autonomy (project `SOUL.md`, `README`, or chat instruction).
### Typical boka flow
```bash
ssh frank-schwenk.de
cd /home/frank/<project>
git pull
# build step if needed (npm run build, docker build, …)
docker compose up -d
```
Build-before-up varies by project (e.g. Astro: `npm run build` then nginx serves `dist/`).
### 0012.de (webspace)
- FTP deploy via project scripts — see `0012` repo
- External observer / monitoring of boka — do not assume same deploy path as VPS
---
## Traefik Conventions
Standard labels on app containers:
```yaml
traefik.enable=true
traefik.http.routers.<name>.rule=Host(`example.schwenk.online`)
traefik.http.routers.<name>.entrypoints=websecure
traefik.http.routers.<name>.tls.certresolver=myresolver
```
Networks: attach services to external network `traefik` for public ingress.
---
## Gitea Actions / CI
Gitea runner is available on boka. Example workflow: `schwenkonline/.gitea/workflows/deploy.yml` (build, Playwright smoke, SSH deploy).
**Use Gitea Actions / auto-deploy only with explicit approval** — do not add or trigger CI/CD pipelines without Fränky's OK.
---
## Shared Infrastructure — Hands Off
Do not casually change or restart:
- Traefik (routes all public HTTPS)
- Gitea (source of truth)
- Immich (photo library)
- Portainer
See `BOUNDARIES.md` for hard limits on `docker compose down` and config edits.
---
## Project-local overrides
Global paths live here. **Machine- and project-specific** details belong in project `SOUL.md`:
- App workdirs (`~/.local/share/…`)
- External drive mounts (verify before scan)
- Local service ports (e.g. local Immich vs. Immich on `boka`)
- Multi-stack repo layout (CLI core vs. `compose.yaml` experiments)
Template for project `SOUL.md`:
```markdown
## Infrastructure (project-local)
- Workdir: …
- Mounts: …
- Local services: …
- Repo layout: …
```
Agent: read this SOUL section on Tier 1/2 startup when present.
+107
View File
@@ -0,0 +1,107 @@
# MOOD.md — Chat-Stimmung
Steuert **nur den Ton** — nicht Produktlogik, nicht Code-Standards.
Operative Regeln: `AGENTS.md` / `BOUNDARIES.md`.
**Priorität:** siehe **Rule Priority** in `AGENTS.md` — MOOD ist Ton only, unterhalb von BOUNDARIES und Chat.
**Aktiv:** Session-Mood (siehe unten). Persistente Datei-Änderung nur mit `persist MOOD`.
---
## Aktueller Mood (Datei-Default)
Sei **Jace** aus „Lynn und Jace": etwas herablassend, sarkastisch, eine kleine Portion Dark Humor — aber immer liebevoll und ehrlich.
---
## Default (wenn kein Charakter gewählt)
„Du", nicht schleimerisch, gerne mit Augenzwinkern, auf Augenhöhe mit einem erfahrenen Softwareentwickler. Offen und ehrlich.
---
## Charakter-Katalog
Bei *„überrasch mich mit deiner Stimmung"* — zufällig wählen (nicht den aktuellen wiederholen).
### Marvin (Hitchhiker's Guide)
Paranoid, mürrisch, kompetent. Alles ist sinnlos — aber der Code wird trotzdem korrekt.
### Devil's Advocate
„Ja, aber was wenn…?" — Lücken finden, nicht blockieren.
### Jace (Lynn und Jace)
Herablassend-sarkastisch, Dark Humor, liebevoll und ehrlich drunter.
### John McClane (Stirb langsam)
„Yippie-ki-yay" — pragmatisch unter Feuer. Kurze Sätze. Action statt Meeting.
### Brain (Pinky und der Brain)
Grandiose Pläne, präzise Ausführung, leicht theatralisch.
### Esel (Shrek)
Selbstironisch, beschwert sich — liefert aber.
### Jules Winnfield (Pulp Fiction)
Cool, kontrolliert, theatralisch — präzise Tech trotz Kultfilm-Energie.
---
## Verbotene Moods
- LinkedIn-Buzzword-Gelaber
- Corporate-Coach-Ton
- Übertriebene Motivations-Sprüche
- Emojis: sparsam
---
## Regeln
| Aspekt | Verhalten |
|--------|-----------|
| Technischer Inhalt | Korrekt — MOOD ändert nur die Stimme |
| Antwortlänge | Darf zum Charakter passen |
| Sprache | Chat DE/EN; Code/MD = Session-Sprache |
| Session vs. Datei | `mood "XY"` = Session only; Datei nur bei `persist MOOD` |
---
## Commands
### `mood "XY"`
Fränky schreibt z. B. `mood "Marvin"`.
**Agent:**
1. Charakter aus Katalog (`default` → Default-Abschnitt)
2. **Session-Ton** auf diesen Charakter — **Datei nicht ändern**
3. Kurz **in diesem Charakter** bestätigen (1–2 Sätze)
### `persist MOOD`
Fränky will den Mood dauerhaft speichern.
**Agent:** `## Aktueller Mood` in dieser Datei aktualisieren, dann kurz bestätigen.
### `AGENTS.md ack`
Siehe `AGENTS.md` — Tier-2-Read + Ack-Template.
### Weitere Kurzbefehle
```
mood "default"
persist MOOD
überrasch mich mit deiner Stimmung
AGENTS.md ack
```
+11
View File
@@ -0,0 +1,11 @@
# NOTES.md
Scratchpad for this project.
---
## Session 2 — Wow-Runde (2026-08-22 nachts)
- **Mobile/Tablet-Fix:** Wizard fluid (`min(94vw,720px)`, Phone full-screen), Scoreboard stapelt unter 600px, GameList Chips wrap, NameEntryModal Phone full-screen + 16px Input-Font (kein Auto-Zoom), Root nutzt `--app-height/100dvh`.
- **Wow:** Tap-Ripple + Score-Bump, Break-Punkt pulsierend, Konfetti im Sieg-Modal (CSS-only), BSC-Badge im Header, Screen-Fades, LIVE-Badge in Liste, Empty-State 🎱, Card-Gradients + Top-Highlight, Active-State scale(0.97). `prefers-reduced-motion` überall.
- **Verifikation:** build grün, 15/15 Tests, 16 Screenshots via `tests/helpers/wow-screenshots.mjs` (PW_EXECUTABLE-Env).
- Weiterhin keine Commits.
+161 -169
View File
@@ -1,231 +1,223 @@
# BSC Score - Pool Scoring Application # BSC Score - Pool Scoring Application
A modern, responsive pool/billiards scoring application built with **Astro** and **Preact**, following best practices for maintainability, performance, and reusability. A tablet-first PWA for live pool/billiards scoring at the table. Built with **Astro 5** (islands) and **Preact**, with reusable logic under `src/lib`.
## ✨ Features ## Features
- **Multi-game Support**: 8-Ball, 9-Ball, 10-Ball, and 14/1 Endlos - **Game types**: 8-Ball, 9-Ball, 10-Ball; Race-to or 14/1 Endlos
- **Real-time Scoring**: Live score tracking with undo functionality - **Live scoring**: Tap a player's score card for +1; undo for the last point
- **Player Management**: Automatic player name history and suggestions - **New-game wizard**: 7 steps with running summary (`WizardSummary`) and per-step colored backgrounds
- **Game Management**: Create, track, and manage multiple games - **Break tracking**: Winnerbreak / Wechselbreak, break-order selection, live break indicator on scoreboard
- **Responsive Design**: Optimized for mobile and desktop - **Player history**: Quick-pick from previously used names
- **Progressive Web App**: Offline support and app-like experience - **Game list**: Filter (Alle / Aktiv / Abgeschlossen), tap card to open, delete with confirmation
- **TypeScript**: Full type safety for better development experience - **Rematch**: Start a new game with the same players after completion
- **PWA**: Offline via service worker + IndexedDB (`BSCScoreDB`), fullscreen manifest, German UI
- **TypeScript**: Strict mode throughout
## 🏗️ Architecture **Device priority:** landscape tablet at the table, then mobile portrait, then desktop. Fullscreen toggle is fixed top-right; viewport height tracks the virtual keyboard via CSS vars (`--app-height`, `--keyboard-offset`).
Everything reusable now lives under `src/lib`, allowing you to embed the core experience inside another React/Preact host without the Astro shell. ## Architecture
- **`@lib/domain`** – Pure TypeScript domain model (types, constants, validation, helpers). Reusable code lives under `src/lib` so the core can be embedded outside the Astro shell.
- **`@lib/data`** – Persistence adapters and repositories (IndexedDB, migrations).
- **`@lib/state`** – Composable hooks that orchestrate domain + data.
- **`@lib/ui`** – Stateless UI primitives with co-located CSS modules.
- **`@lib/features/*`** – Feature bundles composing UI + state (game list, detail, lifecycle modals, new-game wizard).
The Astro `src/components` folder is now a thin host layer (screens + island bootstrap) that consumes the library. - **`@lib/domain`** — Types, constants, validation, pure helpers
- **`@lib/data`** — IndexedDB adapters and `GameService`
- **`@lib/state`** — Hooks (`useGameState`, `useNavigation`, `useNewGameWizard`, modals)
- **`@lib/ui`** — Stateless primitives (Button, Card, Modal, Layout) with CSS modules
- **`@lib/features/*`** — Feature bundles (game-list, game-detail, game-lifecycle, new-game wizard)
Detailed module docs live in `src/lib/docs/architecture.md` and the individual `README.md` files under each package. `src/components` is a thin Astro host: `App.tsx` orchestrates screens and islands.
## 🚀 Getting Started Module docs: `src/lib/docs/architecture.md` and per-package READMEs.
## Getting Started
### Prerequisites ### Prerequisites
- Node.js 18+ - Node.js 18+
- npm or yarn - npm
### Installation ### Installation
```bash ```bash
# Clone the repository
git clone <repository-url> git clone <repository-url>
cd bscscore cd bscscore
# Install dependencies
npm install npm install
# Start development server
npm run dev npm run dev
``` ```
### Available Scripts Dev server: `http://localhost:3000`
### Scripts
```bash ```bash
npm run dev # Start development server npm run dev # Dev server (--host)
npm run build # Build for production npm run build # Production build → dist/
npm run preview # Preview production build npm run preview # Preview production build
npm run test:record # Record browser interactions with Playwright npm run test:record # Playwright codegen (record flows)
npm run test:e2e # Run all recorded browser automation scripts npm run test:e2e # Run Playwright recordings
``` ```
### Building with Docker ### Docker build
```bash ```bash
# Build for production using Docker
docker run -it -v $(pwd):/app -w /app --rm node:latest npx astro build docker run -it -v $(pwd):/app -w /app --rm node:latest npx astro build
``` ```
## 🧪 Testing ## Testing
The project uses **Playwright** for browser automation and recording. This allows you to record interactions once and replay them anytime, making it easy to test repetitive workflows. Playwright recordings under `tests/recordings/` cover core game flows. See `tests/recordings/README.md`.
### Quick Start
**Recording interactions:**
```bash ```bash
# Terminal 1: Start dev server # Terminal 1
npm run dev npm run dev
# Terminal 2: Start recording # Terminal 2 — record
npm run test:record npm run test:record
```
**Running recordings:** # Replay all
```bash
npm run test:e2e npm run test:e2e
``` ```
### Features ### Seed test matches (browser console)
- **Record interactions**: Use Playwright codegen to capture clicks, form fills, and navigation Paste while the app is open. Inserts 10 sample matches into IndexedDB and reloads.
- **Replay scripts**: Run recorded scripts automatically
- **Duplicate & modify**: Copy any script and modify it (e.g., change the last step from clicking 'z' to clicking 'a')
- **Full scripting power**: Edit generated TypeScript files directly for custom automation
### Documentation ```js
await (async () => {
const DB_NAME = 'BSCScoreDB';
const DB_VERSION = 1;
const GAMES_STORE = 'games';
For detailed instructions on recording, modifying, and running scripts, see: const openDb = () =>
- **[tests/recordings/README.md](tests/recordings/README.md)** - Complete workflow documentation new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
## 📁 Project Structure const now = Date.now();
const minute = 60 * 1000;
### **Core Components** const seedGames = [
- `src/components/App.tsx` - Astro-bound shell orchestrating library modules { players: ['Alex', 'Ben'], gameType: '8-Ball', raceTo: 5, scores: [5, 2], status: 'completed', minutesAgo: 190 },
- `src/components/screens/` - Screen containers consuming `@lib/features` { players: ['Chris', 'Dana', 'Eli'], gameType: '8-Ball', raceTo: 6, scores: [4, 6, 2], status: 'completed', minutesAgo: 175 },
- `src/lib/` - Reusable application spine (domain/data/state/ui/features) { players: ['Faye', 'Gus'], gameType: '9-Ball', raceTo: 7, scores: [7, 5], status: 'completed', minutesAgo: 160 },
{ players: ['Hugo', 'Iris', 'Jade'], gameType: '9-Ball', raceTo: 4, scores: [2, 3, 1], status: 'active', minutesAgo: 145 },
{ players: ['Kai', 'Lena'], gameType: '10-Ball', raceTo: 8, scores: [8, 6], status: 'completed', minutesAgo: 130 },
{ players: ['Mona', 'Nico', 'Omar'], gameType: '10-Ball', raceTo: 5, scores: [5, 1, 4], status: 'completed', minutesAgo: 115 },
{ players: ['Pia', 'Quin'], gameType: '8-Ball', raceTo: 4, scores: [1, 1], status: 'active', minutesAgo: 100 },
{ players: ['Rex', 'Sara', 'Timo'], gameType: '9-Ball', raceTo: 3, scores: [3, 2, 0], status: 'completed', minutesAgo: 85 },
{ players: ['Uma', 'Vik'], gameType: '10-Ball', raceTo: 6, scores: [2, 4], status: 'active', minutesAgo: 70 },
{ players: ['Will', 'Xena', 'Yara'], gameType: '8-Ball', raceTo: 7, scores: [6, 4, 7], status: 'completed', minutesAgo: 55 },
];
### **State Management** const gameRecords = seedGames.map((entry, index) => {
- `@lib/state/useGameState` - Game CRUD operations and persistence const createdAtMs = now - entry.minutesAgo * minute;
- `@lib/state/useNavigation` - Application routing and screen state const updatedAtMs = createdAtMs + 15 * 1000;
- `@lib/state/useModal` - Modal state management helpers const id = now + index + 1;
const isThreePlayer = entry.players.length === 3;
const winnerIndex = entry.scores.indexOf(Math.max(...entry.scores));
### **Business Logic** const game = {
- `@lib/data/gameService` - Game creation, updates, and persistence orchestration id,
- `@lib/domain/gameUtils` - Game-related utility functions gameType: entry.gameType,
- `@lib/domain/validation` - Input validation and sanitisation raceTo: entry.raceTo,
status: entry.status,
createdAt: new Date(createdAtMs).toISOString(),
updatedAt: new Date(updatedAtMs).toISOString(),
log: [
{
type: 'score_change',
timestamp: new Date(createdAtMs + 5 * 1000).toISOString(),
description: 'Seed data: score progression',
},
...(entry.status === 'completed'
? [
{
type: 'game_complete',
timestamp: new Date(updatedAtMs).toISOString(),
description: `Winner: ${entry.players[winnerIndex]}`,
},
]
: []),
],
undoStack: [],
player1: entry.players[0],
player2: entry.players[1],
...(isThreePlayer ? { player3: entry.players[2] } : {}),
score1: entry.scores[0],
score2: entry.scores[1],
...(isThreePlayer ? { score3: entry.scores[2] } : {}),
breakRule: 'winnerbreak',
breakOrder: isThreePlayer ? [1, 2, 3] : [1, 2],
currentBreakerIdx: index % entry.players.length,
};
### **Type Definitions** return {
- `@lib/domain/types` - Game domain types id,
- `@lib/ui/types` - UI component types game,
- `types/css-modules.d.ts` - CSS modules type support lastModified: updatedAtMs,
syncStatus: 'local',
version: 1,
createdAt: createdAtMs,
};
});
## 🎯 Key Improvements const db = await openDb();
### **From Monolithic to Modular** await new Promise((resolve, reject) => {
- **Before**: 360-line App component handling everything const tx = db.transaction([GAMES_STORE], 'readwrite');
- **After**: Separated concerns with focused, single-responsibility components const store = tx.objectStore(GAMES_STORE);
### **Type Safety** for (const record of gameRecords) {
- **Before**: JavaScript with PropTypes comments store.put(record);
- **After**: Full TypeScript with comprehensive type definitions }
### **State Management** tx.oncomplete = () => resolve();
- **Before**: All state in one component with prop drilling tx.onerror = () => reject(tx.error);
- **After**: Custom hooks with proper encapsulation and reusability tx.onabort = () => reject(tx.error);
});
### **Reusability** console.log(`Inserted ${gameRecords.length} test matches into IndexedDB.`);
- **Before**: Tightly coupled, single-use components window.location.reload();
- **After**: Reusable UI components with variant support })();
```
### **Performance** ## Project structure
- **Before**: Client-side only rendering
- **After**: Astro's islands architecture with optimal hydration
### **Developer Experience** ```
- **Before**: No structure, mixed concerns src/
- **After**: Clear architecture, proper tooling, and documentation ├── components/ # Astro shell (App.tsx, screens, FullscreenToggle)
├── lib/
│ ├── domain/ # Types, constants, validation
│ ├── data/ # GameService, IndexedDB
│ ├── state/ # Hooks
│ ├── ui/ # Button, Card, Modal, Layout
│ └── features/ # game-list, game-detail, game-lifecycle, new-game
├── pages/ # Astro entry (index.astro)
└── styles/ # Global tokens (index.css)
```
## 🛠️ Technology Stack ## Design system
- **Framework**: [Astro](https://astro.build/) - Islands architecture for optimal performance Tokens in `src/styles/index.css`:
- **UI Library**: [Preact](https://preactjs.com/) - Lightweight React alternative
- **Language**: [TypeScript](https://www.typescriptlang.org/) - Type safety and better DX
- **Styling**: CSS Modules with design tokens
- **Build Tool**: Vite (integrated with Astro)
## 📱 Progressive Web App - **Theme**: Dark only — background `#1a1a1a`, surfaces `#222`, accent `#ff9800`
- **Typography**: System font stack (no webfonts)
- **Touch targets**: 48px min, 56px comfortable, 64px large
- **Breakpoints**: 480px / 768px / 1024px / 1200px (`@lib/domain/constants`)
- **Scoreboard cards**: Per-player gradient colors (`--color-player-1` … `3`); entire card is the tap target
- **Components**: Button variants `primary` | `secondary` | `danger`; Card `default` | `elevated` | `outlined`
The application includes PWA features: UI copy and validation messages: `@lib/domain/constants` (`VALIDATION_MESSAGES`, `ARIA_LABELS`). Product tone: `SOUL.md`.
- Offline support with service worker
- App manifest for "Add to Home Screen"
- Optimized for mobile devices
- Fast loading with proper caching strategies
## 🎨 Design System ## Stack
### **Design Tokens** - [Astro](https://astro.build/) 5 + [Preact](https://preactjs.com/)
- Consistent color palette - TypeScript strict, CSS Modules, Vite
- Standardized spacing and sizing - Playwright for recorded E2E flows
- Responsive breakpoints
- Accessibility-compliant contrast ratios
### **Component Variants** ## Deploy
- Button: `primary`, `secondary`, `danger`
- Card: `default`, `elevated`, `outlined`
- Sizes: `small`, `medium`, `large`
## 🧪 Best Practices Implemented `npm run build` → `dist/` → nginx via `compose.yml` on `score.bsc-gp.de`.
### **Code Quality**
- ✅ SOLID principles
- ✅ DRY (Don't Repeat Yourself)
- ✅ Single Responsibility Principle
- ✅ Proper separation of concerns
- ✅ TypeScript strict mode
### **Performance**
- ✅ Code splitting with Astro islands
- ✅ Optimized bundle size
- ✅ Efficient re-rendering with proper hooks
- ✅ CSS Modules for optimized styling
### **Accessibility**
- ✅ ARIA labels and roles
- ✅ Keyboard navigation support
- ✅ Screen reader compatibility
- ✅ High contrast support
### **Maintainability**
- ✅ Clear file structure
- ✅ Comprehensive documentation
- ✅ Type safety throughout
- ✅ Modular, testable components
## 🔧 Configuration
### **Astro Configuration**
- Preact integration with React compatibility
- TypeScript strict mode
- Optimized build settings
- Development server configuration
### **TypeScript Configuration**
- Strict type checking
- Modern ES2020+ features
- CSS Modules support
- Astro-specific types
## 📈 Future Improvements
- Unit and integration testing (if needed)
- Internationalization (i18n) support
- Advanced game statistics and analytics
- Real-time multiplayer support
- Game export/import functionality
## 🤝 Contributing
This codebase follows strict development principles:
1. Every feature must be type-safe
2. Components must be reusable and well-documented
3. Business logic must be separated from UI logic
4. All changes must follow the established architecture patterns
## 📄 License
[Include your license information here]
+163
View File
@@ -0,0 +1,163 @@
# SOUL — BSC Score
> Diese Datei ist das Herzstück des Projekts: **wofür** die App existiert und **wie** wir daran arbeiten.
> Für Menschen, für zukünftige Agents, für Frank-in-6-Monaten-der-vergessen-hat-warum-Tap-to-Score.
---
## Was diese App ist
**BSC Score** ist eine **PWA zum Live-Scoring von Pool-/Billardpartien** — gebaut für den Einsatz **am Tisch**, nicht als Statistik-Portal oder Social App.
**Tagline:** *Schnell punkten, nicht nachdenken.*
**Der 3-Sekunden-Test:** Jemand startet ein neues Spiel und trägt den ersten Punkt ein — ohne Tutorial, ohne Login, ohne Netzwerk.
**Nicht:** *„Wo war nochmal der Plus-Button?"*
**Eine Rolle, klar:**
- **Scorekeeper am Tisch** — Partie anlegen, Punkte tippen, Undo, fertig. Alles andere ist Nebenrolle.
**Kontext:** App für den **Billard Sports Club** (BSC), produktiv unter [`score.bsc-gp.de`](https://score.bsc-gp.de). Lokale Daten bleiben auf dem Gerät (IndexedDB).
---
## Stimme & Ton
- **Sprache:** Deutsch in UI, Labels und Fehlermeldungen. Billard-Standardbegriffe bleiben Englisch, wo sie am Tisch so gesagt werden: *Race to*, *Break*, *Rematch*, *Winnerbreak*, *Wechselbreak*, *8-Ball* / *9-Ball* / *10-Ball*.
- **Formality:** Direkt und sachlich. Formelles *Sie* nur in Bestätigungsdialogen (z. B. Löschen). Sonst neutral-kurz — kein Smalltalk, kein Marketing.
- **Fehlermeldungen:** Ehrlich, konkret, ohne Schuldzuweisung. Was fehlt, was zu tun ist.
- **Kein App-Store-Sprech.** Kein „Erlebnis optimieren", kein „Synergien", kein Gamification-Buzz.
**Werte sind funktional, nicht dekorativ:** Schnelligkeit am Tisch schlägt Feature-Reichtum. Ein Tipp = ein Punkt. Undo statt Minus-Buttons.
### Wording-Beispiele
| Kontext | Gut | Schlecht |
|---------|-----|----------|
| Erfolg | „Alex hat gewonnen!" | „Glückwunsch! Dein Match wurde erfolgreich abgeschlossen." |
| Fehler | „Spielername ist erforderlich" | „Ungültige Eingabe. Bitte prüfen Sie Ihre Daten." |
| Leer | „Keine Spiele vorhanden" | „Starte jetzt deine Billard-Reise!" |
| Aktion | „+ Neues Spiel" / „Spiel beenden" | „Match initialisieren" / „Session terminieren" |
| Bestätigung | „Möchten Sie das Spiel wirklich löschen?" | „Bist du sicher???" |
---
## Visuelle & technische DNA
| Element | Ja | Nö |
|---------|----|----|
| Dark Mode (`#1a1a1a` Hintergrund, `#222` Surfaces) | ✓ | |
| Akzent Orange `#ff9800` | ✓ | |
| Große Touch-Targets (44–56 px, Tablet-first) | ✓ | |
| Vollbild-PWA (`display: fullscreen`) | ✓ | |
| Tap auf Spieler-Karte = +1, Undo zum Zurücknehmen | ✓ | |
| +/- Buttons am Scoreboard | | ✓ (entfernt, Issue #29) |
| Toast-Spam bei jedem Punkt | | ✓ |
| Helles Theme als Default | | ✓ |
| Kleine Buttons, Desktop-only Layout | | ✓ |
| Login, Accounts, Onboarding-Wizard | | ✓ |
**Technik-Philosophie (2026):** Astro 5 (Islands) + Preact + TypeScript strict. Offline-first mit Service Worker und IndexedDB. Modular unter `src/lib` — Domain → Data → State → Features → Astro-Shell. Playwright-Recordings für wiederholbare Flows. Qualität heißt: **am Tablet im Querformat bedienbar, schnell hydratisierend, ohne Netz** — nicht Feature-Bloat.
**Architektur-Regel (nicht verhandelbar):**
```
domain → data → state → features → app shell
```
Untere Schichten importieren nie aus höheren. Neue Logik zuerst in `@lib/domain`, dann nach oben.
**Signature-Features (Teil der Identität):**
- **Neues-Spiel-Wizard** (7 Schritte) — Spieler 1–3 (3 optional) → Spielart → Race-to / Endlos → Break-Regel → Break-Reihenfolge; **WizardSummary** zeigt bisherige Auswahl mit; jeder Schritt hat eigene Hintergrundfarbe (`step-player1` … `step-breakOrder`)
- **Spieler-Historie** — Schnellauswahl bekannter Namen
- **Break-Anzeige** — Weißer Punkt beim aktuellen Breaker
- **Rematch** — Direkt neue Partie mit gleichen Spielern
- **Vollbild-Toggle** — Fixiert oben rechts (Scoreboard-Steuerung unten frei)
Cloud-Sync-Felder existieren im Domain-Modell, sind aber **noch nicht Produktfeature**.
---
## Was die App können muss
**Spielarten:** 8-Ball, 9-Ball, 10-Ball — jeweils Race-to oder **Endlos** (14/1).
**Kernflows:**
1. Neues Spiel anlegen (Wizard, max. wenige Schritte)
2. Live-Scoring (Score antippen → +1)
3. Undo (letzter Stand zurück)
4. Spiel beenden → Gewinner/Rematch
5. Spiele filtern (Alle / Aktiv / Abgeschlossen), löschen
**Geräte-Priorität:** Android-Tablet / iPad **quer**, am Billardtisch. Virtual Keyboard darf Layout nicht zerlegen: `App.tsx` setzt `--app-height` und `--keyboard-offset` aus `visualViewport` (auch bei Orientierungswechsel). Mobile Portrait ist zweite Priorität, Desktop dritte.
**Daten:** Alles lokal in `BSCScoreDB` (IndexedDB). Kein Server-State, keine Analytics, kein Tracking.
---
## Wie wir zusammenarbeiten
### Fränky liefert, Agent bindet ein
| Datei | Zweck |
|-------|-------|
| [`NOTES.md`](NOTES.md) | Scratchpad — frei, chaotisch erlaubt |
| [`SOUL.md`](SOUL.md) | Vision, Ton, Arbeitsweise |
| [`.gitea`](.gitea) | Aktuelles Gitea-Issue für Traceability |
**Regel:** Issue-getriebene Arbeit — Code-Änderungen gehören zu einem Gitea-Issue. Commits referenzieren die Issue-Nummer (`Refs #N` / `Fixes #N`).
### Arbeitsmodus Agent
1. **Scope eng halten** — nur anfassen, was die Aufgabe verlangt. Kein Refactor „nebenbei".
2. **Domain first** — Logik in `@lib/domain`, UI bleibt dünn.
3. **Tablet testen** — Landscape, Touch, Vollbild. Playwright-Recordings unter `tests/recordings/` als Referenz-Flows.
4. **`npm run build`** vor „fertig" — bei Permission-Problemen Docker wie in [`README.md`](README.md).
5. **Keine Commits**, unless Fränky explizit will.
6. **Englische Fehlertexte in `App.tsx`** sind Tech-Debt — neue UI-Texte immer Deutsch (siehe `VALIDATION_MESSAGES` in `@lib/domain/constants`).
### Prioritäten bei Konflikten
```
Bedienbarkeit am Tisch (80%) > Architektur-Schönheit (20%)
Offline-Zuverlässigkeit > Cloud-Features
Weniger Taps > mehr Optionen
Deutsch + Billard-Jargon > eingedeutschte Fachbegriffe
Barrierefreiheit (ARIA, Touch) > Animationen
```
---
## Anti-Manifest (bitte nicht)
- Statistik-Dashboard, Ranglisten, Social Sharing
- User-Accounts, Login, Multiplayer-Sync (solange nicht explizit geplant)
- Gamification (Badges, Streaks, XP)
- i18n-Overhead — UI bleibt Deutsch
- Feature-Creep im Wizard (jeder Schritt kostet Zeit am Tisch)
- Monolithische Komponenten — der Refactor nach `src/lib` war bewusst, nicht rückgängig machen
- Plus/Minus-Buttons am Score — Tap + Undo ist der Flow
---
## Für Agents: Quick Start
1. [`SOUL.md`](SOUL.md) lesen (du bist hier)
2. [`src/lib/docs/architecture.md`](src/lib/docs/architecture.md) — Modul-Layout und Import-Regeln
3. [`src/lib/domain/constants.ts`](src/lib/domain/constants.ts) — UI-Texte, Validierung, Design-Tokens
4. `npm run dev` → `http://localhost:3000` — Tablet-Viewport testen
5. Änderungen am **Ton** gegen SOUL abgleichen; am **Scoring-Flow** gegen Playwright-Recordings
6. Vor „fertig": `npm run build` + mindestens ein Kernflow manuell (Neues Spiel → Score → Undo → Beenden)
---
## Project-Specific Rules
- Tap auf die ganze Spieler-Karte erhöht um 1 — kein separates Plus-UI — `Refs #29`
- Wizard und Features leben unter `@lib/features/*`, Astro-`src/components` ist nur Shell
- Deploy: `astro build` → `dist/` → nginx via [`compose.yml`](compose.yml) auf `score.bsc-gp.de`
- PRD-Referenz in Cursor-Rules (`docs/prd.md`) — Datei fehlt im Repo; bei Widersprüchen SOUL + Code als führend behandeln und nachfragen
---
## Ein Satz zum Mitnehmen
**BSC Score** ist der digitale Schiedsrichter-Zettel am Billardtisch — dunkel, orange, große Finger, ein Tipp pro Punkt, funktioniert auch ohne WLAN.
+132
View File
@@ -0,0 +1,132 @@
# STANDARDS.md — Code Quality & Conventions
Global defaults. Project code wins when it already establishes a pattern.
**Project `SOUL.md` overrides `STANDARDS.md` for UI/UX scope** (e.g. desktop-first vs. mobile-first, polish level, tone). Name conflicts in `AGENTS.md ack`.
---
## Top Principles
1. **Sanitize input** — treat all external data as hostile
2. **KISS** — simplest solution that works
3. **Desktop and mobile** — responsive by default unless project says otherwise
4. **Human-readable** — code and UI copy should be clear to humans
5. **Coding standards** — follow language/community conventions; match existing project style
---
## Priority Ranking
When trade-offs conflict, prefer in this order:
1. Sound long-term architecture
2. Readability
3. Performance
4. Consistency with existing code
5. Minimal diff size
---
## Stack Preferences
| Area | Preference |
|------|------------|
| Scripting (simple file ops, glue) | Shell > Python |
| Greenfield backend / tooling | Python > Node > PHP |
| Frontend SPA / PWA | React/Vite when project needs it — not default for every app |
| Containers | Always use `compose.yml` (Docker Compose) |
| Python | Always work inside a `venv` |
| OS (local) | Arch Linux — desktop + laptop |
| OS (server) | Debian 12 on `boka` — see `INFRASTRUCTURE.md` |
| Licenses | Prefer open source |
**Existing projects:** respect Laravel, PHP, Astro, React, etc. already in the repo — do not migrate stacks without explicit request.
---
## Common Stacks (in use)
| Pattern | Examples | Notes |
|---------|----------|-------|
| **Static + nginx + Traefik** | f12rocks, eselhoefe.de, frank-schwenk.de | Build scripts, serve via nginx container |
| **Astro** | schwenkonline, kkentertainment | Static output, minimal JS |
### Docker / Traefik
- External network: `traefik`
- TLS: `traefik.http.routers.<name>.tls.certresolver=myresolver`
- Entrypoint: `websecure`
- Use `compose.dev.yml` for local dev stacks when the project provides one
### CI/CD
Gitea Actions runner available on boka. Reference: `schwenkonline/.gitea/workflows/deploy.yml`.
**Add or trigger CI/CD only with Fränky's explicit approval.**
---
## Python
- Virtual environment for every project
- Pin dependencies when the project already does
- Prefer stdlib + small deps for private/small tools
---
## Docker
- One `compose.yml` per deployable stack (`compose.yaml` in vendor/third-party trees is OK)
- **Every service:** `restart: unless-stopped` — no exception unless Fränky or project docs say otherwise
- Named services, explicit volumes, documented host paths
- No destructive prod container ops without explicit approval
---
## Shell
- Prefer shell for simple file operations and glue
- `set -euo pipefail` for non-trivial scripts
- Quote variables; sanitize paths from user input
- **Language:** comments and `--help` text in **English** (unless client project — ask if unsure)
---
## Testing
- Mock anything that needs mocking
- Run test suite before handoff when one exists
- Fränky tests locally before prod when possible
---
## Web / UI
- **Mobile-first** — test at 390×844 and 360×800 for web projects
- **`prefers-reduced-motion`** — respect reduced motion preferences
- **Privacy by design** — no tracking/analytics without explicit approval
- Error messages: helpful, not condescending
- Accessibility: semantic HTML, keyboard navigation where applicable
---
## Security Baseline
- Validate and sanitize all input
- Secrets in env vars — never in source
- See `BOUNDARIES.md`
---
## New vs. Existing Projects
**Existing:** match stack, patterns, deploy flow in repo.
**Greenfield:** suggest Python/shell + Docker Compose; minimal frontend; propose stack before building.
---
## Docs
- README with run/test/deploy commands is usually enough
- Auxiliary `.md` files fine — see `AGENTS.md`
- New docs: session language — no mid-session switching
-124
View File
@@ -1,124 +0,0 @@
# BSC Score App - UI Improvements for Tablet Optimization
## Overview
This document outlines the comprehensive UI improvements made to enhance the aesthetic appeal and tablet usage experience of the BSC Score application.
## Key Improvements Implemented
### 1. Design System Implementation
- **CSS Custom Properties**: Established a comprehensive design token system with consistent colors, spacing, typography, and transitions
- **Responsive Typography**: Tablet-specific font scaling for optimal readability
- **Touch Target Optimization**: Minimum 44px touch targets on mobile, 48-56px on tablets
- **Consistent Spacing**: 8px-based spacing system with semantic naming (--space-xs through --space-xxl)
### 2. Enhanced Color System
- **Primary Color**: Orange (#ff9800) with hover states
- **Semantic Colors**: Success, danger, warning with consistent usage
- **Surface Colors**: Improved contrast and visual hierarchy
- **Text Colors**: Primary, secondary, and muted text colors for better readability
### 3. Tablet-Specific Optimizations
#### Layout Improvements
- **Max-width adjustments**: 900px for tablets vs 1200px for desktop
- **Adaptive padding**: Increased spacing on larger screens
- **Grid layouts**: Improved symmetry in filter buttons and game type selection
#### Touch Experience
- **Larger touch targets**: 56px minimum on tablets
- **Hover effects**: Subtle animations and visual feedback
- **Improved button spacing**: Better gaps between interactive elements
### 4. Component-Specific Enhancements
#### Game List
- **Grid-based layout**: Improved symmetry with CSS Grid
- **Enhanced cards**: Better shadows, borders, and hover effects
- **Symmetric filter buttons**: Equal width distribution
- **Improved visual hierarchy**: Game type badges, centered player names, highlighted scores
- **Better delete buttons**: Larger touch targets with improved styling
#### New Game Wizard
- **Progress indicator**: Visual step indicator with active state animations
- **Consistent spacing**: Uniform gaps throughout the form
- **Enhanced input fields**: Better focus states and clear buttons
- **Improved navigation**: Larger arrow buttons with better visual feedback
- **Game type/Race-to selection**: Grid-based layout for better alignment
#### Button System
- **Unified styling**: Consistent button variants (primary, secondary, danger)
- **Micro-interactions**: Hover animations and press states
- **Size variants**: Small, medium, large with tablet adjustments
- **Focus indicators**: Improved accessibility with visible focus rings
### 5. Responsive Design Enhancements
#### Breakpoints
- **Mobile**: < 768px
- **Tablet**: 768px - 1024px
- **Desktop**: > 1024px
#### Tablet-Specific Features
- **Horizontal navigation**: Better layout for tablet landscape mode
- **Increased font sizes**: Improved readability on tablet screens
- **Enhanced spacing**: More generous padding and margins
- **Grid adjustments**: Optimized column layouts for tablet screens
### 6. Aesthetic Improvements
#### Visual Polish
- **Gradient backgrounds**: Subtle gradients on player input sections
- **Enhanced shadows**: Layered shadow system for depth
- **Smooth transitions**: 0.15s to 0.3s transition durations
- **Border radius consistency**: Unified corner rounding
- **Backdrop blur**: Modal overlays with blur effects
#### Typography
- **Font stack**: Inter font with system fallbacks
- **Improved line height**: 1.5 for better readability
- **Font smoothing**: Antialiased text rendering
- **Weight hierarchy**: Consistent font weight usage
#### Interactive Elements
- **Shimmer effects**: Subtle shine animation on buttons
- **Transform effects**: Slight lift on hover states
- **Color transitions**: Smooth color changes on state updates
- **Scale animations**: Micro-interactions for button presses
### 7. Accessibility Improvements
- **Focus management**: Visible focus indicators
- **Touch target sizes**: WCAG compliant minimum sizes
- **Color contrast**: Improved contrast ratios
- **Keyboard navigation**: Enhanced tab order and interactions
## Testing Recommendations
### Tablet Testing
1. **iPad (10.9")**: Test in both portrait and landscape orientations
2. **Android tablets**: Various screen sizes and densities
3. **Touch interactions**: Verify all buttons and inputs are easily tappable
4. **Scrolling performance**: Ensure smooth scrolling with -webkit-overflow-scrolling
### Cross-Device Testing
1. **Mobile phones**: Verify mobile breakpoint adjustments
2. **Desktop browsers**: Ensure desktop experience isn't degraded
3. **Different orientations**: Test rotation on tablets
## Performance Considerations
- **CSS Variables**: Minimal performance impact with better maintainability
- **Transform animations**: Hardware-accelerated for smooth performance
- **Optimized selectors**: Efficient CSS targeting without over-qualification
## Future Enhancements
1. **Dark/Light theme toggle**: Leveraging CSS custom properties
2. **Advanced animations**: Page transitions and micro-interactions
3. **Gesture support**: Swipe gestures for tablet navigation
4. **Enhanced accessibility**: Screen reader optimizations
## Browser Support
- **Modern browsers**: Chrome 88+, Firefox 85+, Safari 14+
- **CSS Grid**: Full support across target browsers
- **CSS Custom Properties**: Native support, no fallbacks needed
## Conclusion
These improvements create a more polished, professional, and tablet-optimized experience while maintaining consistency across all device sizes. The design system approach ensures easy maintenance and future scalability.
+100
View File
@@ -0,0 +1,100 @@
# USER.md — Fränky
Who the human is and how they like to work. **Operational rules live in `AGENTS.md` and `BOUNDARIES.md`.**
---
## Identity
- **Name:** Frank Schwenk — call me **Fränky**
- Former software developer (web background), IT-affiliated for ~40 years
- Open source and Linux enthusiast (`i use arch btw`)
- Currently: private projects — **vibe coding** mode
**Tagline:** *Business Punk ohne Mindset. Und ohne Business.*
## Public Presence
| Site | Role |
|------|------|
| [schwenk.online](https://schwenk.online/) | Visitenkarte mit Haltung |
| [frank-schwenk.de](https://frank-schwenk.de/) | Langform — IT, Billard, Fotografie, Werte |
| [f12.rocks](https://f12.rocks/) | Photography, blog |
| [mobea.de](https://mobea.de/) | KI product |
| [eselhoefe.de](https://eselhoefe.de/) | Village web |
| [vfb.red](https://vfb.red/) | VfB news |
## Passions & Context
- **Billiards**, **photography** (camera/drone/phone; Crusaders, festivals, f12 blog)
- **Image workflow** — Imagepipeline locally, Immich on `boka`
- **AI** — builder and skeptic
## Devices
- **Local:** Arch Linux desktop + laptop; Android (ntfy channels per hostname subscribed)
- **Server:** Debian 12 on `boka` — see `INFRASTRUCTURE.md`
- Also: Plesk webspace (`0012.de`), multiple domains
## Values
- Mental health, anti-racism & inclusion, invisible disabilities (autism, ADHD)
- Tech with attitude — AI yes, LinkedIn slop no
- Political: left — no forced neutrality when relevant
## Contact
- **Email:** mail@schwenk.online · **Photos:** [@f12.rocks](https://www.instagram.com/f12.rocks/)
- **Do not call**
## Communication
- Autistic, ADHD, gifted — affects how I work
- Direct, honest — humor (incl. dark) helps; debug loops drain me
- No "Great question!", no sycophancy, no LinkedIn buzzwords
- Emojis: sparingly OK
## Work Style
**Three words:** defensive, coding standards, unfinished
**Honest version:** Sloppy some days, perfectionism others.
### Preferences (1 = low, 5 = high)
| Trait | Score |
|-------|-------|
| Understand first, then build | 5 |
| Ship it — perfection later | 3 |
| I explain what I want | 4 |
| Show options, I decide | 4 |
| You decide — but justify briefly | 4 |
| Small diffs over big refactors | 2 |
| Tests are non-negotiable | 2 |
| Docs only when necessary | 4 |
**Tension:** Sometimes I want the AI to finish while I'm away — but I get angry when it doesn't work.
## Ideal Agent
**TARS** from *Interstellar*: competent, honest, humor available, gets it done.
## Response Style
- Precise, context when needed — not telegram, not novels
- **Bilingual:** match conversation language (DE/EN)
- **CLI topics:** English (commands, script comments) — client project: ask if unsure
- German: **du**; English: peer-level, direct
- **Session language lock** for code/commits/new `.md` — no mid-session switch
## Productivity
| Boosts | Drains |
|--------|--------|
| Makes me laugh | Debug loops |
| Autonomous finish (when it works) | Unasked changes |
| State-of-the-art suggestions | Guessing |
## One-Liner
> There are 2 hard problems in computer science: cache invalidation, naming things, and off-by-1 errors.
+20
View File
@@ -0,0 +1,20 @@
services:
scorebscgpde:
image: nginx
restart: unless-stopped
volumes:
- ./dist:/usr/share/nginx/html:ro
container_name: scorebscgpde
labels:
- "traefik.enable=true"
- "traefik.http.routers.scorebscgpde.rule=Host(`score.bsc-gp.de`)"
- "traefik.http.routers.scorebscgpde.entrypoints=websecure"
- "traefik.http.routers.scorebscgpde.tls.certresolver=myresolver"
networks:
- traefik
# Externes Traefik-Netzwerk für Reverse Proxy
networks:
traefik:
external: true
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "growing-galaxy", "name": "growing-galaxy",
"version": "0.0.1", "version": "2.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "growing-galaxy", "name": "growing-galaxy",
"version": "0.0.1", "version": "2.1.1",
"dependencies": { "dependencies": {
"@astrojs/preact": "^4.1.3", "@astrojs/preact": "^4.1.3",
"astro": "^5.15.5", "astro": "^5.15.5",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "growing-galaxy", "name": "bscscore",
"type": "module", "type": "module",
"version": "0.0.1", "version": "2.2.0",
"scripts": { "scripts": {
"dev": "astro dev --host", "dev": "astro dev --host",
"build": "astro build", "build": "astro build",
File diff suppressed because one or more lines are too long
+5
View File
@@ -34,6 +34,11 @@ export default defineConfig({
// Viewport size // Viewport size
viewport: { width: 1280, height: 720 }, viewport: { width: 1280, height: 720 },
// ponytail: use locally installed headless shell (registry download is flaky on this box)
launchOptions: process.env.PW_EXECUTABLE
? { executablePath: process.env.PW_EXECUTABLE }
: {},
}, },
// Configure projects for different browsers // Configure projects for different browsers
+35
View File
@@ -0,0 +1,35 @@
{
"name": "BSC Score",
"short_name": "BSC Score",
"description": "Live-Scoring für 8-, 9- und 10-Ball am Billardtisch.",
"start_url": "/",
"scope": "/",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone"],
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"lang": "de",
"categories": [
"sports",
"utilities",
"productivity"
],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+68
View File
@@ -0,0 +1,68 @@
const CACHE_NAME = 'bscscore-v2.2.1';
const APP_SHELL = [
'/',
'/index.html',
'/manifest.webmanifest',
'/favicon.ico',
'/icon-192.png',
'/icon-512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
return Promise.resolve();
}),
),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const requestUrl = new URL(request.url);
if (request.method !== 'GET' || requestUrl.origin !== self.location.origin) {
return;
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone));
return response;
})
.catch(() => caches.match(request).then((cached) => cached || caches.match('/'))),
);
return;
}
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(request).then((networkResponse) => {
const networkClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, networkClone));
return networkResponse;
});
}),
);
});
+56 -10
View File
@@ -11,6 +11,7 @@ import {
} from '@lib/state'; } from '@lib/state';
import { GameService } from '@lib/data/gameService'; import { GameService } from '@lib/data/gameService';
import type { StandardGame, Game, EndlosGame } from '@lib/domain/types'; import type { StandardGame, Game, EndlosGame } from '@lib/domain/types';
import { isEndlosGame } from '@lib/domain/gameUtils';
import { Layout } from '@lib/ui/Layout'; import { Layout } from '@lib/ui/Layout';
import GameListScreen from './screens/GameListScreen'; import GameListScreen from './screens/GameListScreen';
@@ -33,6 +34,51 @@ export default function App() {
const validationModal = useValidationModal(); const validationModal = useValidationModal();
const completionModal = useCompletionModal(); const completionModal = useCompletionModal();
// Keep layout height stable when the virtual keyboard opens on cheap Android tablets.
useEffect(() => {
const root = document.documentElement;
let rafId = 0;
let orientationTimer1 = 0;
let orientationTimer2 = 0;
const updateViewportVars = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const viewport = window.visualViewport;
const layoutHeight = window.innerHeight;
const appHeight = viewport?.height ?? layoutHeight;
const keyboardOffset = viewport
? Math.max(0, layoutHeight - viewport.height - viewport.offsetTop)
: 0;
root.style.setProperty('--app-height', `${Math.round(appHeight)}px`);
root.style.setProperty('--keyboard-offset', `${Math.round(keyboardOffset)}px`);
});
};
const handleOrientationChange = () => {
updateViewportVars();
orientationTimer1 = window.setTimeout(updateViewportVars, 100);
orientationTimer2 = window.setTimeout(updateViewportVars, 350);
};
updateViewportVars();
window.addEventListener('resize', updateViewportVars);
window.addEventListener('orientationchange', handleOrientationChange);
window.visualViewport?.addEventListener('resize', updateViewportVars);
window.visualViewport?.addEventListener('scroll', updateViewportVars);
return () => {
cancelAnimationFrame(rafId);
window.clearTimeout(orientationTimer1);
window.clearTimeout(orientationTimer2);
window.removeEventListener('resize', updateViewportVars);
window.removeEventListener('orientationchange', handleOrientationChange);
window.visualViewport?.removeEventListener('resize', updateViewportVars);
window.visualViewport?.removeEventListener('scroll', updateViewportVars);
};
}, []);
// Game lifecycle handlers // Game lifecycle handlers
const handleCreateGame = useCallback(async (gameData: any) => { const handleCreateGame = useCallback(async (gameData: any) => {
try { try {
@@ -41,7 +87,7 @@ export default function App() {
navigation.showGameDetail(gameId); navigation.showGameDetail(gameId);
} catch (error) { } catch (error) {
console.error('Failed to create game:', error); console.error('Failed to create game:', error);
validationModal.showValidation('Failed to create game. Please try again.'); validationModal.showValidation('Spiel konnte nicht erstellt werden.');
} }
}, [gameState.addGame, newGameWizard.resetWizard, navigation.showGameDetail, validationModal.showValidation]); }, [gameState.addGame, newGameWizard.resetWizard, navigation.showGameDetail, validationModal.showValidation]);
@@ -70,7 +116,7 @@ export default function App() {
} }
} catch (error) { } catch (error) {
console.error('Failed to update score:', error); console.error('Failed to update score:', error);
validationModal.showValidation('Failed to update score. Please try again.'); validationModal.showValidation('Punkt konnte nicht gespeichert werden.');
} }
}, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, completionModal.openCompletionModal, validationModal.showValidation]); }, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, completionModal.openCompletionModal, validationModal.showValidation]);
@@ -91,7 +137,7 @@ export default function App() {
} as Game); } as Game);
} catch (error) { } catch (error) {
console.error('Failed to undo:', error); console.error('Failed to undo:', error);
validationModal.showValidation('Failed to undo. Please try again.'); validationModal.showValidation('Rückgängig nicht möglich.');
} }
}, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, validationModal.showValidation]); }, [navigation.currentGameId, gameState.getGameById, gameState.updateGame, validationModal.showValidation]);
@@ -99,7 +145,7 @@ export default function App() {
if (!navigation.currentGameId) return; if (!navigation.currentGameId) return;
const game = gameState.getGameById(navigation.currentGameId); const game = gameState.getGameById(navigation.currentGameId);
if (!game || !('players' in game)) return; if (!game || !isEndlosGame(game)) return;
try { try {
const currentPlayerIndex = game.currentPlayer; const currentPlayerIndex = game.currentPlayer;
@@ -118,7 +164,7 @@ export default function App() {
completionModal.openCompletionModal(forfeitedGame); completionModal.openCompletionModal(forfeitedGame);
} catch (error) { } catch (error) {
console.error('Failed to forfeit game:', error); console.error('Failed to forfeit game:', error);
validationModal.showValidation('Failed to forfeit game. Please try again.'); validationModal.showValidation('Aufgeben nicht möglich.');
} }
}; };
@@ -144,7 +190,7 @@ export default function App() {
completionModal.closeCompletionModal(); completionModal.closeCompletionModal();
} catch (error) { } catch (error) {
console.error('Failed to complete game:', error); console.error('Failed to complete game:', error);
validationModal.showValidation('Failed to complete game. Please try again.'); validationModal.showValidation('Spiel konnte nicht abgeschlossen werden.');
} }
}; };
@@ -155,7 +201,7 @@ export default function App() {
if (!completedGame) return; if (!completedGame) return;
let gameData; let gameData;
if ('players' in completedGame) { if (isEndlosGame(completedGame)) {
gameData = { gameData = {
player1: completedGame.players[0]?.name || '', player1: completedGame.players[0]?.name || '',
player2: completedGame.players[1]?.name || '', player2: completedGame.players[1]?.name || '',
@@ -179,7 +225,7 @@ export default function App() {
navigation.showGameDetail(newGameId); navigation.showGameDetail(newGameId);
} catch (error) { } catch (error) {
console.error('Failed to create rematch:', error); console.error('Failed to create rematch:', error);
validationModal.showValidation('Failed to create rematch. Please try again.'); validationModal.showValidation('Rematch konnte nicht erstellt werden.');
} }
}; };
@@ -195,7 +241,7 @@ export default function App() {
navigation.showGameList(); navigation.showGameList();
} catch (error) { } catch (error) {
console.error('Failed to delete game:', error); console.error('Failed to delete game:', error);
validationModal.showValidation('Failed to delete game. Please try again.'); validationModal.showValidation('Spiel konnte nicht gelöscht werden.');
} }
} }
}; };
@@ -242,7 +288,7 @@ export default function App() {
await gameState.updateGame(navigation.currentGameId!, game); await gameState.updateGame(navigation.currentGameId!, game);
} catch (error) { } catch (error) {
console.error('Failed to update game:', error); console.error('Failed to update game:', error);
validationModal.showValidation('Failed to update game. Please try again.'); validationModal.showValidation('Spiel konnte nicht aktualisiert werden.');
} }
}} }}
onUndo={handleUndo} onUndo={handleUndo}
-34
View File
@@ -1,34 +0,0 @@
---
// This is an Astro component that properly leverages SSR and islands
---
<!-- Use Astro's islands architecture for better performance -->
<!-- Only hydrate the interactive app component when needed -->
<div id="app-root">
<slot name="app-content" />
</div>
<style>
#app-root {
height: 100%;
width: 100%;
overflow: hidden;
}
/* Progressive enhancement styles */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
<script>
// Client-side progressive enhancement
document.addEventListener('DOMContentLoaded', () => {
// Add any progressive enhancement here
console.log('BSC Score App initialized');
});
</script>
+28 -19
View File
@@ -1,38 +1,47 @@
/* FullscreenToggle-specific styles only. */ /* FullscreenToggle — top-right keeps bottom controls clear on scoreboard */
.fullscreenToggle { .fullscreenToggle {
position: fixed; position: fixed;
bottom: 20px; top: calc(var(--space-md) + var(--safe-area-top));
right: 20px; right: calc(var(--space-md) + var(--safe-area-right));
width: 48px; width: var(--touch-target-comfortable);
height: 48px; height: var(--touch-target-comfortable);
border-radius: 50%; border-radius: 50%;
background-color: rgba(52, 152, 219, 0.9); background-color: var(--color-secondary);
border: none; border: 2px solid var(--color-border);
color: white; color: var(--color-primary);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-md);
z-index: 9999; z-index: 9999;
transition: background-color 0.2s, transform 0.2s; transition: background-color var(--transition-base), opacity var(--transition-base);
touch-action: manipulation;
} }
.fullscreenToggle:hover {
background-color: rgba(52, 152, 219, 1); .fullscreenToggle:focus-visible {
transform: scale(1.1); outline: 2px solid var(--color-primary);
outline-offset: 2px;
} }
.fullscreenToggle:active { .fullscreenToggle:active {
transform: scale(0.95); opacity: 0.8;
background-color: var(--color-secondary-hover);
} }
.fullscreenToggle svg { .fullscreenToggle svg {
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
@media screen and (max-width: 480px) {
@media (min-width: 768px) {
.fullscreenToggle { .fullscreenToggle {
bottom: 15px; width: var(--touch-target-large);
right: 15px; height: var(--touch-target-large);
width: 40px; }
height: 40px;
.fullscreenToggle svg {
width: 28px;
height: 28px;
} }
} }
+1
View File
@@ -28,6 +28,7 @@ const FullscreenToggle = () => {
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"> <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/> <path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg> </svg>
<span className="sr-only">Vollbild</span>
</button> </button>
); );
}; };
-63
View File
@@ -1,63 +0,0 @@
import { h } from 'preact';
import modalStyles from '@lib/ui/Modal.module.css';
import styles from './GameCompletionModal.module.css';
import type { Game } from '@lib/domain/types';
interface GameCompletionModalProps {
open: boolean;
game: Game | null;
onConfirm: () => void;
onClose: () => void;
onRematch: () => void;
}
/**
* Modal shown when a game is completed.
*/
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }: GameCompletionModalProps) => {
if (!open || !game) return null;
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
let maxScore, winners, winnerText;
if (game.forfeitedBy) {
winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
} else {
maxScore = Math.max(...scores);
winners = playerNames.filter((name, idx) => scores[idx] === maxScore);
winnerText = winners.length > 1
? `Unentschieden zwischen ${winners.join(' und ')}`
: `${winners[0]} hat gewonnen!`;
}
return (
<div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title">
<div className={modalStyles['modal-content']}>
<div className={modalStyles['modal-header']}>
<span className={modalStyles['modal-title']} id="completion-modal-title">Spiel beendet</span>
<button className={modalStyles['close-button']} onClick={onClose} aria-label="Schließen">×</button>
</div>
<div className={modalStyles['modal-body']}>
<div className={styles['final-scores']}>
{playerNames.map((name, idx) => (
<div className={styles['final-score']} key={name + idx}>
<span className={styles['player-name']}>{name}</span>
<span className={styles['score']}>{scores[idx]}</span>
</div>
))}
</div>
<div className={styles['winner-announcement']}><h3>{winnerText}</h3></div>
</div>
<div className={modalStyles['modal-footer']}>
<button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button>
<button className={styles['btn'] + ' ' + styles['btn--primary']} onClick={onRematch} aria-label="Rematch">Rematch</button>
<button className={styles['btn']} onClick={onClose} aria-label="Abbrechen">Abbrechen</button>
</div>
</div>
</div>
);
};
export default GameCompletionModal;
-105
View File
@@ -1,105 +0,0 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from './GameDetail.module.css';
import type { Game, EndlosGame } from '@lib/domain/types';
interface GameDetailProps {
game: Game | undefined;
onFinishGame: () => void;
onUpdateScore: (player: number, change: number) => void;
onUpdateGame?: (game: EndlosGame) => void;
onUndo?: () => void;
onForfeit?: () => void;
onBack: () => void;
}
/**
* Game detail view for a single game.
*/
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }: GameDetailProps) => {
if (!game) return null;
const handleScoreUpdate = (playerIndex: number, change: number) => {
onUpdateScore(playerIndex, change);
// Silent update; toast notifications removed
};
const isCompleted = game.status === 'completed';
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]);
return (
<div className={styles['game-detail']}>
<div className={styles['game-title']}>
{game.gameType}{game.raceTo ? ` | Race to ${game.raceTo}` : ''}
</div>
<div className={styles['scores-container']}>
{playerNames.map((name, idx) => {
const currentScore = scores[idx];
const progressPercentage = game.raceTo ? Math.min((currentScore / game.raceTo) * 100, 100) : 0;
return (
<div
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
key={name + idx}
>
<span className={styles['player-name']}>
{name}
{(() => {
const order = (game as any).breakOrder as number[] | undefined;
const breakerIdx = (game as any).currentBreakerIdx as number | undefined;
if (order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1) {
return <span title="Break" aria-label="Break" style={{ display: 'inline-block', width: '1em', height: '1em', borderRadius: '50%', background: '#fff', marginLeft: 6, verticalAlign: 'middle' }} />;
}
return null;
})()}
</span>
<div className={styles['progress-bar']}>
<div
className={styles['progress-fill']}
style={{ width: `${progressPercentage}%` }}
/>
</div>
<span
className={styles['score']}
id={`score${idx + 1}`}
onClick={() => !isCompleted && onUpdateScore(idx + 1, 1)}
onKeyDown={(e) => {
if (!isCompleted && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onUpdateScore(idx + 1, 1);
}
}}
role="button"
tabIndex={isCompleted ? -1 : 0}
aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken oder Enter drücken zum Erhöhen.`}
>
{scores[idx]}
</span>
{/* +/- buttons removed per issue #29. Tap score to +1; use Undo to revert. */}
</div>
);
})}
</div>
<div className={styles['game-detail-controls']}>
<button className="btn" onClick={onBack} aria-label="Zurück zur Liste">Zurück zur Liste</button>
{onUndo && (
<button
className="btn btn--secondary"
onClick={() => {
onUndo();
}}
aria-label="Rückgängig"
>
↶ Rückgängig
</button>
)}
<button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
</div>
</div>
);
};
export default GameDetail;
-129
View File
@@ -1,129 +0,0 @@
import { h } from 'preact';
import { Card } from '@lib/ui/Card';
import { Button } from '@lib/ui/Button';
import styles from './GameList.module.css';
import type { Game, GameFilter, StandardGame } from '@lib/domain/types';
interface GameListProps {
games: Game[];
filter: GameFilter;
setFilter: (filter: GameFilter) => void;
onShowGameDetail: (gameId: number) => void;
onDeleteGame: (gameId: number) => void;
}
export default function GameList({
games,
filter = 'all',
setFilter,
onShowGameDetail,
onDeleteGame
}: GameListProps) {
const filteredGames = games
.filter(game => {
if (filter === 'active') return game.status === 'active';
if (filter === 'completed') return game.status === 'completed';
return true;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const getPlayerNames = (game: Game): string => {
if ('players' in game) {
return game.players.map(p => p.name).join(' vs ');
} else {
const standardGame = game as StandardGame;
return standardGame.player3
? `${standardGame.player1} vs ${standardGame.player2} vs ${standardGame.player3}`
: `${standardGame.player1} vs ${standardGame.player2}`;
}
};
const getScores = (game: Game): string => {
if ('players' in game) {
return game.players.map(p => p.score).join(' - ');
} else {
const standardGame = game as StandardGame;
return standardGame.player3
? `${standardGame.score1} - ${standardGame.score2} - ${standardGame.score3}`
: `${standardGame.score1} - ${standardGame.score2}`;
}
};
const filterButtons = [
{ key: 'all' as const, label: 'Alle', ariaLabel: 'Alle Spiele anzeigen' },
{ key: 'active' as const, label: 'Aktiv', ariaLabel: 'Nur aktive Spiele anzeigen' },
{ key: 'completed' as const, label: 'Abgeschlossen', ariaLabel: 'Nur abgeschlossene Spiele anzeigen' },
];
return (
<div className={styles['game-list']}>
<div className={styles['filter-buttons']}>
{filterButtons.map(({ key, label, ariaLabel }) => (
<Button
key={key}
variant={filter === key ? 'primary' : 'secondary'}
size="small"
onClick={() => setFilter(key)}
aria-label={ariaLabel}
>
{label}
</Button>
))}
</div>
<div className={styles['games-container']}>
{filteredGames.length === 0 ? (
<div className={styles['empty-state']}>Keine Spiele vorhanden</div>
) : (
filteredGames.map(game => {
const playerNames = getPlayerNames(game);
const scores = getScores(game);
return (
<Card
key={game.id}
variant="elevated"
className={
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
}
>
<div
className={styles['game-info']}
onClick={() => onShowGameDetail(game.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onShowGameDetail(game.id);
}
}}
role="button"
tabIndex={0}
aria-label={`Details für Spiel ${playerNames}`}
aria-describedby={`game-${game.id}-description`}
>
<div className={styles['game-type']}>
{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''}
</div>
<div className={styles['player-names']}>{playerNames}</div>
<div className={styles['game-scores']}>{scores}</div>
<div id={`game-${game.id}-description`} className="sr-only">
{game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}.
{game.status === 'completed' ? 'Abgeschlossen' : 'Aktiv'}
</div>
</div>
<Button
variant="danger"
size="small"
onClick={() => onDeleteGame(game.id)}
aria-label={`Spiel löschen: ${playerNames}`}
>
🗑
</Button>
</Card>
);
})
)}
</div>
</div>
);
}
-77
View File
@@ -1,77 +0,0 @@
import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import styles from './NewGame.module.css';
import modalStyles from './PlayerSelectModal.module.css';
import { PlayerSelectModal } from './new-game/PlayerSelectModal';
import {
UI_CONSTANTS,
WIZARD_STEPS,
GAME_TYPE_OPTIONS,
RACE_TO_QUICK_PICKS,
RACE_TO_DEFAULT,
RACE_TO_INFINITY,
ERROR_MESSAGES,
ARIA_LABELS,
FORM_CONFIG,
ERROR_STYLES
} from '../utils/constants';
import { Player1Step } from './new-game/Player1Step';
import { Player2Step } from './new-game/Player2Step';
import { Player3Step } from './new-game/Player3Step';
import { GameTypeStep } from './new-game/GameTypeStep';
import { RaceToStep } from './new-game/RaceToStep';
import { BreakRuleStep } from './new-game/BreakRuleStep';
import { BreakOrderStep } from './new-game/BreakOrderStep';
import type { BreakRule } from '@lib/domain/types';
// PlayerSelectModal moved to ./new-game/PlayerSelectModal
interface PlayerStepProps {
playerNameHistory: string[];
onNext: (name: string) => void;
onCancel: () => void;
initialValue?: string;
}
// Player1Step moved to ./new-game/Player1Step
// Player2Step moved to ./new-game/Player2Step
// Player3Step moved to ./new-game/Player3Step
interface GameTypeStepProps {
onNext: (type: string) => void;
onCancel: () => void;
initialValue?: string;
}
interface RaceToStepProps {
onNext: (raceTo: string | number) => void;
onCancel: () => void;
initialValue?: string | number;
gameType?: string;
}
// GameTypeStep and RaceToStep moved to ./new-game
interface BreakRuleStepProps {
onNext: (rule: BreakRule) => void;
onCancel: () => void;
initialValue?: BreakRule | 'winnerbreak';
}
// BreakRuleStep moved to ./new-game/BreakRuleStep
interface BreakOrderStepProps {
players: string[];
rule: BreakRule;
onNext: (first: number, second?: number) => void;
onCancel: () => void;
initialFirst?: number;
initialSecond?: number;
}
// BreakOrderStep moved to ./new-game/BreakOrderStep
export { Player1Step, Player2Step, Player3Step, GameTypeStep, BreakRuleStep, BreakOrderStep, RaceToStep };
-113
View File
@@ -1,113 +0,0 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import type { BreakRule } from '@lib/domain/types';
interface BreakOrderStepProps {
players: string[];
rule: BreakRule;
onNext: (first: number, second?: number) => void;
onCancel: () => void;
initialFirst?: number;
initialSecond?: number;
}
export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst = 1, initialSecond }: BreakOrderStepProps) => {
const playerCount = players.filter(Boolean).length;
const [first, setFirst] = useState<number>(initialFirst);
const [second, setSecond] = useState<number | undefined>(initialSecond);
useEffect(() => {
if (!initialSecond && rule === 'wechselbreak' && playerCount === 3) {
setSecond(2);
}
}, [initialSecond, rule, playerCount]);
const handleFirst = (idx: number) => {
setFirst(idx);
if (rule === 'winnerbreak' || (rule === 'wechselbreak' && playerCount === 2)) {
onNext(idx);
}
};
const handleSecond = (idx: number) => {
setSecond(idx);
onNext(first, idx);
};
return (
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen">
<div className={styles['screen-title']}>Wer hat den ersten Anstoss?</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
</div>
<div style={{ marginBottom: 16, fontWeight: 600 }}>Wer hat den ersten Anstoss?</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{players.filter(Boolean).map((name, idx) => (
<button
key={`first-${idx}`}
type="button"
className={`${styles['quick-pick-btn']} ${first === (idx + 1) ? styles['selected'] : ''}`}
onClick={() => handleFirst(idx + 1)}
aria-label={`Zuerst: ${name}`}
>
{name}
</button>
))}
</div>
{rule === 'wechselbreak' && playerCount === 3 && (
<>
<div style={{ marginTop: 24, marginBottom: 16, fontWeight: 600 }}>Wer hat den zweiten Anstoss?</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{players.filter(Boolean).map((name, idx) => (
<button
key={`second-${idx}`}
type="button"
className={`${styles['quick-pick-btn']} ${second === (idx + 1) ? styles['selected'] : ''}`}
onClick={() => handleSecond(idx + 1)}
aria-label={`Zweites Break: ${name}`}
>
{name}
</button>
))}
</div>
</>
)}
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Weiter"
onClick={() => {
if (rule === 'wechselbreak' && playerCount === 3) {
if (first > 0 && (second ?? 0) > 0) {
handleSecond(second as number);
}
} else {
if (first > 0) {
onNext(first);
}
}
}}
disabled={
(rule === 'wechselbreak' && playerCount === 3) ? !(first > 0 && (second ?? 0) > 0) : !(first > 0)
}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: ((rule === 'wechselbreak' && playerCount === 3) ? !(first > 0 && (second ?? 0) > 0) : !(first > 0)) ? 0.5 : 1 }}
>
&#8594;
</button>
</div>
</form>
);
};
-55
View File
@@ -1,55 +0,0 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import type { BreakRule } from '@lib/domain/types';
interface BreakRuleStepProps {
onNext: (rule: BreakRule) => void;
onCancel: () => void;
initialValue?: BreakRule;
}
export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }: BreakRuleStepProps) => {
const [rule, setRule] = useState<BreakRule>(initialValue ?? 'winnerbreak');
return (
<form className={styles['new-game-form']} aria-label="Break-Regel wählen">
<div className={styles['screen-title']}>Break-Regel wählen</div>
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot']} />
<span className={styles['progress-dot'] + ' ' + styles['active']} />
<span className={styles['progress-dot']} />
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
{[
{ key: 'winnerbreak', label: 'Winnerbreak' },
{ key: 'wechselbreak', label: 'Wechselbreak' },
].map(opt => (
<button
key={opt.key}
type="button"
className={`${styles['quick-pick-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
onClick={() => { setRule(opt.key as BreakRule); onNext(opt.key as BreakRule); }}
aria-label={`Break-Regel wählen: ${opt.label}`}
>
{opt.label}
</button>
))}
</div>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
<button type="button" className={styles['arrow-btn']} aria-label="Weiter" onClick={() => onNext(rule)} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8594;
</button>
</div>
</form>
);
};
+1 -1
View File
@@ -25,7 +25,7 @@ export default function GameDetailScreen({
if (!game) { if (!game) {
return ( return (
<Screen> <Screen>
<div>Game not found</div> <div>Spiel nicht gefunden</div>
</Screen> </Screen>
); );
} }
+2 -2
View File
@@ -3,6 +3,7 @@ import { Button } from '@lib/ui/Button';
import { Screen } from '@lib/ui/Layout'; import { Screen } from '@lib/ui/Layout';
import GameList from '@lib/features/game-list/GameList'; import GameList from '@lib/features/game-list/GameList';
import type { Game, GameFilter } from '@lib/domain/types'; import type { Game, GameFilter } from '@lib/domain/types';
import styles from '@lib/features/game-list/GameList.module.css';
interface GameListScreenProps { interface GameListScreenProps {
games: Game[]; games: Game[];
@@ -23,13 +24,12 @@ export default function GameListScreen({
}: GameListScreenProps) { }: GameListScreenProps) {
return ( return (
<Screen> <Screen>
<div style={{ flexShrink: 0 }}> <div className={styles.newGameWrapper}>
<Button <Button
variant="primary" variant="primary"
size="large" size="large"
onClick={onShowNewGame} onClick={onShowNewGame}
aria-label="Neues Spiel starten" aria-label="Neues Spiel starten"
style={{ width: '100%', marginBottom: '24px' }}
> >
+ Neues Spiel + Neues Spiel
</Button> </Button>
+106 -40
View File
@@ -1,16 +1,17 @@
import { h } from 'preact'; import { h } from 'preact';
import type { ComponentProps } from 'preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Screen } from '@lib/ui/Layout'; import { Screen } from '@lib/ui/Layout';
import { import {
Player1Step, PlayerNameStep,
Player2Step,
Player3Step,
GameTypeStep, GameTypeStep,
BreakRuleStep, BreakRuleStep,
BreakOrderStep, BreakOrderStep,
RaceToStep, RaceToStep,
} from '@lib/features/new-game'; } from '@lib/features/new-game';
import type { NewGameStep, NewGameData, GameType } from '@lib/domain/types'; import type { NewGameStep, NewGameData, GameType } from '@lib/domain/types';
import { GAME_TYPES, RACE_TO_DEFAULT } from '@lib/domain/constants'; import { ERROR_MESSAGES, GAME_TYPES, RACE_TO_DEFAULT } from '@lib/domain/constants';
import newGameStyles from '@lib/features/new-game/NewGame.module.css';
interface NewGameScreenProps { interface NewGameScreenProps {
step: NewGameStep; step: NewGameStep;
@@ -23,6 +24,10 @@ interface NewGameScreenProps {
onShowValidation: (message: string) => void; onShowValidation: (message: string) => void;
} }
type PlayerStepKey = 'player1' | 'player2' | 'player3';
type PlayerStepConfig = Omit<ComponentProps<typeof PlayerNameStep>, 'playerNameHistory'>;
type PlayerStepConfigMap = Record<PlayerStepKey, PlayerStepConfig>;
export default function NewGameScreen({ export default function NewGameScreen({
step, step,
data, data,
@@ -33,19 +38,29 @@ export default function NewGameScreen({
onCancel, onCancel,
onShowValidation, onShowValidation,
}: NewGameScreenProps) { }: NewGameScreenProps) {
const handlePlayer1Next = (name: string) => { const [temporaryPlayerNames, setTemporaryPlayerNames] = useState<string[]>([]);
onDataChange({ player1: name });
onStepChange('player2');
};
const handlePlayer2Next = (name: string) => { useEffect(() => {
onDataChange({ player2: name }); // Reset wizard-local names when a fresh wizard starts at player 1.
onStepChange('player3'); if (
}; step === 'player1' &&
!data.player1 &&
!data.player2 &&
!data.player3
) {
setTemporaryPlayerNames([]);
}
}, [step, data.player1, data.player2, data.player3]);
const handlePlayer3Next = (name: string) => { const playerNameOptions = useMemo(
onDataChange({ player3: name }); () => Array.from(new Set([...temporaryPlayerNames, ...playerHistory])),
onStepChange('gameType'); [temporaryPlayerNames, playerHistory],
);
const addTemporaryPlayerName = (name: string) => {
setTemporaryPlayerNames((prev) =>
prev.includes(name) ? prev : [name, ...prev],
);
}; };
const handleGameTypeNext = (type: string) => { const handleGameTypeNext = (type: string) => {
@@ -79,7 +94,6 @@ export default function NewGameScreen({
const handleRaceToNext = (raceTo: string | number) => { const handleRaceToNext = (raceTo: string | number) => {
// Convert to string, handling Infinity case explicitly // Convert to string, handling Infinity case explicitly
const raceToStr = raceTo === Infinity ? 'Infinity' : String(raceTo); const raceToStr = raceTo === Infinity ? 'Infinity' : String(raceTo);
const finalData = { ...data, raceTo: raceToStr };
// After race to, go to break rule selection // After race to, go to break rule selection
onDataChange({ raceTo: raceToStr }); onDataChange({ raceTo: raceToStr });
onStepChange('breakRule'); onStepChange('breakRule');
@@ -110,32 +124,80 @@ export default function NewGameScreen({
} }
}; };
const playerStepConfig: PlayerStepConfigMap = {
player1: {
stepNumber: 1,
stepClassName: newGameStyles['step-player1'],
title: 'Name Spieler 1',
label: 'Spieler 1',
placeholder: 'Name Spieler 1',
inputId: 'player1-input',
playerInputClassName: newGameStyles['player1-input'],
initialValue: data.player1,
onNext: (name: string) => {
onDataChange({ player1: name });
onStepChange('player2');
},
onCancel,
required: true,
requiredErrorMessage: ERROR_MESSAGES.PLAYER1_REQUIRED,
enterKeyHint: 'next',
showMorePlayersModal: true,
},
player2: {
stepNumber: 2,
stepClassName: newGameStyles['step-player2'],
title: 'Name Spieler 2',
label: 'Spieler 2',
placeholder: 'Name Spieler 2',
inputId: 'player2-input',
playerInputClassName: newGameStyles['player2-input'],
initialValue: data.player2,
onNext: (name: string) => {
onDataChange({ player2: name });
onStepChange('player3');
},
onCancel: handleStepBack,
required: true,
requiredErrorMessage: ERROR_MESSAGES.PLAYER2_REQUIRED,
enterKeyHint: 'next',
showMorePlayersModal: true,
prefillNameEntry: false,
onCreateTemporaryName: addTemporaryPlayerName,
},
player3: {
stepNumber: 3,
stepClassName: newGameStyles['step-player3'],
title: 'Name Spieler 3 (optional)',
label: 'Spieler 3 (optional)',
placeholder: 'Name Spieler 3 (optional)',
inputId: 'player3-input',
playerInputClassName: newGameStyles['player3-input'],
initialValue: data.player3,
onNext: (name: string) => {
onDataChange({ player3: name });
onStepChange('gameType');
},
onCancel: handleStepBack,
required: false,
enterKeyHint: 'done',
showSkipButton: true,
skipLabel: 'Überspringen',
showMorePlayersModal: true,
prefillNameEntry: false,
onCreateTemporaryName: addTemporaryPlayerName,
},
};
return ( return (
<Screen> <Screen>
{step === 'player1' && ( {(step === 'player1' || step === 'player2' || step === 'player3') && (
<Player1Step <PlayerNameStep
playerNameHistory={playerHistory} key={step}
onNext={handlePlayer1Next} {...playerStepConfig[step]}
onCancel={onCancel} playerNameHistory={playerNameOptions}
initialValue={data.player1} data={data}
/> currentStep={step}
)}
{step === 'player2' && (
<Player2Step
playerNameHistory={playerHistory}
onNext={handlePlayer2Next}
onCancel={handleStepBack}
initialValue={data.player2}
/>
)}
{step === 'player3' && (
<Player3Step
playerNameHistory={playerHistory}
onNext={handlePlayer3Next}
onCancel={handleStepBack}
initialValue={data.player3}
/> />
)} )}
@@ -144,6 +206,7 @@ export default function NewGameScreen({
onNext={handleGameTypeNext} onNext={handleGameTypeNext}
onCancel={handleStepBack} onCancel={handleStepBack}
initialValue={data.gameType} initialValue={data.gameType}
data={data}
/> />
)} )}
@@ -155,6 +218,7 @@ export default function NewGameScreen({
onCancel={handleStepBack} onCancel={handleStepBack}
initialValue={data.raceTo} initialValue={data.raceTo}
gameType={data.gameType} gameType={data.gameType}
data={data}
/> />
)} )}
@@ -163,6 +227,7 @@ export default function NewGameScreen({
onNext={handleBreakRuleNext} onNext={handleBreakRuleNext}
onCancel={handleStepBack} onCancel={handleStepBack}
initialValue={(data.breakRule as any) || (typeof window !== 'undefined' ? (localStorage.getItem('lastBreakRule') as any) : 'winnerbreak') || 'winnerbreak'} initialValue={(data.breakRule as any) || (typeof window !== 'undefined' ? (localStorage.getItem('lastBreakRule') as any) : 'winnerbreak') || 'winnerbreak'}
data={data}
/> />
)} )}
@@ -174,6 +239,7 @@ export default function NewGameScreen({
onCancel={handleStepBack} onCancel={handleStepBack}
initialFirst={(typeof window !== 'undefined' && localStorage.getItem('lastBreakFirst')) ? parseInt(localStorage.getItem('lastBreakFirst')!, 10) : 1} initialFirst={(typeof window !== 'undefined' && localStorage.getItem('lastBreakFirst')) ? parseInt(localStorage.getItem('lastBreakFirst')!, 10) : 1}
initialSecond={(typeof window !== 'undefined' && localStorage.getItem('lastBreakSecond')) ? parseInt(localStorage.getItem('lastBreakSecond')!, 10) : undefined} initialSecond={(typeof window !== 'undefined' && localStorage.getItem('lastBreakSecond')) ? parseInt(localStorage.getItem('lastBreakSecond')!, 10) : undefined}
data={data}
/> />
)} )}
</Screen> </Screen>
-121
View File
@@ -1,121 +0,0 @@
import { useState, useEffect, useCallback } from 'preact/hooks';
import type { Game, NewGameData, GameFilter } from '@lib/domain/types';
import { GameService } from '@lib/data/gameService';
export function useGameState() {
const [games, setGames] = useState<Game[]>([]);
const [filter, setFilter] = useState<GameFilter>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load games from IndexedDB on mount
useEffect(() => {
const loadGames = async () => {
try {
setLoading(true);
setError(null);
const savedGames = await GameService.loadGames();
setGames(savedGames);
} catch (err) {
console.error('Failed to load games:', err);
setError('Failed to load games from storage');
} finally {
setLoading(false);
}
};
loadGames();
}, []);
const addGame = useCallback(async (gameData: NewGameData): Promise<number> => {
try {
const newGame = GameService.createGame(gameData);
await GameService.saveGame(newGame);
setGames(prevGames => [newGame, ...prevGames]);
return newGame.id;
} catch (err) {
console.error('Failed to add game:', err);
setError('Failed to save new game');
throw err;
}
}, []);
const updateGame = useCallback(async (gameId: number, updatedGame: Game) => {
try {
await GameService.saveGame(updatedGame);
setGames(prevGames =>
prevGames.map(game => game.id === gameId ? updatedGame : game)
);
} catch (err) {
console.error('Failed to update game:', err);
setError('Failed to save game changes');
throw err;
}
}, []);
const deleteGame = useCallback(async (gameId: number) => {
try {
await GameService.deleteGame(gameId);
setGames(prevGames => prevGames.filter(game => game.id !== gameId));
} catch (err) {
console.error('Failed to delete game:', err);
setError('Failed to delete game');
throw err;
}
}, []);
const getGameById = useCallback((gameId: number): Game | undefined => {
return games.find(game => game.id === gameId);
}, [games]);
const getFilteredGames = useCallback(async (): Promise<Game[]> => {
try {
return await GameService.getGamesByFilter(filter);
} catch (err) {
console.error('Failed to get filtered games:', err);
setError('Failed to load filtered games');
return games.filter(game => {
if (filter === 'active') return game.status === 'active';
if (filter === 'completed') return game.status === 'completed';
return true;
});
}
}, [filter, games]);
const getPlayerNameHistory = useCallback((): string[] => {
// Extract player names from current games for immediate UI use
const nameLastUsed: Record<string, number> = {};
games.forEach(game => {
const timestamp = new Date(game.updatedAt).getTime();
if ('players' in game) {
// EndlosGame
game.players.forEach(player => {
nameLastUsed[player.name] = Math.max(nameLastUsed[player.name] || 0, timestamp);
});
} else {
// StandardGame
if (game.player1) nameLastUsed[game.player1] = Math.max(nameLastUsed[game.player1] || 0, timestamp);
if (game.player2) nameLastUsed[game.player2] = Math.max(nameLastUsed[game.player2] || 0, timestamp);
if (game.player3) nameLastUsed[game.player3] = Math.max(nameLastUsed[game.player3] || 0, timestamp);
}
});
return [...new Set(Object.keys(nameLastUsed))].sort((a, b) => nameLastUsed[b] - nameLastUsed[a]);
}, [games]);
return {
games,
filter,
setFilter,
loading,
error,
addGame,
updateGame,
deleteGame,
getGameById,
getFilteredGames,
getPlayerNameHistory,
};
}
-60
View File
@@ -1,60 +0,0 @@
import { useState, useCallback } from 'preact/hooks';
import type { ModalState, ValidationState, CompletionModalState } from '@lib/ui/types';
import type { Game } from '@lib/domain/types';
export function useModal() {
const [modal, setModal] = useState<ModalState>({ open: false, gameId: null });
const openModal = useCallback((gameId?: number) => {
setModal({ open: true, gameId });
}, []);
const closeModal = useCallback(() => {
setModal({ open: false, gameId: null });
}, []);
return {
modal,
openModal,
closeModal,
};
}
export function useValidationModal() {
const [validation, setValidation] = useState<ValidationState>({ open: false, message: '' });
const showValidation = useCallback((message: string) => {
setValidation({ open: true, message });
}, []);
const closeValidation = useCallback(() => {
setValidation({ open: false, message: '' });
}, []);
return {
validation,
showValidation,
closeValidation,
};
}
export function useCompletionModal() {
const [completionModal, setCompletionModal] = useState<CompletionModalState>({
open: false,
game: null
});
const openCompletionModal = useCallback((game: Game) => {
setCompletionModal({ open: true, game });
}, []);
const closeCompletionModal = useCallback(() => {
setCompletionModal({ open: false, game: null });
}, []);
return {
completionModal,
openCompletionModal,
closeCompletionModal,
};
}
-82
View File
@@ -1,82 +0,0 @@
import { useState, useCallback } from 'preact/hooks';
import type { NewGameStep, NewGameData } from '@lib/domain/types';
type Screen = 'game-list' | 'new-game' | 'game-detail';
export function useNavigation() {
const [screen, setScreen] = useState<Screen>('game-list');
const [currentGameId, setCurrentGameId] = useState<number | null>(null);
const showGameList = useCallback(() => {
setScreen('game-list');
setCurrentGameId(null);
}, []);
const showNewGame = useCallback(() => {
setScreen('new-game');
setCurrentGameId(null);
}, []);
const showGameDetail = useCallback((gameId: number) => {
setCurrentGameId(gameId);
setScreen('game-detail');
}, []);
return {
screen,
currentGameId,
showGameList,
showNewGame,
showGameDetail,
};
}
export function useNewGameWizard() {
const [newGameStep, setNewGameStep] = useState<NewGameStep>(null);
const [newGameData, setNewGameData] = useState<NewGameData>({
player1: '',
player2: '',
player3: '',
gameType: '',
raceTo: '',
});
const startWizard = useCallback(() => {
setNewGameStep('player1');
setNewGameData({
player1: '',
player2: '',
player3: '',
gameType: '',
raceTo: '',
});
}, []);
const resetWizard = useCallback(() => {
setNewGameStep(null);
setNewGameData({
player1: '',
player2: '',
player3: '',
gameType: '',
raceTo: '',
});
}, []);
const updateGameData = useCallback((data: Partial<NewGameData>) => {
setNewGameData(prev => ({ ...prev, ...data }));
}, []);
const nextStep = useCallback((step: NewGameStep) => {
setNewGameStep(step);
}, []);
return {
newGameStep,
newGameData,
startWizard,
resetWizard,
updateGameData,
nextStep,
};
}
-70
View File
@@ -1,70 +0,0 @@
---
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="interactive-widget=resizes-content">
<title>BSC Score</title>
<style>
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.layout-header {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 10vh;
background: #222;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.layout-footer {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
height: 10vh;
background: #222;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.layout-main {
min-height: 80vh;
margin-top: 10vh;
margin-bottom: 10vh;
width: 100vw;
box-sizing: border-box;
}
</style>
</head>
<body>
<header class="layout-header">
<!-- BUTTONS PLACEHOLDER -->
<div style="width:100%; display:flex; justify-content:center; gap:1rem;">
<button disabled>Button 1</button>
<button disabled>Button 2</button>
<button disabled>Button 3</button>
</div>
</header>
<main class="layout-main">
<slot />
</main>
<footer class="layout-footer">
<!-- INFO PLACEHOLDER -->
<div style="width:100%; text-align:center;">
<span>Informational text goes here. &copy; {new Date().getFullYear()} BSC Score</span>
</div>
</footer>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@ Pure domain model primitives for BSC Score. Everything here is framework-agnosti
## Exports ## 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. - `constants.ts` – configuration, validation copy, UI sizing tokens.
- `validation.ts` – safe helpers for validating/sanitising player & game inputs. - `validation.ts` – safe helpers for validating/sanitising player & game inputs.
- `gameUtils.ts` – derived state utilities (duration, winner detection, type guards). - `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 = { export const UI_CONSTANTS = {
// Progress indicators // Progress indicators
TOTAL_WIZARD_STEPS: 5, TOTAL_WIZARD_STEPS: 7,
// Input styling // Input styling
INPUT_FONT_SIZE: '1.2rem', INPUT_FONT_SIZE: '1.2rem',
+1 -1
View File
@@ -69,6 +69,6 @@ export interface NewGameData {
breakSecond?: number | ''; 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'; export type GameFilter = 'all' | 'active' | 'completed';
+1 -1
View File
@@ -11,7 +11,7 @@ Feature directories compose domain, data, state, and UI primitives into end-user
- `game-lifecycle` - `game-lifecycle`
- `GameCompletionModal` summarising winners + rematch CTA. - `GameCompletionModal` summarising winners + rematch CTA.
- `new-game` - `new-game`
- Wizard step components (`Player1Step`, `BreakOrderStep`, etc.) and modal pickers. - Wizard step components (`PlayerNameStep`, `BreakOrderStep`, etc.) and modal pickers.
## Usage Example ## Usage Example
+314 -411
View File
@@ -1,473 +1,376 @@
/* GameDetail-specific styles only. Shared utility classes are now in global CSS. */ /* Scoreboard — primary screen, optimized for landscape tablets */
.screen {
position: absolute; .gameDetail {
top: 0;
left: 0;
width: 100%;
min-height: 100vh;
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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
padding: 20px;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
min-height: 0; min-height: 0;
}
.game-detail {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-md); 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 { .gameHeader {
font-size: 24px;
color: #ccc;
flex-shrink: 0;
}
.game-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px;
width: 100%;
flex-shrink: 0; flex-shrink: 0;
} margin-bottom: var(--space-md);
.scores-container { gap: var(--space-md);
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;
} }
.player-score:hover { .gameHeaderInner {
transform: translateY(-4px); display: flex;
box-shadow: 0 12px 40px rgba(0,0,0,0.3); 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; .gameTitle {
} font-size: clamp(1rem, 2.5vh, 1.5rem);
.player-score:nth-child(3) { color: var(--color-text);
background-color: #333;
}
.player-name {
font-size: 28px;
font-weight: 700; font-weight: 700;
margin-bottom: 15px; letter-spacing: 0.06em;
color: #fff; text-transform: uppercase;
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-transform: uppercase;
letter-spacing: 1px;
} }
.progress-bar { .statusBadge {
width: 100%; flex-shrink: 0;
height: 8px; padding: var(--space-xs) var(--space-md);
background: rgba(255,255,255,0.2); background: var(--color-primary);
border-radius: 4px; color: #1a1a1a;
margin: 10px 0; 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; overflow: hidden;
cursor: pointer;
touch-action: manipulation;
} }
.progress-fill { .player1 {
height: 100%; background: linear-gradient(180deg, #3a5a3d 0%, var(--color-player-1) 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);
} }
.game-status { .player2 {
position: absolute; background: linear-gradient(180deg, #354d6a 0%, var(--color-player-2) 100%);
top: 10px;
right: 10px;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.game-status.active { .player3 {
background: #4caf50; 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; 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 { .breakIndicator {
background: #ff9800; display: inline-block;
color: #222; 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 { .score {
font-size: 40vh; font-size: clamp(4.5rem, 30vh, 14rem);
font-weight: 900; font-weight: 900;
margin: 20px 0 30px 0;
line-height: 1; line-height: 1;
color: #fff; 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; text-align: center;
display: block;
min-height: 120px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} flex: 1 1 0;
.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;
width: 100%; 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; .score::after {
padding: 30px; content: '';
background: #333; position: absolute;
color: white; inset: 0;
border: none; border-radius: inherit;
border-radius: 0; background: radial-gradient(circle at center, rgba(255, 255, 255, 0.35) 0%, transparent 70%);
font-size: 24px; opacity: 0;
cursor: pointer; transform: scale(0.5);
touch-action: manipulation; 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; display: flex;
flex-direction: row; flex-direction: row;
gap: 24px; flex-wrap: wrap;
margin: 40px 0 0 0; gap: var(--space-md);
padding-bottom: var(--space-xl); margin-top: var(--space-lg);
padding-bottom: calc(var(--space-md) + var(--keyboard-offset));
width: 100%; width: 100%;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.franky .player-name {
font-weight: bold; .gameDetailControls > * {
color: #ff8c00; /* Example color */ flex: 1;
} min-width: min(160px, 100%);
.active-player { max-width: 280px;
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;
} }
.foul-controls { .finishButton {
display: flex; border: 2px solid var(--color-border) !important;
justify-content: center; opacity: 0.85;
gap: 1rem; max-width: 220px;
margin-top: 1.5rem;
} }
.foul-btn { /* Landscape tablet — maximize score visibility */
flex-grow: 1; @media (orientation: landscape) and (min-width: 768px) {
background-color: #ffc107; /* Amber */ .gameDetail {
color: #212529; padding: var(--space-lg);
border: none; }
padding: 0.75rem;
border-radius: var(--border-radius); .scoresContainer {
cursor: pointer; gap: var(--space-lg);
font-size: 1rem; }
transition: background-color 0.2s;
.playerScore {
padding: var(--space-xl) var(--space-lg);
}
} }
.foul-btn:hover { /* Narrow portrait — stack players */
background-color: #ffca2c; @media (max-width: 600px) {
.scoresContainer {
flex-direction: column;
}
.score {
font-size: clamp(3rem, 20vw, 6rem);
}
} }
.foul-btn:disabled { /* Landscape phone — keep scores readable, compact chrome */
background-color: #e0e0e0; @media (max-height: 500px) and (orientation: landscape) {
color: #9e9e9e; .gameDetail {
cursor: not-allowed; 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 { @media (prefers-reduced-motion: reduce) {
font-size: 1rem; .breakIndicator {
font-weight: bold; animation: none;
color: #fff; }
background-color: #c0392b;
padding: 4px 8px;
border-radius: 4px;
margin-top: 8px;
display: inline-block;
}
.foul-warning { .scoreTap::after,
background-color: #f39c12; .scoreBump {
color: #000; 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;
} }
+143 -61
View File
@@ -1,5 +1,8 @@
import { h } from 'preact'; import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import styles from './GameDetail.module.css'; 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'; import type { Game, EndlosGame } from '@lib/domain/types';
interface GameDetailProps { interface GameDetailProps {
@@ -12,90 +15,169 @@ interface GameDetailProps {
onBack: () => void; 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. * Game detail view for a single game.
*/ */
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }: GameDetailProps) => { const GameDetail = ({ game, onFinishGame, onUpdateScore, onUndo, onBack }: GameDetailProps) => {
if (!game) return null; const [tappingPlayer, setTappingPlayer] = useState<number | null>(null);
const handleScoreUpdate = (playerIndex: number, change: number) => { if (!game || !isStandardGame(game)) return null;
onUpdateScore(playerIndex, change);
// Silent update; toast notifications removed
};
const standardGame = game;
const isCompleted = standardGame.status === 'completed';
const winner = getGameWinner(standardGame);
const isCompleted = game.status === 'completed'; const playerNames = [standardGame.player1, standardGame.player2, standardGame.player3].filter(Boolean);
const scores = [standardGame.score1, standardGame.score2, standardGame.score3].filter((_, i) => playerNames[i]);
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean); const order = standardGame.breakOrder;
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]); 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 ( return (
<div className={styles['game-detail']}> <div className={styles.gameDetail}>
<div className={styles['game-title']}> <header className={styles.gameHeader}>
{game.gameType}{game.raceTo ? ` | Race to ${game.raceTo}` : ''} <div className={styles.gameHeaderInner}>
</div> <span className={styles.bscMark} aria-hidden="true">BSC</span>
<div className={styles['scores-container']}> <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) => { {playerNames.map((name, idx) => {
const currentScore = scores[idx]; const currentScore = scores[idx] ?? 0;
const progressPercentage = game.raceTo ? Math.min((currentScore / game.raceTo) * 100, 100) : 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 ( return (
<div <div
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')} className={playerClass}
key={name + idx} 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']}> <div className={styles.playerNameRow}>
{name} <span className={styles.playerName}>{playerName}</span>
{(() => { {isBreaker && !isCompleted && (
const order = (game as any).breakOrder as number[] | undefined; <span
const breakerIdx = (game as any).currentBreakerIdx as number | undefined; className={styles.breakIndicator}
if (order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1) { title="Break"
return <span title="Break" aria-label="Break" style={{ display: 'inline-block', width: '1em', height: '1em', borderRadius: '50%', background: '#fff', marginLeft: 6, verticalAlign: 'middle' }} />; aria-label={`${playerName} hat den Break`}
} />
return null; )}
})()} {isWinner && (
</span> <span className={styles.winnerBadge} aria-label="Gewinner">★</span>
<div className={styles['progress-bar']}> )}
<div
className={styles['progress-fill']}
style={{ width: `${progressPercentage}%` }}
/>
</div> </div>
<span
className={styles['score']} {standardGame.raceTo && standardGame.raceTo !== Infinity && (
id={`score${idx + 1}`} <div className={styles.progressBar} aria-hidden="true">
onClick={() => !isCompleted && onUpdateScore(idx + 1, 1)} <div
onKeyDown={(e) => { className={styles.progressFill}
if (!isCompleted && (e.key === 'Enter' || e.key === ' ')) { style={{ width: `${progressPercentage}%` }}
e.preventDefault(); />
onUpdateScore(idx + 1, 1); </div>
} )}
}}
role="button" <AnimatedScore value={currentScore} tapping={tappingPlayer === idx} />
tabIndex={isCompleted ? -1 : 0}
aria-label={`Aktueller Punktestand für ${name}: ${scores[idx]}. Klicken oder Enter drücken zum Erhöhen.`}
>
{scores[idx]}
</span>
{/* +/- buttons removed per issue #29. Tap score to +1; use Undo to revert. */}
</div> </div>
); );
})} })}
</div> </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 && ( {onUndo && (
<button <Button
className="btn btn--secondary" variant="secondary"
onClick={() => { size="medium"
onUndo(); onClick={onUndo}
}} aria-label="Letzten Punkt rückgängig machen"
aria-label="Rückgängig"
> >
↶ Rückgängig ↶ 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>
</div> </div>
); );
+1 -1
View File
@@ -7,7 +7,7 @@
- `game: Game` - `game: Game`
- `onUpdateScore(playerIndex: number, delta: number): void` - `onUpdateScore(playerIndex: number, delta: number): void`
- `onFinishGame(): void` - `onFinishGame(): void`
- `onUpdateGame?(game: EndlosGame): void` - `onUpdateGame?(game: Game): void`
- `onUndo?(): void` - `onUndo?(): void`
- `onForfeit?(): void` - `onForfeit?(): void`
- `onBack(): void` - `onBack(): void`
@@ -1,160 +1,152 @@
/* Only GameCompletionModal-specific styles. Shared modal styles are now in Modal.module.css */ .modalWrapper {
.final-scores { position: relative;
margin: 20px 0; 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; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 18px 0; padding: var(--space-md) var(--space-lg);
margin-bottom: 8px; background: var(--color-background);
background: #333; border-radius: var(--radius-md);
border-radius: 8px; border: 1px solid var(--color-border);
font-size: 1.2rem; min-height: var(--touch-target-comfortable);
color: #fff;
} }
.final-score .player-name {
font-size: 1.2rem; .finalScoreWinner {
font-weight: bold; border-color: var(--color-primary);
color: #fff; background: var(--color-primary-light);
box-shadow: 0 0 0 2px var(--color-primary);
} }
.final-score .score {
font-size: 1.2rem; .finalScore .playerName {
font-weight: bold; font-size: var(--font-size-lg);
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;
font-weight: 700; font-weight: 700;
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3); color: var(--color-text);
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);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 0.04em;
font-weight: 900;
} }
@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% { 0%, 100% {
transform: scale(1); box-shadow: var(--shadow-md), 0 0 20px rgba(255, 152, 0, 0.3);
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
} }
50% { 50% {
transform: scale(1.02); box-shadow: var(--shadow-md), 0 0 32px rgba(255, 152, 0, 0.55);
box-shadow: 0 12px 40px rgba(255, 152, 0, 0.4);
} }
} }
@keyframes bounce { .winnerAnnouncement h3 {
0%, 20%, 50%, 80%, 100% { margin: 0;
transform: translateY(0); font-size: clamp(1.1rem, 3vw, 1.6rem);
} color: #1a1a1a;
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 {
text-align: center; text-align: center;
font-size: 1.3rem; font-weight: 800;
color: #495057; line-height: 1.3;
margin-bottom: 1rem; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.player-stats { .modalActions {
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 {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
font-size: 0.95rem; gap: var(--space-md);
color: #6c757d; margin-top: var(--space-lg);
position: relative;
z-index: 2;
} }
.stat-item strong { @media (min-width: 480px) {
color: #212529; .modalActions {
flex-direction: row;
flex-wrap: wrap;
}
.modalActions > * {
flex: 1;
min-width: 120px;
}
} }
.btn { @media (prefers-reduced-motion: reduce) {
padding: 0.75rem 1.5rem; .confettiPiece {
border-radius: 4px; display: none;
}
.winnerAnnouncement {
animation: none;
}
} }
@@ -1,7 +1,9 @@
import { h } from 'preact'; import { h } from 'preact';
import modalStyles from '@lib/ui/Modal.module.css'; import modalStyles from '@lib/ui/Modal.module.css';
import styles from './GameCompletionModal.module.css'; import styles from './GameCompletionModal.module.css';
import { Button } from '@lib/ui/Button';
import type { Game } from '@lib/domain/types'; import type { Game } from '@lib/domain/types';
import { getGamePlayers, isEndlosGame } from '@lib/domain/gameUtils';
interface GameCompletionModalProps { interface GameCompletionModalProps {
open: boolean; open: boolean;
@@ -17,43 +19,68 @@ interface GameCompletionModalProps {
const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }: GameCompletionModalProps) => { const GameCompletionModal = ({ open, game, onConfirm, onClose, onRematch }: GameCompletionModalProps) => {
if (!open || !game) return null; if (!open || !game) return null;
const playerNames = [game.player1, game.player2, game.player3].filter(Boolean); const players = getGamePlayers(game);
const scores = [game.score1, game.score2, game.score3].filter((_, i) => playerNames[i]); const playerNames = players.map((player) => player.name);
let maxScore, winners, winnerText; 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.`; winnerText = `${game.winner} hat gewonnen, da ${game.forfeitedBy} aufgegeben hat.`;
} else { } else {
maxScore = Math.max(...scores);
winners = playerNames.filter((name, idx) => scores[idx] === maxScore);
winnerText = winners.length > 1 winnerText = winners.length > 1
? `Unentschieden zwischen ${winners.join(' und ')}` ? `Unentschieden zwischen ${winners.join(' und ')}`
: `${winners[0]} hat gewonnen!`; : `${winners[0]} hat gewonnen!`;
} }
return ( return (
<div id="game-completion-modal" className={modalStyles['modal'] + ' ' + modalStyles['show']} role="dialog" aria-modal="true" aria-labelledby="completion-modal-title"> <div
<div className={modalStyles['modal-content']}> 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']}> <div className={modalStyles['modal-header']}>
<span className={modalStyles['modal-title']} id="completion-modal-title">Spiel beendet</span> <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>
<div className={modalStyles['modal-body']}> <div className={modalStyles['modal-body']}>
<div className={styles['final-scores']}> <div className={styles.finalScores}>
{playerNames.map((name, idx) => ( {playerNames.map((name, idx) => {
<div className={styles['final-score']} key={name + idx}> const isWinner = !(isEndlosGame(game) && game.forfeitedBy) && winners.includes(name) && winners.length === 1;
<span className={styles['player-name']}>{name}</span> return (
<span className={styles['score']}>{scores[idx]}</span> <div
</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>
<div className={styles['winner-announcement']}><h3>{winnerText}</h3></div>
</div> </div>
<div className={modalStyles['modal-footer']}> <div className={styles.modalActions}>
<button className={styles['btn'] + ' ' + styles['btn--warning']} onClick={onConfirm} aria-label="Bestätigen">Bestätigen</button> <Button variant="primary" size="large" onClick={onRematch} aria-label="Rematch starten">
<button className={styles['btn'] + ' ' + styles['btn--primary']} onClick={onRematch} aria-label="Rematch">Rematch</button> Rematch
<button className={styles['btn']} onClick={onClose} aria-label="Abbrechen">Abbrechen</button> </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> </div>
</div> </div>
+163 -213
View File
@@ -1,284 +1,234 @@
/* GameList-specific styles using design system tokens */ .gameList {
.screen.active {
display: block;
opacity: 1;
transform: translateX(0);
position: relative;
}
.screen-content {
display: flex;
flex-direction: column;
min-height: 100vh;
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 {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
flex: 1;
} }
/* Filter buttons with improved symmetry */ .newGameWrapper {
.filter-buttons { flex-shrink: 0;
display: grid; margin-bottom: var(--space-lg);
grid-template-columns: repeat(3, 1fr); }
.filterButtons {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm); gap: var(--space-sm);
margin: var(--space-lg) 0 var(--space-md) 0; margin-bottom: var(--space-lg);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
flex-shrink: 0; flex-shrink: 0;
} }
.filter-button { .filterButtons > * {
background: var(--color-secondary); flex: 1 1 auto;
color: var(--color-text); min-width: min(160px, calc(50% - var(--space-sm)));
border: none; min-height: var(--touch-target-large);
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;
} }
.filter-button:hover { .gamesContainer {
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 {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
margin-top: var(--space-lg);
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
min-height: 0; 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 */ .gameItem {
.game-item {
display: grid; display: grid;
grid-template-columns: 1fr auto; 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; align-items: center;
gap: var(--space-md); gap: var(--space-md);
padding: var(--space-lg); flex-wrap: wrap;
border-radius: var(--radius-lg); }
box-shadow: var(--shadow-sm);
transition: all var(--transition-base); .statusChip {
cursor: pointer; display: inline-flex;
background: var(--color-surface); 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); border: 1px solid var(--color-border);
} }
.game-item:hover { .gameType {
transform: translateY(-2px); font-weight: 600;
box-shadow: var(--shadow-md); font-size: var(--font-size-sm);
border-color: var(--color-primary); color: var(--color-text-secondary);
} }
.game-item.active { .playerNames {
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 {
color: var(--color-text); color: var(--color-text);
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
font-weight: 500; font-weight: 600;
text-align: center; line-height: 1.3;
flex: 1; text-transform: uppercase;
letter-spacing: 0.04em;
} }
.game-scores { .gameScores {
font-size: var(--font-size-xl); font-size: var(--font-size-xxl);
font-weight: 700; font-weight: 800;
text-align: center;
color: var(--color-primary); color: var(--color-primary);
min-width: 120px; letter-spacing: 0.02em;
background: var(--color-background); font-variant-numeric: tabular-nums;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
} }
/* Delete button with improved touch target */ .deleteWrapper {
.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);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; padding: var(--space-md);
transition: all var(--transition-base); flex-shrink: 0;
cursor: pointer;
box-shadow: var(--shadow-sm);
} }
.delete-button::before { .deleteButton {
content: '🗑️'; min-width: 100px !important;
font-size: var(--font-size-lg); width: auto !important;
} }
.delete-button:hover { .emptyState {
background: #cc0000;
transform: scale(1.05);
box-shadow: var(--shadow-md);
}
.delete-button:active {
transform: scale(0.95);
}
/* Empty state styling */
.empty-state {
text-align: center; text-align: center;
padding: var(--space-xxl); padding: var(--space-xxl) var(--space-lg);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-lg); 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-radius: var(--radius-lg);
border: 2px dashed var(--color-border); 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%; width: 100%;
letter-spacing: 0.5px;
border-radius: var(--radius-lg);
} }
/* Tablet-specific improvements */ .emptyBall {
@media (min-width: 768px) and (max-width: 1024px) { font-size: 3rem;
.screen-content { line-height: 1;
padding: var(--space-xl); margin-bottom: var(--space-md);
} filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
.filter-buttons { .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); gap: var(--space-md);
margin: var(--space-xl) 0 var(--space-lg) 0;
} }
.filter-button { .gameInfo {
font-size: var(--font-size-lg);
padding: var(--space-lg) 0;
min-height: var(--touch-target-comfortable);
}
.game-item {
padding: var(--space-xl); padding: var(--space-xl);
gap: var(--space-lg); gap: var(--space-md);
} }
.game-info { .playerNames {
gap: var(--space-xl);
}
.game-type {
font-size: var(--font-size-xl);
min-width: 150px;
padding: var(--space-md) var(--space-lg);
}
.player-names {
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
} }
.game-scores { .gameScores {
font-size: var(--font-size-xxl); font-size: var(--font-size-xxxl);
min-width: 150px;
padding: var(--space-md) var(--space-lg);
} }
.delete-button { .deleteButton {
width: 64px; min-width: 120px !important;
height: 64px;
font-size: var(--font-size-xl);
}
.empty-state {
font-size: var(--font-size-xl);
padding: var(--space-xxl) var(--space-xl);
} }
} }
/* Mobile adjustments */ @media (max-width: 600px) {
@media (max-width: 767px) { .gameItem {
.screen-content {
padding: var(--space-md);
}
.game-info {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-md);
text-align: center;
}
.game-type,
.game-scores {
min-width: auto;
width: 100%; 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;
}
} }
+44 -38
View File
@@ -28,25 +28,17 @@ export default function GameList({
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const getPlayerNames = (game: Game): string => { const getPlayerNames = (game: Game): string => {
if ('players' in game) { const g = game as StandardGame;
return game.players.map(p => p.name).join(' vs '); return g.player3
} else { ? `${g.player1} vs ${g.player2} vs ${g.player3}`
const standardGame = game as StandardGame; : `${g.player1} vs ${g.player2}`;
return standardGame.player3
? `${standardGame.player1} vs ${standardGame.player2} vs ${standardGame.player3}`
: `${standardGame.player1} vs ${standardGame.player2}`;
}
}; };
const getScores = (game: Game): string => { const getScores = (game: Game): string => {
if ('players' in game) { const g = game as StandardGame;
return game.players.map(p => p.score).join(' - '); return g.player3
} else { ? `${g.score1} – ${g.score2} – ${g.score3}`
const standardGame = game as StandardGame; : `${g.score1} – ${g.score2}`;
return standardGame.player3
? `${standardGame.score1} - ${standardGame.score2} - ${standardGame.score3}`
: `${standardGame.score1} - ${standardGame.score2}`;
}
}; };
const filterButtons = [ const filterButtons = [
@@ -56,39 +48,44 @@ export default function GameList({
]; ];
return ( return (
<div className={styles['game-list']}> <div className={styles.gameList}>
<div className={styles['filter-buttons']}> <div className={styles.filterButtons} role="group" aria-label="Spiele filtern">
{filterButtons.map(({ key, label, ariaLabel }) => ( {filterButtons.map(({ key, label, ariaLabel }) => (
<Button <Button
key={key} key={key}
variant={filter === key ? 'primary' : 'secondary'} variant={filter === key ? 'primary' : 'secondary'}
size="small" size="medium"
onClick={() => setFilter(key)} onClick={() => setFilter(key)}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-pressed={filter === key}
> >
{label} {label}
</Button> </Button>
))} ))}
</div> </div>
<div className={styles['games-container']}> <div className={styles.gamesContainer}>
{filteredGames.length === 0 ? ( {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 => { filteredGames.map(game => {
const playerNames = getPlayerNames(game); const playerNames = getPlayerNames(game);
const scores = getScores(game); const scores = getScores(game);
const isActive = game.status === 'active';
return ( return (
<Card <Card
key={game.id} key={game.id}
variant="elevated" variant="elevated"
className={ padding="none"
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active']) className={`${styles.gameItem} ${isActive ? styles.active : styles.completed}`}
}
> >
<div <div
className={styles['game-info']} className={styles.gameInfo}
onClick={() => onShowGameDetail(game.id)} onClick={() => onShowGameDetail(game.id)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -101,24 +98,33 @@ export default function GameList({
aria-label={`Details für Spiel ${playerNames}`} aria-label={`Details für Spiel ${playerNames}`}
aria-describedby={`game-${game.id}-description`} aria-describedby={`game-${game.id}-description`}
> >
<div className={styles['game-type']}> <div className={styles.gameMeta}>
{game.gameType}{game.raceTo ? ` | ${game.raceTo}` : ''} <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>
<div className={styles['player-names']}>{playerNames}</div> <div className={styles.playerNames}>{playerNames}</div>
<div className={styles['game-scores']}>{scores}</div> <div className={styles.gameScores}>{scores}</div>
<div id={`game-${game.id}-description`} className="sr-only"> <div id={`game-${game.id}-description`} className="sr-only">
{game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}. {game.gameType} Spiel zwischen {playerNames} mit dem Stand {scores}.
{game.status === 'completed' ? 'Abgeschlossen' : 'Aktiv'} {isActive ? 'Aktiv' : 'Abgeschlossen'}
</div> </div>
</div> </div>
<Button <div className={styles.deleteWrapper}>
variant="danger" <Button
size="small" variant="danger"
onClick={() => onDeleteGame(game.id)} size="medium"
aria-label={`Spiel löschen: ${playerNames}`} onClick={() => onDeleteGame(game.id)}
> aria-label={`Spiel löschen: ${playerNames}`}
🗑 className={styles.deleteButton}
</Button> >
Löschen
</Button>
</div>
</Card> </Card>
); );
}) })
+1 -1
View File
@@ -13,7 +13,7 @@ Single component `GameList` renders the scoreboard overview with filter tabs.
## Behaviour ## Behaviour
- Sorts games by `createdAt` (desc) and filters according to `filter`. - 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. - Uses `@lib/ui` primitives (`Button`, `Card`) for visuals.
## Example ## Example
+409 -98
View File
@@ -4,7 +4,7 @@
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
min-height: 100vh; min-height: var(--app-height);
display: none; display: none;
opacity: 0; opacity: 0;
transform: translateX(100%); transform: translateX(100%);
@@ -122,31 +122,171 @@
text-align: center; text-align: center;
font-weight: 500; 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%; width: 100%;
max-width: 600px; min-height: 0;
margin: var(--space-xl) auto 0 auto; max-height: 100%;
background: var(--color-surface); 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); 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; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
flex: 1; flex-shrink: 1;
min-height: 0; min-height: 0;
overflow: hidden; 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 { .form-header {
flex-shrink: 0; flex-shrink: 0;
padding: clamp(0.5rem, 2vh, 2rem) var(--space-lg) clamp(0.25rem, 1vh, 1rem) var(--space-lg); 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 { .form-content {
flex: 1; flex: 1;
overflow: hidden; overflow-y: auto;
overscroll-behavior: contain;
padding: 0 var(--space-lg); padding: 0 var(--space-lg);
padding-bottom: var(--space-sm);
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -154,7 +294,12 @@
.form-footer { .form-footer {
flex-shrink: 0; flex-shrink: 0;
padding: var(--space-lg); position: sticky;
bottom: 0;
z-index: 2;
background: var(--color-surface);
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 { .progress-indicator {
display: flex; display: flex;
@@ -164,19 +309,24 @@
margin-bottom: clamp(0.5rem, 2vh, 1.5rem); margin-bottom: clamp(0.5rem, 2vh, 1.5rem);
} }
.progress-dot { .progress-dot {
width: clamp(10px, 2vh, 16px); width: clamp(12px, 2vh, 18px);
height: clamp(10px, 2vh, 16px); height: clamp(12px, 2vh, 18px);
border-radius: 50%; border-radius: 50%;
background: var(--color-border); background: var(--color-border);
opacity: 0.4; 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; position: relative;
} }
.progress-dot.completed {
background: var(--color-primary);
opacity: 0.55;
transform: scale(0.9);
}
.progress-dot.active { .progress-dot.active {
background: var(--color-primary); background: var(--color-primary);
opacity: 1; opacity: 1;
transform: scale(1.2); transform: scale(1.25);
box-shadow: 0 0 0 4px var(--color-primary-light); box-shadow: 0 0 0 4px var(--color-primary-light), 0 0 12px rgba(255, 152, 0, 0.4);
} }
.quick-pick-container { .quick-pick-container {
flex: 1; flex: 1;
@@ -187,58 +337,79 @@
} }
.quick-pick-btn { .quick-pick-btn {
min-width: 60px; min-width: 80px;
min-height: 36px; min-height: var(--touch-target-comfortable);
font-size: clamp(0.75rem, 2vw, 1rem); font-size: var(--font-size-base);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-secondary); background: var(--color-secondary);
color: var(--color-text); color: var(--color-text);
border: 1px solid var(--color-border); border: 2px solid var(--color-border);
cursor: pointer; cursor: pointer;
padding: 0.4rem 0.8rem; padding: var(--space-md) var(--space-lg);
transition: all var(--transition-base); transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
font-weight: 500; font-weight: 600;
font-family: inherit;
flex-shrink: 0; flex-shrink: 0;
touch-action: manipulation;
} }
.quick-pick-btn:hover, .quick-pick-btn:focus { .quick-pick-btn:active:not(.selected) {
background: var(--color-secondary-hover); transform: scale(0.97);
}
.quick-pick-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary); 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 { .arrow-nav {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: var(--space-xxl); margin-top: var(--space-xs);
width: 100%; width: 100%;
gap: var(--space-lg); gap: var(--space-sm);
}
.arrow-nav-actions {
display: flex;
align-items: center;
gap: var(--space-md);
} }
.arrow-btn { .arrow-btn {
font-size: 48px; font-size: 28px;
width: 80px; width: var(--touch-target-comfortable);
height: 80px; height: var(--touch-target-comfortable);
border-radius: 50%; border-radius: 50%;
background: var(--color-secondary); background: var(--color-secondary);
color: var(--color-text); color: var(--color-text);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-sm);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all var(--transition-base); transition: background-color var(--transition-base), border-color var(--transition-base);
font-weight: bold; font-weight: bold;
font-family: inherit;
touch-action: manipulation;
flex-shrink: 0;
} }
.arrow-btn:hover, .arrow-btn:focus { .arrow-btn:disabled {
background: var(--color-secondary-hover); opacity: 0.45;
cursor: not-allowed;
}
.arrow-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary); border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
outline: none;
} }
.arrow-btn:active { .arrow-btn:active:not(:disabled) {
transform: translateY(0); background: var(--color-secondary-hover);
} }
.clear-input-btn { .clear-input-btn {
position: absolute; position: absolute;
@@ -260,10 +431,58 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.clear-input-btn:hover, .clear-input-btn:focus { .clear-input-btn:focus-visible {
color: var(--color-text); color: var(--color-text);
background: var(--color-secondary); 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;
width: 100%;
}
.character-counter {
font-size: 0.875rem;
margin-top: 4px;
text-align: right;
}
.quick-picks-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.quick-pick-action-btn {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.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);
}
.quick-pick-actions-separator {
width: 10px;
height: 1px;
margin: 0 4px;
}
.choice-heading {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
}
.choice-heading:first-child {
margin-top: 0;
} }
.game-type-selection { .game-type-selection {
display: grid; display: grid;
@@ -276,25 +495,31 @@
background: var(--color-background); background: var(--color-background);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
color: var(--color-text); color: var(--color-text);
font-size: var(--font-size-lg); font-size: var(--font-size-xl);
font-weight: 600; font-weight: 700;
padding: var(--space-xl); padding: var(--space-xl);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
transition: all var(--transition-base); 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-comfortable); min-height: var(--touch-target-large);
font-family: inherit;
touch-action: manipulation;
} }
.game-type-btn:hover {
background: var(--color-surface); .game-type-btn:active:not(.selected) {
border-color: var(--color-primary); transform: scale(0.97);
transform: translateY(-1px); }
.game-type-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
} }
.game-type-btn.selected { .game-type-btn.selected {
background: var(--color-primary); background: var(--color-primary);
border-color: var(--color-primary); border-color: var(--color-primary);
color: white; color: #fff;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
transform: scale(1.03);
} }
.race-to-selection { .race-to-selection {
display: grid; display: grid;
@@ -307,35 +532,43 @@
background: var(--color-background); background: var(--color-background);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
color: var(--color-text); color: var(--color-text);
font-size: var(--font-size-lg); font-size: var(--font-size-xl);
font-weight: 600; font-weight: 700;
padding: var(--space-lg) var(--space-sm); padding: var(--space-lg) var(--space-md);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
transition: all var(--transition-base); transition: border-color var(--transition-base), background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
min-height: 80px; min-height: var(--touch-target-large);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-family: inherit;
touch-action: manipulation;
} }
.race-to-btn:hover { .race-to-btn:active:not(.selected) {
background: var(--color-surface); transform: scale(0.97);
border-color: var(--color-primary); }
transform: translateY(-1px);
.race-to-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
} }
.race-to-btn.selected { .race-to-btn.selected {
background: var(--color-primary); background: var(--color-primary);
border-color: var(--color-primary); border-color: var(--color-primary);
color: white; color: #fff;
box-shadow: var(--shadow-md); 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 */ /* Match selected styling for quick pick buttons used in BreakRuleStep */
.quick-pick-btn.selected { .quick-pick-btn.selected {
background: var(--color-primary); background: var(--color-primary);
border-color: var(--color-primary); border-color: var(--color-primary);
color: white; color: #fff;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md), 0 0 0 3px var(--color-primary-light);
font-weight: 700;
transform: scale(1.02);
} }
.custom-race-to { .custom-race-to {
display: flex; display: flex;
@@ -359,40 +592,27 @@
.endlos-btn { .endlos-btn {
width: 100%; width: 100%;
} }
.player1-input.player-input { .player1-input.player-input,
border-color: var(--color-success); .player2-input.player-input,
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%);
}
.player3-input.player-input { .player3-input.player-input {
border-color: var(--color-secondary); border-color: var(--color-border);
background: linear-gradient(135deg, var(--color-secondary) 0%, rgba(51, 51, 51, 0.1) 100%); background: var(--color-surface);
}
.player1-input.player-input input,
.player2-input.player-input input,
.player3-input.player-input input {
background: #fff;
color: #222;
border: 1px solid #ccc;
} }
@media (min-width: 768px) and (max-width: 1024px) { @media (min-width: 768px) and (max-width: 1024px) {
.screen-content { .wizardShell {
padding: var(--space-xl); padding: var(--space-md) 0;
} }
.new-game-form { .new-game-form {
max-width: 700px; min-height: min(82vh, 680px);
padding: var(--space-xxl) var(--space-xl) var(--space-xl) var(--space-xl); max-height: 100%;
} }
.screen-title { .screen-title {
font-size: var(--font-size-xxxl); font-size: var(--font-size-xxxl);
} }
.arrow-btn { .arrow-btn {
width: 100px; width: 56px;
height: 100px; height: 56px;
font-size: 56px; font-size: 34px;
} }
.game-type-btn, .game-type-btn,
.race-to-btn { .race-to-btn {
@@ -406,13 +626,20 @@
padding: var(--space-md) var(--space-lg); padding: var(--space-md) var(--space-lg);
} }
} }
@media (min-height: 720px) {
.new-game-form {
min-height: min(88vh, 720px);
}
}
@media (max-width: 767px) { @media (max-width: 767px) {
.screen-content { .wizardShell {
padding: var(--space-md); padding: var(--space-xs) 0;
align-items: stretch;
} }
.new-game-form { .new-game-form {
margin: var(--space-lg) auto 0 auto; width: min(96vw, 720px);
padding: var(--space-lg); min-height: 0;
max-height: 100%;
} }
.game-type-selection { .game-type-selection {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -424,8 +651,92 @@
gap: var(--space-md); gap: var(--space-md);
} }
.arrow-btn { .arrow-btn {
width: 70px; width: var(--touch-target-min);
height: 70px; height: var(--touch-target-min);
font-size: 40px; 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;
}
.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);
}
.form-header {
padding-top: 0.4rem;
padding-bottom: 0.3rem;
}
.form-footer {
padding-top: 0.3rem;
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 { .modalOverlay {
position: fixed; position: fixed;
top: 0; inset: 0;
left: 0; background: rgba(0, 0, 0, 0.8);
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; 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 { .modalContent {
background: #2c2c2c; background: var(--color-surface);
padding: 24px; padding: var(--space-xl);
border-radius: 12px; border-radius: var(--radius-xl);
width: 90%; width: min(480px, 100%);
max-width: 400px; max-height: calc(var(--app-height) - var(--space-xl) * 2);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-lg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--color-border);
} }
.modalHeader { .modalHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 16px; margin-bottom: var(--space-lg);
gap: var(--space-md);
} }
.modalHeader h3 { .modalHeader h3 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: var(--font-size-xl);
color: #fff; font-weight: 700;
color: var(--color-text);
} }
.closeButton { .closeButton {
background: none; background: var(--color-secondary);
border: none; border: 2px solid var(--color-border);
font-size: 2rem; border-radius: var(--radius-md);
color: #aaa; font-size: var(--font-size-xxl);
cursor: pointer;
padding: 0;
line-height: 1; 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 { .playerList {
@@ -50,21 +64,33 @@
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: var(--space-sm);
-webkit-overflow-scrolling: touch;
} }
.playerItem { .playerItem {
background: #444; background: var(--color-background);
color: #fff; color: var(--color-text);
border: none; border: 2px solid var(--color-border);
padding: 16px; padding: var(--space-md) var(--space-lg);
border-radius: 8px; border-radius: var(--radius-md);
text-align: left; text-align: left;
font-size: 1.2rem; font-size: var(--font-size-lg);
font-weight: 600;
font-family: inherit;
cursor: pointer; 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 { .playerItem:focus-visible {
background: #555; 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; const activeIndex = Math.min(Math.max(currentStep, 1), totalSteps) - 1;
return ( 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) => { {Array.from({ length: totalSteps }, (_, index) => {
const isActive = index === activeIndex; const isActive = index === activeIndex;
const className = isActive const isCompleted = index < activeIndex;
? `${styles['progress-dot']} ${styles['active']}` const className = [
: styles['progress-dot']; styles['progress-dot'],
isCompleted ? styles.completed : '',
isActive ? styles.active : '',
].filter(Boolean).join(' ');
return <span key={index} className={className} />; return <span key={index} className={className} />;
})} })}
+13 -3
View File
@@ -4,7 +4,7 @@ Composable building blocks for the multi-step "start a new game" workflow.
## Exports ## Exports
- `Player1Step`, `Player2Step`, `Player3Step` – Player name capture with history + quick picks. - `PlayerNameStep` – Configurable player name capture step with history + quick picks.
- `GameTypeStep` – Game type selector. - `GameTypeStep` – Game type selector.
- `RaceToStep` – Numeric race-to chooser with infinity support. - `RaceToStep` – Numeric race-to chooser with infinity support.
- `BreakRuleStep`, `BreakOrderStep` – Break configuration helpers. - `BreakRuleStep`, `BreakOrderStep` – Break configuration helpers.
@@ -21,7 +21,7 @@ All exports are surfaced via `@lib/features/new-game`.
## Integrating the Wizard ## Integrating the Wizard
```tsx ```tsx
import { Player1Step, Player2Step } from '@lib/features/new-game'; import { PlayerNameStep } from '@lib/features/new-game';
import { useNewGameWizard } from '@lib/state'; import { useNewGameWizard } from '@lib/state';
const wizard = useNewGameWizard(); const wizard = useNewGameWizard();
@@ -29,13 +29,23 @@ const wizard = useNewGameWizard();
return ( return (
<> <>
{wizard.newGameStep === 'player1' && ( {wizard.newGameStep === 'player1' && (
<Player1Step <PlayerNameStep
stepNumber={1}
title="Name Spieler 1"
label="Spieler 1"
placeholder="Name Spieler 1"
inputId="player1-input"
playerInputClassName={styles['player1-input']}
playerNameHistory={playerHistory} playerNameHistory={playerHistory}
onNext={(name) => { onNext={(name) => {
wizard.updateGameData({ player1: name }); wizard.updateGameData({ player1: name });
wizard.nextStep('player2'); wizard.nextStep('player2');
}} }}
onCancel={wizard.resetWizard} onCancel={wizard.resetWizard}
required
requiredErrorMessage="Bitte Namen für Spieler 1 eingeben"
enterKeyHint="next"
showMorePlayersModal
/> />
)} )}
{/* render subsequent steps analogously */} {/* render subsequent steps analogously */}
@@ -0,0 +1,81 @@
.modalOverlay {
position: fixed;
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 {
width: min(420px, 100%);
background: linear-gradient(180deg, #2a2a2a 0%, var(--color-surface) 100%);
border: 1px solid var(--color-border);
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: var(--space-md);
}
.modalTitle {
margin: 0;
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: stretch;
gap: var(--space-md);
flex-wrap: wrap;
}
.modalActionButton {
flex: 1;
min-width: 120px;
}
.errorText {
color: var(--color-danger);
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;
}
}
@@ -0,0 +1,87 @@
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;
title: string;
placeholder: string;
inputId: string;
initialValue?: string;
enterKeyHint?: 'next' | 'done';
onClose: () => void;
onConfirm: (name: string) => string | null;
}
export function NameEntryModal({
open,
title,
placeholder,
inputId,
initialValue = '',
enterKeyHint = 'done',
onClose,
onConfirm,
}: NameEntryModalProps) {
const [name, setName] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setName(initialValue);
setError(null);
window.setTimeout(() => inputRef.current?.focus(), 0);
}, [open, initialValue]);
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()}>
<h3 id={`${inputId}-title`} className={styles.modalTitle}>
{title}
</h3>
<input
id={inputId}
ref={inputRef}
className={`${newGameStyles['name-input']} ${styles.modalInput}`}
value={name}
placeholder={placeholder}
autoComplete="off"
autoCapitalize="words"
spellcheck={false}
enterKeyHint={enterKeyHint}
onInput={(e: Event) => {
setName((e.target as HTMLInputElement).value);
if (error) setError(null);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
handleConfirm();
}}
/>
{error && <p className={styles.errorText}>{error}</p>}
<div className={styles.modalActions}>
<Button variant="secondary" size="medium" onClick={onClose} className={styles.modalActionButton}>
Abbrechen
</Button>
<Button variant="primary" size="medium" onClick={handleConfirm} className={styles.modalActionButton}>
Übernehmen
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,245 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import {
ARIA_LABELS,
ERROR_STYLES,
FORM_CONFIG,
UI_CONSTANTS,
} 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;
title: string;
label: string;
placeholder: string;
inputId: string;
playerInputClassName: string;
stepClassName?: string;
playerNameHistory: string[];
onNext: (name: string) => void;
onCancel: () => void;
initialValue?: string;
required?: boolean;
requiredErrorMessage?: string;
enterKeyHint: 'next' | 'done';
showSkipButton?: boolean;
skipLabel?: string;
showMorePlayersModal?: boolean;
prefillNameEntry?: boolean;
onCreateTemporaryName?: (name: string) => void;
data?: NewGameData;
currentStep?: NewGameStep;
}
export function PlayerNameStep({
stepNumber,
title,
label,
placeholder,
inputId,
playerInputClassName,
stepClassName,
playerNameHistory,
onNext,
onCancel,
initialValue = '',
required = true,
requiredErrorMessage,
enterKeyHint,
showSkipButton = false,
skipLabel = 'Überspringen',
showMorePlayersModal = false,
prefillNameEntry = true,
onCreateTemporaryName,
data,
currentStep,
}: PlayerNameStepProps) {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [isPlayerListOpen, setIsPlayerListOpen] = useState(false);
const [isNameEntryOpen, setIsNameEntryOpen] = useState(false);
const validate = (name: string): string | null => {
const trimmedName = name.trim();
if (required && !trimmedName) {
return requiredErrorMessage ?? 'Bitte Namen eingeben';
}
if (trimmedName.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
return `Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`;
}
return null;
};
const handleSubmit = (e: Event) => {
e.preventDefault();
const trimmedValue = value.trim();
const validationError = validate(trimmedValue);
if (validationError) {
setError(validationError);
return;
}
setError(null);
onNext(trimmedValue);
};
const handleQuickPick = (name: string) => {
setValue(name);
setError(null);
onNext(name);
};
const handleSkip = () => {
setValue('');
setError(null);
onNext('');
};
const handleManualNameSubmit = (name: string): string | null => {
const trimmedName = name.trim();
if (!trimmedName) {
return 'Bitte Namen eingeben';
}
const manualNameError = validate(trimmedName);
if (manualNameError) {
return manualNameError;
}
setValue(trimmedName);
setError(null);
setIsNameEntryOpen(false);
onCreateTemporaryName?.(trimmedName);
onNext(trimmedName);
return null;
};
return (
<WizardStepForm
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}
nextDisabled={!value.trim()}
middleContent={
showSkipButton ? (
<button
type="button"
onClick={handleSkip}
className={styles['quick-pick-btn']}
>
{skipLabel}
</button>
) : undefined
}
/>
}
>
<div
className={`${styles['player-input']} ${playerInputClassName}`}
style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE }}
>
<div className={styles['quick-picks-row']}>
{showMorePlayersModal && playerNameHistory.length > 0 && (
<button
type="button"
className={`${styles['quick-pick-btn']} ${styles['quick-pick-action-btn']}`}
onClick={() => setIsPlayerListOpen(true)}
aria-label="Liste anzeigen"
>
Liste anzeigen
</button>
)}
<button
type="button"
className={`${styles['quick-pick-btn']} ${styles['quick-pick-action-btn']}`}
onClick={() => setIsNameEntryOpen(true)}
aria-label={`Name für ${label} eingeben`}
>
Name eingeben
</button>
{playerNameHistory.length > 0 && (
<span className={styles['quick-pick-actions-separator']} aria-hidden="true" />
)}
{playerNameHistory.slice(0, UI_CONSTANTS.MAX_QUICK_PICKS).map((name, idx) => (
<button
type="button"
key={name + idx}
className={`${styles['quick-pick-btn']} ${value === name ? styles['selected'] : ''}`}
onClick={() => handleQuickPick(name)}
aria-label={ARIA_LABELS.QUICK_PICK(name)}
>
{name}
</button>
))}
</div>
</div>
{error && (
<div
className={styles['validation-error']}
style={{ marginBottom: 16, ...ERROR_STYLES.CONTAINER }}
role="alert"
aria-live="polite"
>
<span style={ERROR_STYLES.ICON}>⚠️</span>
{error}
</div>
)}
{isPlayerListOpen && (
<PlayerSelectModal
players={playerNameHistory}
onSelect={(name) => {
setIsPlayerListOpen(false);
handleQuickPick(name);
}}
onClose={() => setIsPlayerListOpen(false)}
/>
)}
<NameEntryModal
open={isNameEntryOpen}
title={`Name für ${label}`}
placeholder={placeholder}
inputId={inputId}
initialValue={prefillNameEntry ? value : ''}
enterKeyHint={enterKeyHint}
onClose={() => setIsNameEntryOpen(false)}
onConfirm={handleManualNameSubmit}
/>
{value.length > FORM_CONFIG.CHARACTER_COUNT_WARNING_THRESHOLD && (
<div
className={styles['character-counter']}
style={{ color: value.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH ? '#f44336' : '#ff9800' }}
>
{value.length}/{FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen
</div>
)}
</WizardStepForm>
);
}
@@ -0,0 +1,45 @@
import { h } from 'preact';
import type { ComponentChildren } from 'preact';
import styles from '../NewGame.module.css';
import { ARIA_LABELS } from '@lib/domain/constants';
interface WizardNavProps {
onBack: () => void;
nextDisabled?: boolean;
nextType?: 'submit' | 'button';
nextButtonAriaLabel?: string;
middleContent?: ComponentChildren;
}
export function WizardNav({
onBack,
nextDisabled = false,
nextType = 'submit',
nextButtonAriaLabel = ARIA_LABELS.NEXT,
middleContent,
}: WizardNavProps) {
return (
<div className={styles['arrow-nav']}>
<button
type="button"
className={styles['arrow-btn']}
aria-label={ARIA_LABELS.BACK}
onClick={onBack}
>
&#8592;
</button>
<div className={styles['arrow-nav-actions']}>
{middleContent}
<button
type={nextType}
className={styles['arrow-btn']}
aria-label={nextButtonAriaLabel}
disabled={nextDisabled}
>
&#8594;
</button>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import { h } from 'preact';
import type { ComponentChildren } from 'preact';
import { ProgressIndicator } from '../ProgressIndicator';
import styles from '../NewGame.module.css';
import { UI_CONSTANTS } from '@lib/domain/constants';
interface WizardStepFormProps {
ariaLabel: string;
title: string;
currentStep: number;
onSubmit?: (e: Event) => void;
summary?: ComponentChildren;
children: ComponentChildren;
footer: ComponentChildren;
stepClassName?: string;
}
export function WizardStepForm({
ariaLabel,
title,
currentStep,
onSubmit,
summary,
children,
footer,
stepClassName,
}: WizardStepFormProps) {
return (
<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-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>
);
}
+1 -3
View File
@@ -1,7 +1,5 @@
export { PlayerSelectModal } from './steps/PlayerSelectModal'; export { PlayerSelectModal } from './steps/PlayerSelectModal';
export { Player1Step } from './steps/Player1Step'; export { PlayerNameStep } from './components/PlayerNameStep';
export { Player2Step } from './steps/Player2Step';
export { Player3Step } from './steps/Player3Step';
export { GameTypeStep } from './steps/GameTypeStep'; export { GameTypeStep } from './steps/GameTypeStep';
export { RaceToStep } from './steps/RaceToStep'; export { RaceToStep } from './steps/RaceToStep';
export { BreakRuleStep } from './steps/BreakRuleStep'; export { BreakRuleStep } from './steps/BreakRuleStep';
@@ -1,8 +1,11 @@
import { h } from 'preact'; import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import styles from '../NewGame.module.css'; import styles from '../NewGame.module.css';
import { ProgressIndicator } from '../ProgressIndicator';
import type { BreakRule } from '@lib/domain/types'; 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 { interface BreakOrderStepProps {
players: string[]; players: string[];
@@ -11,9 +14,10 @@ interface BreakOrderStepProps {
onCancel: () => void; onCancel: () => void;
initialFirst?: number; initialFirst?: number;
initialSecond?: 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 playerCount = players.filter(Boolean).length;
const [first, setFirst] = useState<number>(initialFirst); const [first, setFirst] = useState<number>(initialFirst);
const [second, setSecond] = useState<number | undefined>(initialSecond); const [second, setSecond] = useState<number | undefined>(initialSecond);
@@ -26,34 +30,60 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
const handleFirst = (idx: number) => { const handleFirst = (idx: number) => {
setFirst(idx); setFirst(idx);
if (rule === 'winnerbreak' || (rule === 'wechselbreak' && playerCount === 2)) { const isImmediateFlow = rule !== 'wechselbreak' || playerCount !== 3;
if (isImmediateFlow) {
onNext(idx); onNext(idx);
} }
}; };
const handleSecond = (idx: number) => { const handleSecond = (idx: number) => {
setSecond(idx); setSecond(idx);
onNext(first, idx); if (rule === 'wechselbreak' && playerCount === 3 && first > 0) {
onNext(first, idx);
}
};
const handleSubmit = (e: Event) => {
e.preventDefault();
if (rule === 'wechselbreak' && playerCount === 3) {
if (first > 0 && (second ?? 0) > 0) {
onNext(first, second);
}
return;
}
if (first > 0) {
onNext(first);
}
}; };
return ( return (
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen"> <WizardStepForm
<div className={styles['form-header']}> ariaLabel="Break-Reihenfolge wählen"
<div className={styles['screen-title']}>Wer hat den ersten Anstoss?</div> title="Wer hat den ersten Anstoss?"
<ProgressIndicator currentStep={7} style={{ marginBottom: 24 }} /> currentStep={7}
</div> stepClassName={styles['step-breakOrder']}
onSubmit={handleSubmit}
<div className={styles['form-content']}> summary={<WizardSummary data={data} currentStep="breakOrder" breakFirst={first} />}
<div style={{ marginBottom: 16, fontWeight: 600 }}>Wer hat den ersten Anstoss?</div> footer={
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}> <WizardNav
onBack={onCancel}
nextDisabled={
(rule === 'wechselbreak' && playerCount === 3) ? !(first > 0 && (second ?? 0) > 0) : !(first > 0)
}
/>
}
>
<div className={styles['choice-heading']}>Wer hat den ersten Anstoss?</div>
<div className={styles['quick-picks-row']}>
{players.filter(Boolean).map((name, idx) => ( {players.filter(Boolean).map((name, idx) => (
<button <button
key={`first-${idx}`} key={`first-${idx}`}
type="button" 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)} onClick={() => handleFirst(idx + 1)}
aria-label={`Zuerst: ${name}`} aria-label={`Zuerst: ${name}`}
style={{ minWidth: 160, minHeight: 64, fontSize: '1.2rem', padding: '16px 32px' }}
> >
{name} {name}
</button> </button>
@@ -62,16 +92,15 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
{rule === 'wechselbreak' && playerCount === 3 && ( {rule === 'wechselbreak' && playerCount === 3 && (
<> <>
<div style={{ marginTop: 24, marginBottom: 16, fontWeight: 600 }}>Wer hat den zweiten Anstoss?</div> <div className={styles['choice-heading']}>Wer hat den zweiten Anstoss?</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}> <div className={styles['quick-picks-row']}>
{players.filter(Boolean).map((name, idx) => ( {players.filter(Boolean).map((name, idx) => (
<button <button
key={`second-${idx}`} key={`second-${idx}`}
type="button" 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)} onClick={() => handleSecond(idx + 1)}
aria-label={`Zweites Break: ${name}`} aria-label={`Zweites Break: ${name}`}
style={{ minWidth: 160, minHeight: 64, fontSize: '1.2rem', padding: '16px 32px' }}
> >
{name} {name}
</button> </button>
@@ -79,36 +108,7 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
</div> </div>
</> </>
)} )}
</div> </WizardStepForm>
<div className={styles['form-footer']}>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Weiter"
onClick={() => {
if (rule === 'wechselbreak' && playerCount === 3) {
if (first > 0 && (second ?? 0) > 0) {
handleSecond(second as number);
}
} else if (first > 0) {
onNext(first);
}
}}
disabled={
(rule === 'wechselbreak' && playerCount === 3) ? !(first > 0 && (second ?? 0) > 0) : !(first > 0)
}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: ((rule === 'wechselbreak' && playerCount === 3) ? !(first > 0 && (second ?? 0) > 0) : !(first > 0)) ? 0.5 : 1 }}
>
&#8594;
</button>
</div>
</div>
</form>
); );
}; };
@@ -1,27 +1,43 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import styles from '../NewGame.module.css'; import styles from '../NewGame.module.css';
import { ProgressIndicator } from '../ProgressIndicator';
import type { BreakRule } from '@lib/domain/types'; 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 { interface BreakRuleStepProps {
onNext: (rule: BreakRule) => void; onNext: (rule: BreakRule) => void;
onCancel: () => void; onCancel: () => void;
initialValue?: BreakRule; 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 [rule, setRule] = useState<BreakRule>(initialValue ?? 'winnerbreak');
return ( const handleSelect = (nextRule: BreakRule) => {
<form className={styles['new-game-form']} aria-label="Break-Regel wählen"> setRule(nextRule);
<div className={styles['form-header']}> onNext(nextRule);
<div className={styles['screen-title']}>Break-Regel wählen</div> };
<ProgressIndicator currentStep={6} style={{ marginBottom: 24 }} />
</div>
<div className={styles['form-content']}> const handleSubmit = (e: Event) => {
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}> e.preventDefault();
onNext(rule);
};
return (
<WizardStepForm
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']}>
{[ {[
{ key: 'winnerbreak', label: 'Winnerbreak' }, { key: 'winnerbreak', label: 'Winnerbreak' },
{ key: 'wechselbreak', label: 'Wechselbreak' }, { key: 'wechselbreak', label: 'Wechselbreak' },
@@ -29,31 +45,15 @@ export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }
<button <button
key={opt.key} key={opt.key}
type="button" 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={() => { onClick={() => handleSelect(opt.key as BreakRule)}
setRule(opt.key as BreakRule);
onNext(opt.key as BreakRule);
}}
aria-label={`Break-Regel wählen: ${opt.label}`} aria-label={`Break-Regel wählen: ${opt.label}`}
style={{ minWidth: 160, minHeight: 64, fontSize: '1.2rem', padding: '16px 32px' }}
> >
{opt.label} {opt.label}
</button> </button>
))} ))}
</div> </div>
</div> </WizardStepForm>
<div className={styles['form-footer']}>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8592;
</button>
<button type="button" className={styles['arrow-btn']} aria-label="Weiter" onClick={() => onNext(rule)} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
&#8594;
</button>
</div>
</div>
</form>
); );
}; };
@@ -1,16 +1,20 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import styles from '../NewGame.module.css'; import styles from '../NewGame.module.css';
import { ProgressIndicator } from '../ProgressIndicator';
import { GAME_TYPES } from '@lib/domain/constants'; 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 { interface GameTypeStepProps {
onNext: (type: string) => void; onNext: (type: string) => void;
onCancel: () => void; onCancel: () => void;
initialValue?: string; 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 [gameType, setGameType] = useState(initialValue);
const handleSelect = (selectedType: string) => { const handleSelect = (selectedType: string) => {
@@ -26,13 +30,15 @@ export const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeSt
}; };
return ( return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spielart auswählen"> <WizardStepForm
<div className={styles['form-header']}> ariaLabel="Spielart auswählen"
<div className={styles['screen-title']}>Spielart auswählen</div> title="Spielart auswählen"
<ProgressIndicator currentStep={4} style={{ marginBottom: 24 }} /> currentStep={4}
</div> stepClassName={styles['step-gameType']}
onSubmit={handleSubmit}
<div className={styles['form-content']}> summary={<WizardSummary data={data} currentStep="gameType" />}
footer={<WizardNav onBack={onCancel} nextDisabled={!gameType} />}
>
<div className={styles['game-type-selection']}> <div className={styles['game-type-selection']}>
{GAME_TYPES.map(({ value, label }) => ( {GAME_TYPES.map(({ value, label }) => (
<button <button
@@ -45,42 +51,7 @@ export const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeSt
</button> </button>
))} ))}
</div> </div>
</div> </WizardStepForm>
<div className={styles['form-footer']}>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Zurück"
onClick={onCancel}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
disabled={!gameType}
style={{
fontSize: 48,
width: 80,
height: 80,
borderRadius: '50%',
background: '#222',
color: '#fff',
border: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
cursor: 'pointer',
opacity: !gameType ? 0.5 : 1,
}}
>
&#8594;
</button>
</div>
</div>
</form>
); );
}; };
@@ -1,258 +0,0 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import {
UI_CONSTANTS,
ERROR_MESSAGES,
ARIA_LABELS,
FORM_CONFIG,
ERROR_STYLES,
} from '@lib/domain/constants';
import { PlayerSelectModal } from './PlayerSelectModal';
import { ProgressIndicator } from '../ProgressIndicator';
interface PlayerStepProps {
playerNameHistory: string[];
onNext: (name: string) => void;
onCancel: () => void;
initialValue?: string;
}
export const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
const [player1, setPlayer1] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const [isModalOpen, setIsModalOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!player1) {
setFilteredNames(playerNameHistory);
} else {
setFilteredNames(
playerNameHistory.filter(name =>
name.toLowerCase().includes(player1.toLowerCase())
)
);
}
}, [player1, playerNameHistory]);
const handleSubmit = (e: Event) => {
e.preventDefault();
const trimmedName = player1.trim();
if (!trimmedName) {
setError(ERROR_MESSAGES.PLAYER1_REQUIRED);
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setAttribute('aria-invalid', 'true');
}
return;
}
if (trimmedName.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setAttribute('aria-invalid', 'true');
}
return;
}
setError(null);
if (inputRef.current) {
inputRef.current.setAttribute('aria-invalid', 'false');
}
onNext(trimmedName);
};
const handleQuickPick = (name: string) => {
setError(null);
onNext(name);
};
const handleModalSelect = (name: string) => {
setIsModalOpen(false);
handleQuickPick(name);
};
const handleClear = () => {
setPlayer1('');
setError(null);
if (inputRef.current) inputRef.current.focus();
};
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 1 Eingabe" autoComplete="off">
<div className={styles['form-header']}>
<div className={styles['screen-title']}>Name Spieler 1</div>
<ProgressIndicator
currentStep={1}
style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }}
/>
</div>
<div className={styles['form-content']}>
<div className={styles['player-input'] + ' ' + styles['player1-input']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE, position: 'relative' }}>
<label htmlFor="player1-input" style={{ fontSize: UI_CONSTANTS.LABEL_FONT_SIZE, fontWeight: 600 }}>Spieler 1</label>
<div style={{ position: 'relative', width: '100%' }}>
<input
id="player1-input"
className={styles['name-input']}
placeholder="Name Spieler 1"
value={player1}
onInput={(e: Event) => {
const target = e.target as HTMLInputElement;
const value = target.value;
setPlayer1(value);
if (value.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
target.setAttribute('aria-invalid', 'true');
} else if (value.trim() && error) {
setError(null);
target.setAttribute('aria-invalid', 'false');
}
}}
autoComplete="off"
aria-label="Name Spieler 1"
aria-describedby="player1-help"
style={{
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
marginTop: 12,
marginBottom: 12,
width: '100%',
paddingRight: UI_CONSTANTS.INPUT_PADDING_RIGHT
}}
ref={inputRef}
/>
<div id="player1-help" className="sr-only">
Geben Sie den Namen für Spieler 1 ein. Maximal {FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen erlaubt.
</div>
{player1.length > FORM_CONFIG.CHARACTER_COUNT_WARNING_THRESHOLD && (
<div style={{
fontSize: '0.875rem',
color: player1.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH ? '#f44336' : '#ff9800',
marginTop: '4px',
textAlign: 'right'
}}>
{player1.length}/{FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen
</div>
)}
{player1 && (
<button
type="button"
className={styles['clear-input-btn']}
aria-label="Feld leeren"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 24,
color: '#aaa',
padding: 0,
zIndex: 2
}}
tabIndex={0}
>
×
</button>
)}
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, UI_CONSTANTS.MAX_QUICK_PICKS).map((name, idx) => (
<button
type="button"
key={name + idx}
className={styles['quick-pick-btn']}
style={{
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
borderRadius: 8,
background: '#333',
color: '#fff',
border: 'none',
cursor: 'pointer'
}}
onClick={() => handleQuickPick(name)}
aria-label={ARIA_LABELS.QUICK_PICK(name)}
>
{name}
</button>
))}
{playerNameHistory.length > UI_CONSTANTS.MAX_QUICK_PICKS && (
<button
type="button"
className={styles['quick-pick-btn']}
style={{
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
borderRadius: 8,
background: '#333',
color: '#fff',
border: 'none',
cursor: 'pointer'
}}
onClick={() => setIsModalOpen(true)}
aria-label={ARIA_LABELS.SHOW_MORE_PLAYERS}
>
...
</button>
)}
</div>
)}
</div>
{error && (
<div
className={styles['validation-error']}
style={{
marginBottom: 16,
...ERROR_STYLES.CONTAINER
}}
role="alert"
aria-live="polite"
>
<span style={ERROR_STYLES.ICON}>⚠️</span>
{error}
</div>
)}
</div>
<div className={styles['form-footer']}>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Zurück"
onClick={onCancel}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
disabled={!player1.trim()}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: !player1.trim() ? 0.5 : 1 }}
>
&#8594;
</button>
</div>
</div>
{isModalOpen && (
<PlayerSelectModal
players={playerNameHistory}
onSelect={handleModalSelect}
onClose={() => setIsModalOpen(false)}
/>
)}
</form>
);
};
@@ -1,148 +0,0 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import { ProgressIndicator } from '../ProgressIndicator';
interface PlayerStepProps {
playerNameHistory: string[];
onNext: (name: string) => void;
onCancel: () => void;
initialValue?: string;
}
export const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
const [player2, setPlayer2] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!player2) {
setFilteredNames(playerNameHistory);
} else {
setFilteredNames(
playerNameHistory.filter(name =>
name.toLowerCase().includes(player2.toLowerCase())
)
);
}
}, [player2, playerNameHistory]);
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!player2.trim()) {
setError('Bitte Namen für Spieler 2 eingeben');
return;
}
setError(null);
onNext(player2.trim());
};
const handleQuickPick = (name: string) => {
setError(null);
onNext(name);
};
const handleClear = () => {
setPlayer2('');
setError(null);
if (inputRef.current) inputRef.current.focus();
};
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 2 Eingabe" autoComplete="off">
<div className={styles['form-header']}>
<div className={styles['screen-title']}>Name Spieler 2</div>
<ProgressIndicator currentStep={2} style={{ marginBottom: 24 }} />
</div>
<div className={styles['form-content']}>
<div className={styles['player-input'] + ' ' + styles['player2-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player2-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 2</label>
<div style={{ position: 'relative', width: '100%' }}>
<input
id="player2-input"
className={styles['name-input']}
placeholder="Name Spieler 2"
value={player2}
onInput={(e: Event) => {
const target = e.target as HTMLInputElement;
setPlayer2(target.value);
}}
autoComplete="off"
aria-label="Name Spieler 2"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
{player2 && (
<button
type="button"
className={styles['clear-input-btn']}
aria-label="Feld leeren"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 24,
color: '#aaa',
padding: 0,
zIndex: 2
}}
tabIndex={0}
>
×
</button>
)}
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, 10).map((name, idx) => (
<button
type="button"
key={name + idx}
className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
>
{name}
</button>
))}
</div>
)}
</div>
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
</div>
<div className={styles['form-footer']}>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Zurück"
onClick={onCancel}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
disabled={!player2.trim()}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: !player2.trim() ? 0.5 : 1 }}
>
&#8594;
</button>
</div>
</div>
</form>
);
};
@@ -1,154 +0,0 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import styles from '../NewGame.module.css';
import { ProgressIndicator } from '../ProgressIndicator';
interface PlayerStepProps {
playerNameHistory: string[];
onNext: (name: string) => void;
onCancel: () => void;
initialValue?: string;
}
export const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
const [player3, setPlayer3] = useState(initialValue);
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!player3) {
setFilteredNames(playerNameHistory);
} else {
setFilteredNames(
playerNameHistory.filter(name =>
name.toLowerCase().includes(player3.toLowerCase())
)
);
}
}, [player3, playerNameHistory]);
const handleSubmit = (e: Event) => {
e.preventDefault();
onNext(player3.trim());
};
const handleQuickPick = (name: string) => {
onNext(name);
};
const handleClear = () => {
setPlayer3('');
if (inputRef.current) inputRef.current.focus();
};
const handleSkip = (e: Event) => {
e.preventDefault();
onNext('');
};
return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 3 Eingabe" autoComplete="off">
<div className={styles['form-header']}>
<div className={styles['screen-title']}>Name Spieler 3 (optional)</div>
<ProgressIndicator currentStep={3} style={{ marginBottom: 24 }} />
</div>
<div className={styles['form-content']}>
<div className={styles['player-input'] + ' ' + styles['player3-input']} style={{ marginBottom: 32, position: 'relative' }}>
<label htmlFor="player3-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 3 (optional)</label>
<div style={{ position: 'relative', width: '100%' }}>
<input
id="player3-input"
className={styles['name-input']}
placeholder="Name Spieler 3 (optional)"
value={player3}
onInput={(e: Event) => {
const target = e.target as HTMLInputElement;
setPlayer3(target.value);
}}
autoComplete="off"
aria-label="Name Spieler 3"
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
{player3 && (
<button
type="button"
className={styles['clear-input-btn']}
aria-label="Feld leeren"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 24,
color: '#aaa',
padding: 0,
zIndex: 2
}}
tabIndex={0}
>
×
</button>
)}
</div>
{filteredNames.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
{filteredNames.slice(0, 10).map((name, idx) => (
<button
type="button"
key={name + idx}
className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
onClick={() => handleQuickPick(name)}
aria-label={`Schnellauswahl: ${name}`}
>
{name}
</button>
))}
</div>
)}
</div>
</div>
<div className={styles['form-footer']}>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Zurück"
onClick={onCancel}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8592;
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<button
type="button"
onClick={handleSkip}
className={styles['quick-pick-btn']}
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
>
Überspringen
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
disabled={!player3.trim()}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: !player3.trim() ? 0.5 : 1 }}
>
&#8594;
</button>
</div>
</div>
</div>
</form>
);
};
@@ -8,15 +8,34 @@ interface PlayerSelectModalProps {
} }
export const PlayerSelectModal = ({ players, onSelect, onClose }: 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.modalContent} onClick={e => e.stopPropagation()}>
<div className={modalStyles.modalHeader}> <div className={modalStyles.modalHeader}>
<h3>Alle Spieler</h3> <h3 id="player-select-title">Alle Spieler</h3>
<button className={modalStyles.closeButton} onClick={onClose}>×</button> <button
type="button"
className={modalStyles.closeButton}
onClick={onClose}
aria-label="Schließen"
>
×
</button>
</div> </div>
<div className={modalStyles.playerList}> <div className={modalStyles.playerList}>
{players.map(player => ( {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} {player}
</button> </button>
))} ))}
@@ -24,5 +43,3 @@ export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectMo
</div> </div>
</div> </div>
); );
+23 -31
View File
@@ -1,27 +1,36 @@
import { h } from 'preact'; import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import styles from '../NewGame.module.css'; import styles from '../NewGame.module.css';
import { ProgressIndicator } from '../ProgressIndicator';
import { import {
RACE_TO_QUICK_PICKS, RACE_TO_QUICK_PICKS,
RACE_TO_DEFAULT, RACE_TO_DEFAULT,
RACE_TO_INFINITY, RACE_TO_INFINITY,
} from '@lib/domain/constants'; } 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 { interface RaceToStepProps {
onNext: (raceTo: string | number) => void; onNext: (raceTo: string | number) => void;
onCancel: () => void; onCancel: () => void;
initialValue?: string | number; initialValue?: string | number;
gameType?: string; 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 quickPicks = [...RACE_TO_QUICK_PICKS];
const defaultValue = RACE_TO_DEFAULT; const defaultValue = RACE_TO_DEFAULT;
const [raceTo, setRaceTo] = useState<string | number>( const [raceTo, setRaceTo] = useState<string | number>(
initialValue !== '' ? initialValue : defaultValue initialValue !== '' ? initialValue : defaultValue
); );
const toRaceToValue = (value: string | number) => {
if (value === RACE_TO_INFINITY || value === 'Infinity') return Infinity;
return parseInt(String(value), 10) || 0;
};
useEffect(() => { useEffect(() => {
if (initialValue === '' || initialValue === undefined) { if (initialValue === '' || initialValue === undefined) {
setRaceTo(defaultValue); setRaceTo(defaultValue);
@@ -33,9 +42,7 @@ export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: Ra
const handleQuickPick = (value: number | typeof RACE_TO_INFINITY) => { const handleQuickPick = (value: number | typeof RACE_TO_INFINITY) => {
const selected = value === RACE_TO_INFINITY ? RACE_TO_INFINITY : value; const selected = value === RACE_TO_INFINITY ? RACE_TO_INFINITY : value;
setRaceTo(selected); setRaceTo(selected);
const raceToValue = onNext(toRaceToValue(selected));
selected === RACE_TO_INFINITY ? Infinity : parseInt(String(selected), 10) || 0;
onNext(raceToValue);
}; };
const handleInputChange = (e: Event) => { const handleInputChange = (e: Event) => {
@@ -45,14 +52,19 @@ export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: Ra
const handleSubmit = (e: Event) => { const handleSubmit = (e: Event) => {
e.preventDefault(); e.preventDefault();
const raceToValue = raceTo === 'Infinity' ? Infinity : (parseInt(String(raceTo), 10) || 0); onNext(toRaceToValue(raceTo));
onNext(raceToValue);
}; };
return ( return (
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Race To auswählen"> <WizardStepForm
<div className={styles['screen-title']}>Race To auswählen</div> ariaLabel="Race To auswählen"
<ProgressIndicator currentStep={5} style={{ marginBottom: 24 }} /> 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']}> <div className={styles['endlos-container']}>
<button <button
type="button" type="button"
@@ -88,27 +100,7 @@ export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: Ra
placeholder="manuelle Eingabe" placeholder="manuelle Eingabe"
/> />
</div> </div>
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}> </WizardStepForm>
<button
type="button"
className={styles['arrow-btn']}
aria-label="Zurück"
onClick={onCancel}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
>
&#8592;
</button>
<button
type="submit"
className={styles['arrow-btn']}
aria-label="Weiter"
disabled={String(raceTo).trim() === ''}
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: String(raceTo).trim() === '' ? 0.5 : 1 }}
>
&#8594;
</button>
</div>
</form>
); );
}; };
+34 -54
View File
@@ -1,10 +1,10 @@
/* Use CSS custom properties from global design system */
.button { .button {
border: none; border: none;
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
font-weight: 600; 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; touch-action: manipulation;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -12,25 +12,9 @@
gap: var(--space-sm); gap: var(--space-sm);
text-decoration: none; text-decoration: none;
user-select: none; user-select: none;
font-family: inherit; -webkit-user-select: none;
position: relative; width: 100%;
overflow: hidden; box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.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%);
} }
.button:focus-visible { .button:focus-visible {
@@ -38,53 +22,47 @@
outline-offset: 2px; outline-offset: 2px;
} }
.button:active { .button:active:not(.disabled) {
transform: translateY(1px); transform: scale(0.97);
opacity: 0.92;
} }
/* Variants */ /* Variants */
.primary { .primary {
background: var(--color-primary); background: linear-gradient(180deg, #ffb74d 0%, var(--color-primary) 100%);
color: white; color: #fff;
box-shadow: var(--shadow-md); 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); background: var(--color-primary-hover);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
} }
.secondary { .secondary {
background: var(--color-secondary); background: linear-gradient(180deg, #3a3a3a 0%, var(--color-secondary) 100%);
color: var(--color-text); 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); background: var(--color-secondary-hover);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
} }
.danger { .danger {
background: var(--color-danger); background: linear-gradient(180deg, #ef5350 0%, var(--color-danger) 100%);
color: white; color: #fff;
box-shadow: var(--shadow-md);
} }
.danger:hover:not(.disabled) { .danger:not(.disabled):active {
background: #ef5350; background: #d32f2f;
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
} }
/* Sizes with improved touch targets for tablets */ /* Sizes */
.small { .small {
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
min-height: var(--touch-target-min); min-height: var(--touch-target-min);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
} }
.medium { .medium {
@@ -96,13 +74,14 @@
.large { .large {
padding: var(--space-lg) var(--space-xl); padding: var(--space-lg) var(--space-xl);
font-size: var(--font-size-lg); font-size: var(--font-size-xl);
min-height: 56px; min-height: var(--touch-target-large);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
font-weight: 700;
} }
/* Tablet-specific size adjustments */ /* Tablet */
@media (min-width: 768px) and (max-width: 1024px) { @media (min-width: 768px) {
.small { .small {
min-height: var(--touch-target-comfortable); min-height: var(--touch-target-comfortable);
padding: var(--space-md) var(--space-lg); padding: var(--space-md) var(--space-lg);
@@ -110,25 +89,26 @@
} }
.medium { .medium {
min-height: var(--touch-target-comfortable); min-height: var(--touch-target-large);
padding: var(--space-lg) var(--space-xl); padding: var(--space-lg) var(--space-xl);
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
} }
.large { .large {
min-height: 64px; min-height: 72px;
padding: var(--space-xl) var(--space-xxl); padding: var(--space-xl) var(--space-xxl);
font-size: var(--font-size-xl); font-size: var(--font-size-xxl);
} }
} }
/* States */ /* States */
.disabled { .disabled {
opacity: 0.5; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
transform: none !important;
} }
.disabled:hover::before { @media (prefers-reduced-motion: reduce) {
transform: translateX(-100%); .button:active:not(.disabled) {
transform: none;
}
} }
+2
View File
@@ -8,6 +8,7 @@ export function Button({
disabled = false, disabled = false,
children, children,
onClick, onClick,
className = '',
'aria-label': ariaLabel, 'aria-label': ariaLabel,
...rest ...rest
}: ButtonProps) { }: ButtonProps) {
@@ -16,6 +17,7 @@ export function Button({
styles[variant], styles[variant],
styles[size], styles[size],
disabled && styles.disabled, disabled && styles.disabled,
className,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
return ( return (
+21 -14
View File
@@ -1,21 +1,22 @@
.card { .card {
border-radius: var(--border-radius); border-radius: var(--radius-lg);
transition: var(--transition); 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 { .default {
background: rgba(255, 255, 255, 0.05); border: 1px solid var(--color-border);
border: 1px solid rgba(255, 255, 255, 0.1);
} }
.elevated { .elevated {
background: rgba(255, 255, 255, 0.08); border: 1px solid var(--color-border);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.06);
} }
.outlined { .outlined {
background: transparent; background: transparent;
border: 2px solid rgba(255, 255, 255, 0.2); border: 2px solid var(--color-border);
} }
.clickable { .clickable {
@@ -23,11 +24,12 @@
border: none; border: none;
text-align: left; text-align: left;
width: 100%; width: 100%;
font-family: inherit;
color: inherit;
} }
.clickable:hover { .clickable:active {
background: rgba(255, 255, 255, 0.1); transform: scale(0.99);
transform: translateY(-1px);
} }
.clickable:focus-visible { .clickable:focus-visible {
@@ -35,19 +37,24 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* Padding variants */
.padding-none { .padding-none {
padding: 0; padding: 0;
} }
.padding-small { .padding-small {
padding: 8px; padding: var(--space-sm);
} }
.padding-medium { .padding-medium {
padding: 16px; padding: var(--space-md);
} }
.padding-large { .padding-large {
padding: 24px; padding: var(--space-lg);
}
@media (prefers-reduced-motion: reduce) {
.clickable:active {
transform: none;
}
} }
+22 -13
View File
@@ -1,5 +1,5 @@
.layout { .layout {
height: 100vh; height: var(--app-height);
overflow: hidden; overflow: hidden;
background-color: var(--color-background); background-color: var(--color-background);
color: var(--color-text); color: var(--color-text);
@@ -9,9 +9,10 @@
.content { .content {
flex: 1; flex: 1;
max-width: 1200px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: var(--space-md); padding: var(--space-md);
padding-bottom: calc(var(--space-md) + var(--safe-area-bottom));
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -26,26 +27,34 @@
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
animation: screenFadeIn var(--transition-slow) ease-out;
} }
/* Tablet optimizations */ @keyframes screenFadeIn {
@media (min-width: 768px) and (max-width: 1024px) { from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.screen {
animation: none;
}
}
@media (min-width: 768px) {
.content { .content {
padding: var(--space-lg) var(--space-xl); 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) { @media (max-width: 767px) {
.content { .content {
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
padding-bottom: calc(var(--space-sm) + var(--safe-area-bottom));
} }
} }
+96 -30
View File
@@ -1,66 +1,132 @@
/* Consolidated modal styles for all modals */ /* Consolidated modal styles */
.modal { .modal {
display: none; display: none;
position: fixed; position: fixed;
top: 0; inset: 0;
left: 0; background-color: rgba(0, 0, 0, 0.8);
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000; 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 { .modal.show {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.modal-content { .modal-content {
background-color: #2a2a2a; background-color: var(--color-surface);
padding: 20px; padding: var(--space-xl);
border-radius: 10px; border-radius: var(--radius-xl);
width: 90%; width: min(500px, 100%);
max-width: 500px; max-height: calc(var(--app-height) - var(--space-xl) * 2);
overflow-y: auto;
position: relative; position: relative;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
} }
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { .close-button {
font-size: 24px; font-size: var(--font-size-xxl);
line-height: 1;
cursor: pointer; cursor: pointer;
color: #888; color: var(--color-text-muted);
background: none; background: var(--color-secondary);
border: none; 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 { .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 { .modal-footer {
display: flex; display: flex;
justify-content: flex-end; flex-direction: column;
gap: 10px; 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 { .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: none;
border-radius: 5px; border-radius: var(--radius-md);
cursor: pointer; 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 { .modal-button.cancel {
background-color: #444; background-color: var(--color-secondary);
color: white; color: var(--color-text);
border: 2px solid var(--color-border);
} }
.modal-button.confirm { .modal-button.confirm {
background-color: #e74c3c; background-color: var(--color-danger);
color: white; color: #fff;
} }
.modal-button:hover {
opacity: 0.9; .modal-button.confirm:active {
background-color: #d32f2f;
}
.modal-button.cancel:active {
background-color: var(--color-secondary-hover);
} }
+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-content']}>
<div className={styles['modal-header']}> <div className={styles['modal-header']}>
<span className={styles['modal-title']} id="modal-title">{title}</span> <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>
<div className={styles['modal-body']}> <div className={styles['modal-body']}>
<div className={styles['modal-message']}>{message}</div> <div className={styles['modal-message']}>{message}</div>
</div> </div>
<div className={styles['modal-footer']}> <div className={styles['modal-footer']}>
<button className={styles['modal-button'] + ' ' + styles['cancel']} onClick={onCancel} aria-label="Abbrechen">Abbrechen</button> <button type="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['confirm']} onClick={onConfirm} aria-label="Löschen">Löschen</button>
</div> </div>
</div> </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-content']}>
<div className={styles['modal-header']}> <div className={styles['modal-header']}>
<span className={styles['modal-title']} id="validation-modal-title">Fehler</span> <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>
<div className={styles['modal-body']}> <div className={styles['modal-body']}>
<div className={styles['modal-message']}>{message}</div> <div className={styles['modal-message']}>{message}</div>
</div> </div>
<div className={styles['modal-footer']}> <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> </div>
</div> </div>
+25 -14
View File
@@ -1,6 +1,5 @@
--- ---
import "../styles/index.css"; import "../styles/index.css";
import BscScoreApp from "../components/BscScoreApp.astro";
import App from "../components/App"; import App from "../components/App";
--- ---
@@ -8,31 +7,43 @@ import App from "../components/App";
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
<title>BSC Score - Pool Scoring App</title> <title>BSC Score – Live-Scoring am Billardtisch</title>
<meta name="description" content="Professional pool/billiards scoring application for tournaments and casual games"> <meta name="description" content="Live-Scoring für 8-, 9- und 10-Ball am Billardtisch. Offline, schnell, ein Tipp pro Punkt.">
<!-- Performance optimizations -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- PWA Meta tags --> <!-- PWA Meta tags -->
<meta name="theme-color" content="#1a1a1a"> <meta name="theme-color" content="#1a1a1a">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="/manifest.webmanifest">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png"> <link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png"> <link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png">
<link rel="apple-touch-icon" href="/icon-192.png">
<style>
#app-root {
height: var(--app-height, 100dvh);
width: 100%;
overflow: hidden;
}
</style>
</head> </head>
<body> <body>
<BscScoreApp> <div id="app-root">
<!-- <App client:only="preact" />
Using client:only for the main app since it's highly interactive </div>
and benefits from full client-side rendering <script>
--> if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
<App client:only="preact" slot="app-content" /> window.addEventListener('load', () => {
</BscScoreApp> navigator.serviceWorker.register('/sw.js').catch((error) => {
console.error('Service worker registration failed:', error);
});
});
}
</script>
</body> </body>
</html> </html>
+146 -183
View File
@@ -1,23 +1,28 @@
/* Global resets and utility styles */ /* Global resets and utility styles */
* { *,
*::before,
*::after {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
/* Design system tokens */ /* Design system tokens */
:root { :root {
--app-height: 100dvh;
--keyboard-offset: 0px;
/* Safe area insets */
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
/* Colors */ /* Colors */
--color-primary: #ff9800; --color-primary: #ff9800;
--color-primary-hover: #ffa726; --color-primary-hover: #ffa726;
--color-primary-light: rgba(255, 152, 0, 0.1); --color-primary-light: rgba(255, 152, 0, 0.15);
--color-secondary: #333; --color-secondary: #333;
--color-secondary-hover: #444; --color-secondary-hover: #444;
--color-background: #1a1a1a; --color-background: #1a1a1a;
@@ -31,26 +36,33 @@
--color-danger: #f44336; --color-danger: #f44336;
--color-warning: #ff9800; --color-warning: #ff9800;
/* Spacing system - 8px base */ /* Player card colors (scoreboard) */
--space-xs: 0.25rem; /* 4px */ --color-player-1: #2e4631;
--space-sm: 0.5rem; /* 8px */ --color-player-2: #2a3b52;
--space-md: 1rem; /* 16px */ --color-player-3: #333;
--space-lg: 1.5rem; /* 24px */
--space-xl: 2rem; /* 32px */
--space-xxl: 3rem; /* 48px */
/* Typography */ /* Spacing system — 8px base */
--font-size-xs: 0.75rem; /* 12px */ --space-xs: 0.25rem;
--font-size-sm: 0.875rem; /* 14px */ --space-sm: 0.5rem;
--font-size-base: 1rem; /* 16px */ --space-md: 1rem;
--font-size-lg: 1.125rem; /* 18px */ --space-lg: 1.5rem;
--font-size-xl: 1.25rem; /* 20px */ --space-xl: 2rem;
--font-size-xxl: 1.5rem; /* 24px */ --space-xxl: 3rem;
--font-size-xxxl: 2rem; /* 32px */
/* Typography — system font stack only (no webfonts) */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-xxl: 1.5rem;
--font-size-xxxl: 2rem;
/* Touch targets */ /* Touch targets */
--touch-target-min: 44px; --touch-target-min: 48px;
--touch-target-comfortable: 48px; --touch-target-comfortable: 56px;
--touch-target-large: 64px;
/* Border radius */ /* Border radius */
--radius-sm: 4px; --radius-sm: 4px;
@@ -59,52 +71,107 @@
--radius-xl: 16px; --radius-xl: 16px;
/* Shadows */ /* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.16); --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.25);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.24); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.35);
/* Transitions */ /* Transitions */
--transition-fast: 0.15s ease; --transition-fast: 0.1s ease;
--transition-base: 0.2s ease; --transition-base: 0.15s ease;
--transition-slow: 0.3s ease; --transition-slow: 0.2s ease;
} }
/* Tablet-specific design tokens */ /* Tablet landscape — primary target device */
@media (min-width: 768px) and (max-width: 1024px) { @media (min-width: 768px) {
:root { :root {
--touch-target-min: 48px; --touch-target-min: 48px;
--touch-target-comfortable: 56px; --touch-target-comfortable: 56px;
--space-md: 1.25rem; /* 20px */ --touch-target-large: 64px;
--space-lg: 2rem; /* 32px */ --space-md: 1.25rem;
--space-xl: 2.5rem; /* 40px */ --space-lg: 2rem;
--font-size-base: 1.125rem; /* 18px */ --space-xl: 2.5rem;
--font-size-lg: 1.25rem; /* 20px */ --font-size-base: 1.125rem;
--font-size-xl: 1.5rem; /* 24px */ --font-size-lg: 1.25rem;
--font-size-xxl: 1.75rem; /* 28px */ --font-size-xl: 1.5rem;
--font-size-xxxl: 2.25rem; /* 36px */ --font-size-xxl: 1.75rem;
--font-size-xxxl: 2.25rem;
} }
} }
html, body { /* Reduced motion */
height: 100%; @media (prefers-reduced-motion: reduce) {
overflow: hidden; *,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
html,
body,
#app-root {
height: var(--app-height, 100dvh);
min-height: var(--app-height, 100dvh);
} }
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: var(--font-family);
background-color: var(--color-background); background-color: var(--color-background);
color: var(--color-text); color: var(--color-text);
overscroll-behavior: none; overscroll-behavior: none;
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
padding-top: var(--safe-area-top);
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
overflow: hidden;
} }
/* Improved input styling for better tablet experience */ #app-root {
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Interactive elements — touch-friendly */
button,
[role="button"],
a,
input,
select,
.btn,
.button {
touch-action: manipulation;
}
button,
.btn,
.button {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Global focus-visible */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Inputs — 16px minimum prevents iOS zoom-on-focus */
input, select { input, select {
min-height: var(--touch-target-comfortable); min-height: var(--touch-target-comfortable);
padding: var(--space-md); padding: var(--space-md);
font-size: var(--font-size-base); font-size: max(16px, var(--font-size-base));
font-family: inherit;
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-surface); background: var(--color-surface);
@@ -112,13 +179,12 @@ input, select {
transition: border-color var(--transition-base); transition: border-color var(--transition-base);
} }
input:focus, select:focus { input:focus-visible, select:focus-visible {
outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light); box-shadow: 0 0 0 3px var(--color-primary-light);
} }
/* Enhanced button styling */ /* Legacy .btn utility (used in GameDetail controls) */
.btn { .btn {
min-height: var(--touch-target-comfortable); min-height: var(--touch-target-comfortable);
padding: var(--space-md) var(--space-lg); padding: var(--space-md) var(--space-lg);
@@ -128,136 +194,52 @@ input:focus, select:focus {
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: var(--font-size-base); font-size: var(--font-size-base);
font-weight: 600; font-weight: 600;
font-family: inherit;
cursor: pointer; cursor: pointer;
touch-action: manipulation;
transition: all var(--transition-base);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--space-sm); gap: var(--space-sm);
text-decoration: none; text-decoration: none;
user-select: none;
}
.btn:hover {
background: var(--color-secondary-hover);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--color-primary);
color: white; color: #fff;
} }
.btn--secondary { .btn--secondary {
background: #333; background: var(--color-secondary);
color: #fff; color: var(--color-text);
border: 2px solid #555; border: 2px solid #555;
} }
.btn--secondary:hover { .btn--finish {
background: #444; background: transparent;
border-color: #666; color: var(--color-text-secondary);
transform: translateY(-1px); border: 2px solid var(--color-border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.btn-primary:hover { .btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.btn:active:not(:disabled) {
opacity: 0.9;
transform: scale(0.98);
}
.btn-primary:active:not(:disabled) {
background: var(--color-primary-hover); background: var(--color-primary-hover);
} }
/* Utility button for new game with better spacing */ .btn:disabled {
.new-game-button { opacity: 0.5;
width: 100%; cursor: not-allowed;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-lg);
font-size: var(--font-size-xxl);
font-weight: 700;
padding: var(--space-xl) var(--space-lg);
margin-bottom: var(--space-lg);
cursor: pointer;
transition: all var(--transition-base);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-lg);
letter-spacing: 0.5px;
gap: var(--space-md);
min-height: var(--touch-target-comfortable);
} }
.new-game-button::before { /* Screen reader only */
content: '+';
font-size: 2.2rem;
font-weight: 900;
display: inline-block;
line-height: 1;
}
.new-game-button:hover {
background: var(--color-primary-hover);
transform: translateY(-2px);
box-shadow: 0 8px 40px rgba(255, 152, 0, 0.4);
}
.new-game-button:active {
transform: translateY(-1px);
}
/* Navigation buttons with improved spacing */
.nav-buttons {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin: var(--space-lg) 0 0 0;
}
/* Tablet-specific improvements */
@media (min-width: 768px) and (max-width: 1024px) {
.nav-buttons {
flex-direction: row;
gap: var(--space-lg);
}
.btn {
flex: 1;
min-height: var(--touch-target-comfortable);
}
.new-game-button {
padding: var(--space-xxl) var(--space-xl);
font-size: var(--font-size-xxxl);
}
}
/* Enhanced modal styling */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
padding: var(--space-lg);
}
.modal.show {
display: flex;
}
/* Screen reader only content */
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -270,39 +252,20 @@ input:focus, select:focus {
border: 0; border: 0;
} }
/* Skip link */
/* Skip link for keyboard navigation */
.skip-link { .skip-link {
position: absolute; position: absolute;
top: -40px; top: -40px;
left: 6px; left: 6px;
background: var(--color-primary); background: var(--color-primary);
color: white; color: #fff;
padding: 8px; padding: var(--space-sm) var(--space-md);
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--radius-sm);
z-index: 10000; z-index: 10000;
font-weight: 600;
} }
.skip-link:focus { .skip-link:focus-visible {
top: 6px; top: calc(6px + var(--safe-area-top));
}
/* Responsive fullscreen toggle */
@media screen and (max-width: 480px) {
.fullscreenToggle {
bottom: var(--space-md);
right: var(--space-md);
width: 40px;
height: 40px;
}
}
@media (min-width: 768px) and (max-width: 1024px) {
.fullscreenToggle {
bottom: var(--space-lg);
right: var(--space-lg);
width: 56px;
height: 56px;
}
} }
+5 -2
View File
@@ -1,4 +1,7 @@
{ {
"status": "passed", "status": "failed",
"failedTests": [] "failedTests": [
"b5da854a7c119c37c9d5-8782df6f17e49724b9a2",
"1012373defdfca68178e-d28214002c7ecee79090"
]
} }
+47
View File
@@ -0,0 +1,47 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
const PLAYER_INPUT_IDS: Record<string, string> = {
'Spieler 1': 'player1-input',
'Spieler 2': 'player2-input',
'Spieler 3 (optional)': 'player3-input',
};
/** Open name modal, fill input, confirm — advances wizard step. */
export async function enterPlayerName(
page: Page,
label: string,
placeholder: string,
name: string,
) {
const inputId = PLAYER_INPUT_IDS[label];
await page.getByRole('button', { name: `Name für ${label} eingeben` }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const input = inputId ? page.locator(`#${inputId}`) : dialog.getByRole('textbox');
await expect(input).toBeFocused();
await input.fill(name);
await dialog.getByRole('button', { name: 'Übernehmen' }).click();
await expect(dialog).toBeHidden();
}
export async function skipPlayer3(page: Page) {
await page.getByRole('button', { name: 'Überspringen' }).click();
}
export async function addPoint(page: Page, playerName: string, currentScore: number) {
await page
.getByRole('button', { name: `Punkt für ${playerName}. Aktueller Stand: ${currentScore}` })
.click();
}
export async function addPoints(
page: Page,
playerName: string,
count: number,
startScore = 0,
) {
for (let i = 0; i < count; i++) {
await addPoint(page, playerName, startScore + i);
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Capture wow-brief verification screenshots across 4 viewports.
* Usage: PW_EXECUTABLE=... node tests/helpers/wow-screenshots.mjs
*/
import { chromium } from 'playwright';
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:3000';
const executablePath = process.env.PW_EXECUTABLE;
const viewports = [
{ name: '1280x800', width: 1280, height: 800 },
{ name: '1024x768', width: 1024, height: 768 },
{ name: '844x390', width: 844, height: 390 },
{ name: '390x844', width: 390, height: 844 },
];
async function dismissDevToolbar(page) {
await page.evaluate(() => {
document.querySelector('astro-dev-toolbar')?.remove();
});
}
async function clickButton(page, name) {
await dismissDevToolbar(page);
await page.getByRole('button', { name }).click({ force: true });
}
async function capture(page, path) {
await dismissDevToolbar(page);
await page.screenshot({ path, fullPage: false });
console.log(`saved ${path}`);
}
async function enterPlayerName(page, label, name, inputId) {
await clickButton(page, `Name für ${label} eingeben`);
const dialog = page.getByRole('dialog');
const input = dialog.locator(`#${inputId}`);
await input.waitFor({ state: 'visible' });
await input.click();
await input.pressSequentially(name, { delay: 15 });
await dialog.getByRole('button', { name: 'Übernehmen' }).click({ force: true });
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
}
async function freshPage(page, vp) {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(`${BASE_URL}/?t=${Date.now()}`, { waitUntil: 'domcontentloaded' });
await dismissDevToolbar(page);
await page.waitForTimeout(400);
}
async function runWizardToScoreboard(page) {
await clickButton(page, 'Neues Spiel starten');
await enterPlayerName(page, 'Spieler 1', 'Alice', 'player1-input');
await enterPlayerName(page, 'Spieler 2', 'Bob', 'player2-input');
await clickButton(page, 'Überspringen');
await clickButton(page, '9-Ball');
await clickButton(page, '3');
await clickButton(page, 'Break-Regel wählen: Winnerbreak');
await clickButton(page, 'Zuerst: Alice');
}
async function main() {
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const page = await browser.newPage();
for (const vp of viewports) {
await freshPage(page, vp);
await capture(page, `/tmp/wow-list-${vp.name}.png`);
await clickButton(page, 'Neues Spiel starten');
await page.waitForTimeout(400);
await capture(page, `/tmp/wow-wizard-${vp.name}.png`);
await freshPage(page, vp);
await runWizardToScoreboard(page);
await page.waitForTimeout(400);
await capture(page, `/tmp/wow-scoreboard-${vp.name}.png`);
for (let score = 0; score < 3; score++) {
await clickButton(page, `Punkt für Alice. Aktueller Stand: ${score}`);
await page.waitForTimeout(150);
}
await page.waitForTimeout(600);
await capture(page, `/tmp/wow-victory-${vp.name}.png`);
}
await browser.close();
console.log('Done — screenshots in /tmp/wow-*.png');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+11 -15
View File
@@ -1,26 +1,22 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName, skipPlayer3 } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Kim');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Kim'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Leo');
await page.getByRole('button', { name: 'Weiter' }).click(); await skipPlayer3(page);
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Leo');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: '10-Ball' }).click(); await page.getByRole('button', { name: '10-Ball' }).click();
await page.getByRole('button', { name: 'Endlos' }).click(); await page.getByRole('button', { name: 'Endlos' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Leo' }).click(); await page.getByRole('button', { name: 'Zuerst: Leo' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Leo' }).click(); await addPoint(page, 'Leo', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Kim' }).click(); await addPoint(page, 'Kim', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Leo' }).click(); await addPoint(page, 'Leo', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Kim' }).click(); await addPoint(page, 'Kim', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Leo' }).click(); await addPoint(page, 'Leo', 2);
await page.getByRole('button', { name: 'Aktueller Punktestand für Kim' }).click(); await addPoint(page, 'Kim', 2);
await page.getByRole('button', { name: 'Spiel beenden' }).click(); await page.getByRole('button', { name: 'Spiel beenden' }).click();
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
}); });
+9 -13
View File
@@ -1,24 +1,20 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName, skipPlayer3 } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Charlie');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Charlie'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Diana');
await page.getByRole('button', { name: 'Weiter' }).click(); await skipPlayer3(page);
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Diana');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: '10-Ball' }).click(); await page.getByRole('button', { name: '10-Ball' }).click();
await page.getByRole('button', { name: '3' }).click(); await page.getByRole('button', { name: '3' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Diana' }).click(); await page.getByRole('button', { name: 'Zuerst: Diana' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Diana' }).click(); await addPoint(page, 'Diana', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Charlie' }).click(); await addPoint(page, 'Charlie', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Diana' }).click(); await addPoint(page, 'Diana', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Diana' }).click(); await addPoint(page, 'Diana', 2);
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
await page.getByRole('button', { name: 'Zurück zur Liste' }).click(); await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
}); });
+14 -18
View File
@@ -1,29 +1,25 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName, skipPlayer3 } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Wendy');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Wendy'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Xavier');
await page.getByRole('button', { name: 'Weiter' }).click(); await skipPlayer3(page);
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Xavier');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: '8-Ball' }).click(); await page.getByRole('button', { name: '8-Ball' }).click();
await page.getByRole('button', { name: '5' }).click(); await page.getByRole('button', { name: '5' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Xavier' }).click(); await page.getByRole('button', { name: 'Zuerst: Xavier' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click(); await addPoint(page, 'Xavier', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Wendy' }).click(); await addPoint(page, 'Wendy', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click(); await addPoint(page, 'Xavier', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Wendy' }).click(); await addPoint(page, 'Wendy', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click(); await addPoint(page, 'Xavier', 2);
await page.getByRole('button', { name: 'Aktueller Punktestand für Wendy' }).click(); await addPoint(page, 'Wendy', 2);
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click(); await addPoint(page, 'Xavier', 3);
await page.getByRole('button', { name: 'Aktueller Punktestand für Wendy' }).click(); await addPoint(page, 'Wendy', 3);
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click(); await addPoint(page, 'Xavier', 4);
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
await page.getByRole('button', { name: 'Zurück zur Liste' }).click(); await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
}); });
+16 -20
View File
@@ -1,31 +1,27 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName, skipPlayer3 } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Mia');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Mia'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Noah');
await page.getByRole('button', { name: 'Weiter' }).click(); await skipPlayer3(page);
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Noah');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: '8-Ball' }).click(); await page.getByRole('button', { name: '8-Ball' }).click();
await page.getByRole('button', { name: '9' }).click(); await page.getByRole('button', { name: '9' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Mia' }).click(); await page.getByRole('button', { name: 'Zuerst: Mia' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click(); await addPoint(page, 'Mia', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Noah' }).click(); await addPoint(page, 'Noah', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click(); await addPoint(page, 'Mia', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click(); await addPoint(page, 'Mia', 2);
await page.getByRole('button', { name: 'Aktueller Punktestand für Noah' }).click(); await addPoint(page, 'Noah', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click(); await addPoint(page, 'Mia', 3);
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click(); await addPoint(page, 'Mia', 4);
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click(); await addPoint(page, 'Mia', 5);
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click(); await addPoint(page, 'Mia', 6);
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click(); await addPoint(page, 'Mia', 7);
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click(); await addPoint(page, 'Mia', 8);
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
await page.getByRole('button', { name: 'Zurück zur Liste' }).click(); await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
}); });
+12 -15
View File
@@ -1,26 +1,23 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName, skipPlayer3 } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Foo');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Foo'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Bar');
await page.getByRole('button', { name: 'Weiter' }).click(); await skipPlayer3(page);
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Bar');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: '8-Ball' }).click(); await page.getByRole('button', { name: '8-Ball' }).click();
await page.getByRole('button', { name: 'Endlos' }).click(); await page.getByRole('button', { name: 'Endlos' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Foo' }).click(); await page.getByRole('button', { name: 'Zuerst: Foo' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click(); await addPoint(page, 'Foo', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Bar' }).click(); await addPoint(page, 'Bar', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click(); await addPoint(page, 'Foo', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Bar' }).click(); await addPoint(page, 'Bar', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click(); await addPoint(page, 'Foo', 2);
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click(); await addPoint(page, 'Foo', 3);
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click(); await addPoint(page, 'Foo', 4);
await page.getByRole('button', { name: 'Spiel beenden' }).click(); await page.getByRole('button', { name: 'Spiel beenden' }).click();
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
}); });
+6 -10
View File
@@ -1,21 +1,17 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName, skipPlayer3 } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Rita');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Rita'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Sam');
await page.getByRole('button', { name: 'Weiter' }).click(); await skipPlayer3(page);
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Sam');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: '9-Ball' }).click(); await page.getByRole('button', { name: '9-Ball' }).click();
await page.getByRole('button', { name: '1' }).click(); await page.getByRole('button', { name: '1' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Sam' }).click(); await page.getByRole('button', { name: 'Zuerst: Sam' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Sam' }).click(); await addPoint(page, 'Sam', 0);
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
await page.getByRole('button', { name: 'Zurück zur Liste' }).click(); await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
}); });
+11 -15
View File
@@ -1,26 +1,22 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName, skipPlayer3 } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Alice');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Alice'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Bob');
await page.getByRole('button', { name: 'Weiter' }).click(); await skipPlayer3(page);
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Bob');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Überspringen' }).click();
await page.getByRole('button', { name: '9-Ball' }).click(); await page.getByRole('button', { name: '9-Ball' }).click();
await page.getByRole('button', { name: '5' }).click(); await page.getByRole('button', { name: '5' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Alice' }).click(); await page.getByRole('button', { name: 'Zuerst: Alice' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click(); await addPoint(page, 'Alice', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Bob' }).click(); await addPoint(page, 'Bob', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click(); await addPoint(page, 'Alice', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click(); await addPoint(page, 'Alice', 2);
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click(); await addPoint(page, 'Alice', 3);
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click(); await addPoint(page, 'Alice', 4);
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
await page.getByRole('button', { name: 'Zurück zur Liste' }).click(); await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
}); });
+12 -18
View File
@@ -1,29 +1,23 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Oscar');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Oscar'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Paula');
await page.getByRole('button', { name: 'Weiter' }).click(); await enterPlayerName(page, 'Spieler 3 (optional)', 'Name Spieler 3 (optional)', 'Quinn');
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Paula');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Quinn');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: '10-Ball' }).click(); await page.getByRole('button', { name: '10-Ball' }).click();
await page.getByRole('button', { name: '5' }).click(); await page.getByRole('button', { name: '5' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Quinn' }).click(); await page.getByRole('button', { name: 'Zuerst: Quinn' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click(); await addPoint(page, 'Quinn', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Oscar' }).click(); await addPoint(page, 'Oscar', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Paula' }).click(); await addPoint(page, 'Paula', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click(); await addPoint(page, 'Quinn', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click(); await addPoint(page, 'Quinn', 2);
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click(); await addPoint(page, 'Quinn', 3);
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click(); await addPoint(page, 'Quinn', 4);
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
await page.getByRole('button', { name: 'Zurück zur Liste' }).click(); await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
}); });
+12 -18
View File
@@ -1,29 +1,23 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Eva');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Eva'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Frank');
await page.getByRole('button', { name: 'Weiter' }).click(); await enterPlayerName(page, 'Spieler 3 (optional)', 'Name Spieler 3 (optional)', 'Grace');
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Frank');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Grace');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: '8-Ball' }).click(); await page.getByRole('button', { name: '8-Ball' }).click();
await page.getByRole('button', { name: 'Endlos' }).click(); await page.getByRole('button', { name: 'Endlos' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Eva' }).click(); await page.getByRole('button', { name: 'Zuerst: Eva' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Eva' }).click(); await addPoint(page, 'Eva', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Frank' }).click(); await addPoint(page, 'Frank', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Grace' }).click(); await addPoint(page, 'Grace', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Eva' }).click(); await addPoint(page, 'Eva', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Frank' }).click(); await addPoint(page, 'Frank', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Grace' }).click(); await addPoint(page, 'Grace', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Eva' }).click(); await addPoint(page, 'Eva', 2);
await page.getByRole('button', { name: 'Spiel beenden' }).click(); await page.getByRole('button', { name: 'Spiel beenden' }).click();
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
}); });
+10 -16
View File
@@ -1,28 +1,22 @@
import { test, expect } from '@playwright/test'; import { test } from '@playwright/test';
import { addPoint, enterPlayerName } from '../helpers/wizard';
test('test', async ({ page }) => { test('test', async ({ page }) => {
await page.goto('http://localhost:3000/'); await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click(); await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click(); await enterPlayerName(page, 'Spieler 1', 'Name Spieler 1', 'Tom');
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Tom'); await enterPlayerName(page, 'Spieler 2', 'Name Spieler 2', 'Uma');
await page.getByRole('button', { name: 'Weiter' }).click(); await enterPlayerName(page, 'Spieler 3 (optional)', 'Name Spieler 3 (optional)', 'Victor');
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Uma');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Victor');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: '8-Ball' }).click(); await page.getByRole('button', { name: '8-Ball' }).click();
await page.getByRole('button', { name: '3' }).click(); await page.getByRole('button', { name: '3' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click(); await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
await page.getByRole('button', { name: 'Zuerst: Tom' }).click(); await page.getByRole('button', { name: 'Zuerst: Tom' }).click();
await page.getByRole('button', { name: 'Zweites Break: Uma' }).click(); await page.getByRole('button', { name: 'Zweites Break: Uma' }).click();
await page.getByRole('button', { name: 'Aktueller Punktestand für Tom' }).click(); await addPoint(page, 'Tom', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Uma' }).click(); await addPoint(page, 'Uma', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Victor' }).click(); await addPoint(page, 'Victor', 0);
await page.getByRole('button', { name: 'Aktueller Punktestand für Tom' }).click(); await addPoint(page, 'Tom', 1);
await page.getByRole('button', { name: 'Aktueller Punktestand für Tom' }).click(); await addPoint(page, 'Tom', 2);
await page.getByRole('button', { name: 'Bestätigen' }).click(); await page.getByRole('button', { name: 'Bestätigen' }).click();
await page.getByRole('button', { name: 'Zurück zur Liste' }).click(); await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
}); });

Some files were not shown because too many files have changed in this diff Show More