4 Commits

Author SHA1 Message Date
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
16 changed files with 172 additions and 40 deletions
+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
+29
View File
@@ -33,6 +33,35 @@ export default function App() {
const validationModal = useValidationModal();
const completionModal = useCompletionModal();
// Keep viewport height stable on Android tablets when virtual keyboard opens.
useEffect(() => {
const root = document.documentElement;
const updateViewportVars = () => {
const viewport = window.visualViewport;
const appHeight = viewport ? viewport.height : window.innerHeight;
const keyboardOffset = viewport
? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
: 0;
root.style.setProperty('--app-height', `${Math.round(appHeight)}px`);
root.style.setProperty('--keyboard-offset', `${Math.round(keyboardOffset)}px`);
};
updateViewportVars();
window.addEventListener('resize', updateViewportVars);
window.addEventListener('orientationchange', updateViewportVars);
window.visualViewport?.addEventListener('resize', updateViewportVars);
window.visualViewport?.addEventListener('scroll', updateViewportVars);
return () => {
window.removeEventListener('resize', updateViewportVars);
window.removeEventListener('orientationchange', updateViewportVars);
window.visualViewport?.removeEventListener('resize', updateViewportVars);
window.visualViewport?.removeEventListener('scroll', updateViewportVars);
};
}, []);
// Game lifecycle handlers
const handleCreateGame = useCallback(async (gameData: any) => {
try {
@@ -4,7 +4,7 @@
top: 0;
left: 0;
width: 100%;
min-height: 100vh;
min-height: var(--app-height);
display: none;
opacity: 0;
transform: translateX(100%);
@@ -9,7 +9,7 @@
.screen-content {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: var(--app-height);
padding: var(--space-lg);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
+11 -4
View File
@@ -4,7 +4,7 @@
top: 0;
left: 0;
width: 100%;
min-height: 100vh;
min-height: var(--app-height);
display: none;
opacity: 0;
transform: translateX(100%);
@@ -145,8 +145,10 @@
.form-content {
flex: 1;
overflow: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 var(--space-lg);
padding-bottom: var(--space-md);
min-height: 0;
display: flex;
flex-direction: column;
@@ -154,7 +156,12 @@
.form-footer {
flex-shrink: 0;
padding: var(--space-lg);
position: sticky;
bottom: 0;
z-index: 2;
background: var(--color-surface);
padding: var(--space-md) var(--space-lg) calc(var(--space-lg) + var(--keyboard-offset)) var(--space-lg);
border-top: 1px solid var(--color-border);
}
.progress-indicator {
display: flex;
@@ -210,7 +217,7 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-xxl);
margin-top: var(--space-lg);
width: 100%;
gap: var(--space-lg);
}
@@ -26,18 +26,29 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
const handleFirst = (idx: number) => {
setFirst(idx);
if (rule === 'winnerbreak' || (rule === 'wechselbreak' && playerCount === 2)) {
onNext(idx);
}
};
const handleSecond = (idx: number) => {
setSecond(idx);
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 (
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen">
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen" onSubmit={handleSubmit}>
<div className={styles['form-header']}>
<div className={styles['screen-title']}>Wer hat den ersten Anstoss?</div>
<ProgressIndicator currentStep={7} style={{ marginBottom: 24 }} />
@@ -87,18 +98,9 @@ export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst =
&#8592;
</button>
<button
type="button"
type="submit"
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)
}
@@ -12,9 +12,18 @@ interface BreakRuleStepProps {
export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }: BreakRuleStepProps) => {
const [rule, setRule] = useState<BreakRule>(initialValue ?? 'winnerbreak');
const handleSelect = (nextRule: BreakRule) => {
setRule(nextRule);
};
const handleSubmit = (e: Event) => {
e.preventDefault();
onNext(rule);
};
return (
<form className={styles['new-game-form']} aria-label="Break-Regel wählen">
<form className={styles['new-game-form']} aria-label="Break-Regel wählen" onSubmit={handleSubmit}>
<div className={styles['form-header']}>
<div className={styles['screen-title']}>Break-Regel wählen</div>
<ProgressIndicator currentStep={6} style={{ marginBottom: 24 }} />
@@ -30,10 +39,7 @@ export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }
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);
}}
onClick={() => handleSelect(opt.key as BreakRule)}
aria-label={`Break-Regel wählen: ${opt.label}`}
style={{ minWidth: 160, minHeight: 64, fontSize: '1.2rem', padding: '16px 32px' }}
>
@@ -48,7 +54,7 @@ export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }
<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' }}>
<button type="submit" className={styles['arrow-btn']} aria-label="Weiter" 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>
@@ -15,7 +15,6 @@ export const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeSt
const handleSelect = (selectedType: string) => {
setGameType(selectedType);
onNext(selectedType);
};
const handleSubmit = (e: Event) => {
@@ -111,8 +111,15 @@ export const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue
}
}}
autoComplete="off"
autoCapitalize="words"
spellCheck={false}
enterKeyHint="next"
aria-label="Name Spieler 1"
aria-describedby="player1-help"
onFocus={(e) => {
const target = e.currentTarget as HTMLInputElement;
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}}
style={{
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
@@ -70,7 +70,14 @@ export const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue
setPlayer2(target.value);
}}
autoComplete="off"
autoCapitalize="words"
spellCheck={false}
enterKeyHint="next"
aria-label="Name Spieler 2"
onFocus={(e) => {
const target = e.currentTarget as HTMLInputElement;
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}}
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
@@ -67,7 +67,14 @@ export const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue
setPlayer3(target.value);
}}
autoComplete="off"
autoCapitalize="words"
spellCheck={false}
enterKeyHint="done"
aria-label="Name Spieler 3"
onFocus={(e) => {
const target = e.currentTarget as HTMLInputElement;
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}}
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
ref={inputRef}
/>
@@ -33,9 +33,6 @@ export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: Ra
const handleQuickPick = (value: number | typeof RACE_TO_INFINITY) => {
const selected = value === RACE_TO_INFINITY ? RACE_TO_INFINITY : value;
setRaceTo(selected);
const raceToValue =
selected === RACE_TO_INFINITY ? Infinity : parseInt(String(selected), 10) || 0;
onNext(raceToValue);
};
const handleInputChange = (e: Event) => {
+1 -1
View File
@@ -1,5 +1,5 @@
.layout {
height: 100vh;
height: var(--app-height);
overflow: hidden;
background-color: var(--color-background);
color: var(--color-text);
+1 -1
View File
@@ -8,7 +8,7 @@ import App from "../components/App";
<html lang="de">
<head>
<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, viewport-fit=cover, interactive-widget=resizes-content">
<title>BSC Score - Pool Scoring App</title>
<meta name="description" content="Professional pool/billiards scoring application for tournaments and casual games">
+10 -7
View File
@@ -3,17 +3,13 @@
margin: 0;
padding: 0;
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;
}
/* Design system tokens */
:root {
--app-height: 100dvh;
--keyboard-offset: 0px;
/* Colors */
--color-primary: #ff9800;
--color-primary-hover: #ffa726;
@@ -87,7 +83,7 @@
html, body {
height: 100%;
overflow: hidden;
min-height: 100%;
}
body {
@@ -98,6 +94,13 @@ body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: var(--app-height);
}
button, .btn, .button {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Improved input styling for better tablet experience */
@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
const LANDSCAPE_VIEWPORTS = [
{ name: 'small-landscape', width: 1024, height: 600 },
{ name: 'tablet-landscape', width: 1280, height: 800 },
{ name: 'large-landscape', width: 1366, height: 768 },
];
for (const viewport of LANDSCAPE_VIEWPORTS) {
test(`viewport matrix: ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('http://localhost:3000/');
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
// Step 1: ensure keyboard-sized viewport still keeps controls usable.
await page.getByLabel('Name Spieler 1').fill('Alpha');
await page.setViewportSize({
width: viewport.width,
height: Math.max(360, viewport.height - Math.floor(viewport.height * 0.35)),
});
const nextButton = page.getByRole('button', { name: 'Weiter' });
await nextButton.scrollIntoViewIfNeeded();
await expect(nextButton).toBeVisible();
await nextButton.click();
// Step 2: keep reduced height and verify progression still works.
await page.getByLabel('Name Spieler 2').fill('Beta');
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Überspringen' }).click();
// Restore full landscape viewport for remaining wizard steps.
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.getByRole('button', { name: '8-Ball' }).click();
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: '5' }).click();
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
await page.getByRole('button', { name: 'Weiter' }).click();
await page.getByRole('button', { name: 'Zuerst: Alpha' }).click();
await page.getByRole('button', { name: 'Weiter' }).click();
await expect(page.getByRole('button', { name: /Aktueller Punktestand für Alpha/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Aktueller Punktestand für Beta/i })).toBeVisible();
});
}