diff --git a/web/src/App.css b/web/src/App.css index cdf4fb1..b692a8c 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,9 +1,13 @@ .app { - min-height: 100vh; + --table-fr: 0.58fr; + --hand-fr: 0.42fr; + height: 100%; + min-height: 0; display: grid; - grid-template-rows: auto 1fr auto; + grid-template-rows: auto minmax(0, var(--table-fr)) minmax(0, var(--hand-fr)); color: var(--ink-100); animation: page-in 0.6s ease both; + overflow: hidden; } .top-bar { @@ -11,7 +15,7 @@ gap: 24px; align-items: center; justify-content: space-between; - padding: 20px 32px; + padding: clamp(12px, 2vh, 20px) clamp(18px, 3vw, 32px); border-bottom: 1px solid rgba(255, 236, 209, 0.18); background: linear-gradient(90deg, rgba(30, 20, 12, 0.9), rgba(44, 28, 18, 0.7)); backdrop-filter: blur(6px); @@ -132,14 +136,18 @@ .table { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr) minmax(220px, 280px); - gap: 20px; - padding: 20px 24px 30px; + gap: clamp(12px, 2vw, 20px); + padding: clamp(12px, 2vh, 18px) clamp(16px, 2.4vw, 24px) clamp(12px, 2.4vh, 22px); + min-height: 0; + align-items: stretch; + height: 100%; } .rail { display: flex; flex-direction: column; gap: 16px; + min-height: 0; } .rail.left { @@ -151,8 +159,8 @@ } .stack-card { - width: 140px; - height: 180px; + width: clamp(110px, 12vw, 140px); + height: clamp(150px, 18vh, 180px); border-radius: 18px; padding: 16px; display: flex; @@ -194,10 +202,13 @@ .table-surface { position: relative; border-radius: 28px; - padding: 26px 32px; + padding: clamp(14px, 2vh, 22px) clamp(18px, 2.4vw, 28px); background: linear-gradient(180deg, #b67c4d 0%, #c9935c 35%, #d7a36a 100%); box-shadow: inset 0 0 0 1px rgba(92, 55, 33, 0.4), var(--shadow-deep); overflow: hidden; + display: grid; + grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr); + min-height: 0; } .table-surface::before { @@ -220,6 +231,7 @@ display: grid; gap: 14px; z-index: 1; + min-height: 0; } .zone-label-row { @@ -254,8 +266,8 @@ } .center-zone { - margin: 20px 0; - padding: 16px; + margin: 12px 0; + padding: 12px; border-radius: 18px; background: rgba(44, 24, 14, 0.15); border: 1px solid rgba(44, 24, 14, 0.2); @@ -302,11 +314,12 @@ border-radius: 20px; padding: 16px; border: 1px solid rgba(255, 221, 181, 0.2); - min-height: 320px; display: flex; flex-direction: column; gap: 12px; box-shadow: var(--shadow-soft); + min-height: 0; + flex: 1; } .panel-header { @@ -326,8 +339,9 @@ font-size: 13px; color: var(--ink-70); line-height: 1.4; - max-height: 320px; overflow-y: auto; + flex: 1; + min-height: 0; } .history-panel ul ul { @@ -349,11 +363,14 @@ } .hand-area { - padding: 18px 24px 24px; + padding: clamp(12px, 2vh, 18px) clamp(16px, 2.4vw, 24px); background: linear-gradient(180deg, rgba(30, 20, 12, 0.85), rgba(18, 12, 8, 0.95)); border-top: 1px solid rgba(255, 221, 181, 0.2); display: grid; - gap: 18px; + gap: 12px; + height: 100%; + overflow: hidden; + min-height: 0; } .action-prompt { @@ -361,12 +378,12 @@ justify-content: space-between; align-items: center; gap: 20px; - padding: 16px 20px; + padding: 12px 16px; border-radius: 18px; background: rgba(255, 221, 181, 0.12); border: 1px solid rgba(255, 221, 181, 0.2); width: 100%; - max-width: 960px; + max-width: 900px; margin: 0 auto; } @@ -388,7 +405,9 @@ .action-choice-row { display: flex; gap: 10px; - flex-wrap: wrap; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 4px; } .action-choice-row .selected-action { @@ -401,12 +420,14 @@ display: flex; gap: 12px; justify-content: center; - flex-wrap: wrap; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 6px; } .card-tile { - width: 110px; - height: 150px; + width: clamp(90px, 8.8vw, 110px); + height: clamp(120px, 12.5vh, 150px); border-radius: 16px; background: #f8f1e6; color: #22160f; @@ -711,6 +732,11 @@ button:disabled { @media (max-width: 980px) { .table { grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr) auto; + } + + .table-surface { + height: 100%; } .rail.left, @@ -725,17 +751,183 @@ button:disabled { } @media (max-width: 720px) { + .app { + --table-fr: 0.5fr; + --hand-fr: 0.5fr; + } + .top-bar { - flex-direction: column; - align-items: flex-start; + padding: 10px 14px; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-start; + } + + .brand-mark { + font-size: 22px; + } + + .brand-sub { + font-size: 10px; + letter-spacing: 1.4px; + display: none; + } + + .scoreboard { + flex-wrap: nowrap; + gap: 6px; + } + + .score-chip { + padding: 6px 10px; + } + + .chip-label { + font-size: 9px; + letter-spacing: 1.2px; + } + + .chip-score { + font-size: 12px; + } + + .controls { + flex-wrap: nowrap; + justify-content: flex-start; + gap: 8px; + overflow-x: auto; + padding-bottom: 4px; + } + + .ai-select { + padding: 4px 8px; + font-size: 10px; + flex: 0 0 auto; + } + + .ai-select select { + padding: 4px 10px; + font-size: 10px; + } + + button { + padding: 8px 12px; + font-size: 11px; + letter-spacing: 1.2px; + } + + .table { + gap: 10px; + padding: 10px 12px 12px; + } + + .rail.left, + .rail.right { + gap: 10px; + } + + .stack-card { + width: min(46vw, 160px); + height: clamp(96px, 14vh, 120px); + } + + .table-surface { + padding: 10px; + border-radius: 18px; + } + + .zone { + gap: 10px; + } + + .zone-label { + font-size: 10px; + letter-spacing: 1.4px; + } + + .zone-chip { + padding: 4px 10px; + font-size: 10px; + letter-spacing: 1px; + } + + .card-row { + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 4px; + gap: 10px; + } + + .center-zone { + margin: 8px 0; + padding: 8px; + } + + .phase-pill { + font-size: 11px; + } + + .hint { + font-size: 10px; + padding: 4px 8px; + } + + .history-panel { + padding: 10px 12px; + border-radius: 14px; + max-height: none; + } + + .history-panel ul { + display: none; + } + + .hand-area { + gap: 6px; + padding: 8px 12px; } .action-prompt { flex-direction: column; align-items: flex-start; + padding: 8px 10px; + gap: 10px; + } + + .action-buttons { + width: 100%; + justify-content: space-between; } .hand { justify-content: flex-start; } + + .action-title { + font-size: 14px; + } + + .action-sub { + font-size: 12px; + } + + .action-choice-row, + .hand { + gap: 8px; + padding-bottom: 4px; + } + + .card-tile { + width: 68px; + height: 96px; + } + + .card-rank { + font-size: 16px; + } + + .card-tag { + font-size: 9px; + letter-spacing: 1.2px; + } } diff --git a/web/src/index.css b/web/src/index.css index bede5a0..005debc 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -15,16 +15,26 @@ box-sizing: border-box; } +html, +body { + height: 100%; + overflow: hidden; +} + body { margin: 0; font-family: var(--font-body); background: radial-gradient(circle at top, #2a1a0f, #130b07); color: var(--ink-100); min-height: 100vh; + height: 100vh; + height: 100svh; + overflow: hidden; } #root { - min-height: 100vh; + min-height: 0; + height: 100%; } a { diff --git a/web/tests/e2e/app.spec.ts b/web/tests/e2e/app.spec.ts index afe56c8..adf4511 100644 --- a/web/tests/e2e/app.spec.ts +++ b/web/tests/e2e/app.spec.ts @@ -335,6 +335,207 @@ test('visual snapshot', async ({ page }) => { }) }) +test('mobile layout fits in one viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.emulateMedia({ reducedMotion: 'reduce' }) + + await page.route('**/api/sessions', async (route) => { + const payload = { + session_id: 'mobile-session', + state: { + hands: [ + [ + { + id: 'card-1', + suit: 'SPADES', + rank: 'ACE', + display: 'Ace of Spades', + played_by: null, + purpose: null, + point_value: 1, + is_stolen: false, + attachments: [], + }, + ], + [], + ], + hand_counts: [1, 0], + fields: [[], []], + effective_fields: [[], []], + deck_count: 19, + discard_pile: [], + discard_count: 0, + scores: [0, 0], + targets: [21, 21], + turn: 0, + current_action_player: 0, + status: null, + resolving_two: false, + resolving_one_off: false, + resolving_three: false, + overall_turn: 0, + use_ai: true, + one_off_card_to_counter: null, + }, + legal_actions: [ + { + id: 0, + label: 'Play Ace of Spades as points', + type: 'Points', + played_by: 0, + source: 'Hand', + requires_additional_input: false, + card: { + id: 'card-1', + suit: 'SPADES', + rank: 'ACE', + display: 'Ace of Spades', + played_by: null, + purpose: null, + point_value: 1, + is_stolen: false, + attachments: [], + }, + target: null, + }, + { + id: 1, + label: 'Draw a card from deck', + type: 'Draw', + played_by: 0, + source: 'Deck', + requires_additional_input: false, + card: null, + target: null, + }, + ], + state_version: 0, + ai_thinking: false, + } + + await route.fulfill({ json: payload }) + }) + + await page.route('**/api/sessions/mobile-session/history', async (route) => { + const entries = Array.from({ length: 6 }, (_, index) => ({ + timestamp: `2025-01-01T00:00:${index.toString().padStart(2, '0')}Z`, + turn_number: 1, + player: index % 2, + action_type: 'Draw', + description: `History entry ${index + 1}`, + })) + + await route.fulfill({ json: { entries, turn_counter: 1 } }) + }) + + await page.route('**/api/sessions/mobile-session', async (route) => { + const payload = { + session_id: 'mobile-session', + state: { + hands: [ + [ + { + id: 'card-1', + suit: 'SPADES', + rank: 'ACE', + display: 'Ace of Spades', + played_by: null, + purpose: null, + point_value: 1, + is_stolen: false, + attachments: [], + }, + ], + [], + ], + hand_counts: [1, 0], + fields: [[], []], + effective_fields: [[], []], + deck_count: 19, + discard_pile: [], + discard_count: 0, + scores: [0, 0], + targets: [21, 21], + turn: 0, + current_action_player: 0, + status: null, + resolving_two: false, + resolving_one_off: false, + resolving_three: false, + overall_turn: 0, + use_ai: true, + one_off_card_to_counter: null, + }, + legal_actions: [ + { + id: 0, + label: 'Play Ace of Spades as points', + type: 'Points', + played_by: 0, + source: 'Hand', + requires_additional_input: false, + card: { + id: 'card-1', + suit: 'SPADES', + rank: 'ACE', + display: 'Ace of Spades', + played_by: null, + purpose: null, + point_value: 1, + is_stolen: false, + attachments: [], + }, + target: null, + }, + { + id: 1, + label: 'Draw a card from deck', + type: 'Draw', + played_by: 0, + source: 'Deck', + requires_additional_input: false, + card: null, + target: null, + }, + ], + state_version: 0, + ai_thinking: false, + } + + await route.fulfill({ json: payload }) + }) + + await page.goto('/') + await expect(page.getByText('Cuttle')).toBeVisible() + + const viewportHeight = await page.evaluate(() => window.innerHeight) + + const sections = [ + page.locator('.top-bar'), + page.locator('.rail.left'), + page.locator('.table-surface'), + page.locator('.rail.right'), + page.locator('.hand-area'), + ] + + for (const section of sections) { + const box = await section.boundingBox() + expect(box).not.toBeNull() + if (!box) continue + expect(box.y).toBeGreaterThanOrEqual(0) + expect(box.y + box.height).toBeLessThanOrEqual(viewportHeight + 2) + } + + const fitsViewport = await page.evaluate( + () => document.documentElement.scrollHeight <= window.innerHeight + 2, + ) + expect(fitsViewport).toBeTruthy() + + expect(await page.screenshot()).toMatchSnapshot('table-layout-mobile.png', { + maxDiffPixelRatio: 0.02, + }) +}) + test('one-off modal flow', async ({ page }) => { await page.route('**/api/sessions', async (route) => { const payload = { diff --git a/web/tests/snapshots/app.spec.ts-snapshots/table-layout-desktop-darwin.png b/web/tests/snapshots/app.spec.ts-snapshots/table-layout-desktop-darwin.png index 2871607..fb931a0 100644 Binary files a/web/tests/snapshots/app.spec.ts-snapshots/table-layout-desktop-darwin.png and b/web/tests/snapshots/app.spec.ts-snapshots/table-layout-desktop-darwin.png differ diff --git a/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-darwin.png b/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-darwin.png index fc721c2..7e250e0 100644 Binary files a/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-darwin.png and b/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-darwin.png differ diff --git a/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-desktop-darwin.png b/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-desktop-darwin.png new file mode 100644 index 0000000..5bf1d61 Binary files /dev/null and b/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-desktop-darwin.png differ diff --git a/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-mobile-darwin.png b/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-mobile-darwin.png new file mode 100644 index 0000000..5bf1d61 Binary files /dev/null and b/web/tests/snapshots/app.spec.ts-snapshots/table-layout-mobile-mobile-darwin.png differ