diff --git a/web/e2e/game.spec.ts b/web/e2e/game.spec.ts index dce4a90..d563cd7 100644 --- a/web/e2e/game.spec.ts +++ b/web/e2e/game.spec.ts @@ -197,6 +197,65 @@ test.describe('QuestModal – Escape key dismissal', () => { }) }) +/* ------------------------------------------------------------------ */ +/* QuestModal overflow fix – Continue button always reachable */ +/* ------------------------------------------------------------------ */ + +test.describe('QuestModal – overflow fix', () => { + test.beforeEach(async ({ page }) => { + await page.goto(APP_URL) + await expect(page.locator('.loading-spinner')).toBeVisible({ + timeout: 10_000, + }) + await injectMockGameState(page) + }) + + async function openLongModal(page: Page) { + const longBody = Array.from( + { length: 12 }, + (_, i) => + `

Paragraph ${i + 1}: The ancient prophecy speaks of a hero who shall rise from humble beginnings to face a darkness that has long plagued these lands. Only the chosen one may restore balance.

`, + ).join('') + await page.evaluate((body: string) => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const app = (document.querySelector('#app') as any).__vue_app__ + const pinia = app.config.globalProperties.$pinia + const store = pinia.state.value.game + store.showModal = true + store.modalTitle = 'A Very Long Quest' + store.modalBody = body + /* eslint-enable @typescript-eslint/no-explicit-any */ + }, longBody) + await expect(page.getByText('A Very Long Quest', { exact: true })).toBeVisible() + } + + test('Continue button is visible in viewport when quest body is very long', async ({ + page, + }) => { + await openLongModal(page) + const button = page.getByRole('button', { name: 'Continue' }) + await expect(button).toBeVisible() + const box = await button.boundingBox() + const viewportSize = page.viewportSize() + expect(box).not.toBeNull() + expect(viewportSize).not.toBeNull() + expect(box!.y + box!.height).toBeLessThanOrEqual(viewportSize!.height) + }) + + test('modal card does not exceed viewport height when body is very long', async ({ + page, + }) => { + await openLongModal(page) + const card = page.locator('[data-testid="quest-modal-card"]') + await expect(card).toBeVisible() + const box = await card.boundingBox() + const viewportSize = page.viewportSize() + expect(box).not.toBeNull() + expect(viewportSize).not.toBeNull() + expect(box!.height).toBeLessThanOrEqual(viewportSize!.height) + }) +}) + /* ------------------------------------------------------------------ */ /* LoadDialog Escape key tests */ /* ------------------------------------------------------------------ */ diff --git a/web/index.html b/web/index.html index cf060a9..702efdf 100644 --- a/web/index.html +++ b/web/index.html @@ -5,6 +5,12 @@ RetroQuest — The Awakening + + + + + + diff --git a/web/public/manifest.json b/web/public/manifest.json new file mode 100644 index 0000000..f4d23e6 --- /dev/null +++ b/web/public/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "RetroQuest — The Awakening", + "short_name": "RetroQuest", + "description": "A classic text adventure game", + "start_url": "/", + "display": "fullscreen", + "orientation": "portrait", + "background_color": "#1a1a2e", + "theme_color": "#1a1a2e", + "icons": [ + { + "src": "/icons/favicon.png", + "sizes": "any", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/web/src/App.vue b/web/src/App.vue index 456c6e8..beaf01c 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -7,9 +7,27 @@ import GameLayout from './components/GameLayout.vue' const store = useGameStore() const bridge = useBridge() +function requestFullscreenOnFirstInteraction() { + if ( + !('ontouchstart' in window) || + !document.documentElement.requestFullscreen + ) + return + + function onFirstInteraction() { + document.documentElement.requestFullscreen().catch(() => {}) + window.removeEventListener('touchstart', onFirstInteraction) + window.removeEventListener('click', onFirstInteraction) + } + + window.addEventListener('touchstart', onFirstInteraction, { once: true }) + window.addEventListener('click', onFirstInteraction, { once: true }) +} + onMounted(() => { store.setBridge(bridge) store.initGame() + requestFullscreenOnFirstInteraction() }) diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 4094987..e9fb94f 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -48,6 +48,12 @@ background: var(--color-bg-primary); color: var(--color-text-primary); } + + @media (max-width: 767px) { + html { + font-size: 15px; + } + } } /* ---------- Theme Tag Styles ---------- */ diff --git a/web/src/components/CommandInput.vue b/web/src/components/CommandInput.vue index f015e12..703c6c6 100644 --- a/web/src/components/CommandInput.vue +++ b/web/src/components/CommandInput.vue @@ -94,34 +94,39 @@ function closeSuggestions() { class="flex gap-1.5 px-3 py-2 border-t border-border bg-bg-secondary shrink-0 overflow-x-auto max-[375px]:px-2 max-[375px]:py-1.5" > diff --git a/web/src/components/GameLayout.vue b/web/src/components/GameLayout.vue index e8cd3cd..190eee1 100644 --- a/web/src/components/GameLayout.vue +++ b/web/src/components/GameLayout.vue @@ -71,13 +71,14 @@ function updateMobile() { entityMenu.isMobile.value = window.innerWidth <= 768 } -// One-shot handler: start music on the very first user gesture (keydown or click). +// One-shot handler: start music on the very first user gesture (keydown, click, or touch). // Browsers block autoplay until a user gesture occurs; this ensures music starts // as soon as the user interacts with anything, even while the intro popup is still visible. function unlockAudio() { music.ensureMusicStarted() window.removeEventListener('keydown', unlockAudio) window.removeEventListener('click', unlockAudio) + window.removeEventListener('touchstart', unlockAudio) } onMounted(() => { @@ -85,11 +86,13 @@ onMounted(() => { window.addEventListener('resize', updateMobile) window.addEventListener('keydown', unlockAudio) window.addEventListener('click', unlockAudio) + window.addEventListener('touchstart', unlockAudio, { passive: true }) }) onUnmounted(() => { window.removeEventListener('resize', updateMobile) window.removeEventListener('keydown', unlockAudio) window.removeEventListener('click', unlockAudio) + window.removeEventListener('touchstart', unlockAudio) }) // --- UI State --- diff --git a/web/src/components/QuestModal.vue b/web/src/components/QuestModal.vue index 9ed2a1c..68f14d0 100644 --- a/web/src/components/QuestModal.vue +++ b/web/src/components/QuestModal.vue @@ -33,18 +33,31 @@ onUnmounted(() => { @click.self="$emit('dismiss')" >
-
{{ title }}
- -
- -
+
+ {{ title }} +
+ +
+
+
+
+ +
+ +
diff --git a/web/src/components/SidePanel.vue b/web/src/components/SidePanel.vue index 2712602..73c1af4 100644 --- a/web/src/components/SidePanel.vue +++ b/web/src/components/SidePanel.vue @@ -158,11 +158,13 @@ defineEmits<{ class="px-2.5 py-2 rounded-md mb-1 transition-colors cursor-pointer hover:bg-chip-hover" @click="$emit('inventoryClick', $event, item.name)" > - +
-
- {{ item.description }} -
+
+ @@ -193,11 +195,13 @@ defineEmits<{ class="px-2.5 py-2 rounded-md mb-1 transition-colors cursor-pointer hover:bg-chip-hover" @click="$emit('spellClick', $event, spell.name)" > - +
-
- {{ spell.description }} -
+
+ diff --git a/web/src/components/TopBar.vue b/web/src/components/TopBar.vue index a74fc31..56e0581 100644 --- a/web/src/components/TopBar.vue +++ b/web/src/components/TopBar.vue @@ -1,4 +1,6 @@