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')"
>