Skip to content
Merged
59 changes: 59 additions & 0 deletions web/e2e/game.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
`<p>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.</p>`,
).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 */
/* ------------------------------------------------------------------ */
Expand Down
6 changes: 6 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RetroQuest — The Awakening</title>
<link rel="icon" type="image/png" href="/icons/favicon.png">
<link rel="manifest" href="/manifest.json">
<meta name="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-title" content="RetroQuest">
<meta name="theme-color" content="#1a1a2e">
<script src="https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js"></script>
</head>
<body>
Expand Down
18 changes: 18 additions & 0 deletions web/public/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
18 changes: 18 additions & 0 deletions web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Comment on lines +10 to 31

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title/description focus on the QuestModal overflow fix, but this diff also introduces fullscreen behavior (auto-enter on first interaction + toggle button), a PWA manifest/meta changes, global mobile font-size change, and an entity-menu ghost-click guard. Either update the PR description to cover these additional features/risks or split them into separate PRs so the quest modal fix can be reviewed/rolled back independently.

Copilot uses AI. Check for mistakes.
</script>

Expand Down
6 changes: 6 additions & 0 deletions web/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------- */
Expand Down
25 changes: 15 additions & 10 deletions web/src/components/CommandInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<button
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-[375px]:min-h-11 max-[375px]:min-w-11 max-[375px]:px-3 max-[375px]:py-2"
aria-label="Look"
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-md:px-2.5 max-md:min-h-11 max-md:min-w-11"
@click="$emit('submitCommand', 'look')"
>
👀 Look
👀<span class="max-md:hidden"> Look</span>
</button>
<button
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-[375px]:min-h-11 max-[375px]:min-w-11 max-[375px]:px-3 max-[375px]:py-2"
aria-label="Search"
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-md:px-2.5 max-md:min-h-11 max-md:min-w-11"
@click="$emit('submitCommand', 'search')"
>
🔍 Search
🔍<span class="max-md:hidden"> Search</span>
</button>
<button
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-[375px]:min-h-11 max-[375px]:min-w-11 max-[375px]:px-3 max-[375px]:py-2"
aria-label="Inventory"
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-md:px-2.5 max-md:min-h-11 max-md:min-w-11"
@click="$emit('submitCommand', 'inventory')"
>
🎒 Inventory
🎒<span class="max-md:hidden"> Inventory</span>
</button>
<button
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-[375px]:min-h-11 max-[375px]:min-w-11 max-[375px]:px-3 max-[375px]:py-2"
aria-label="Spells"
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-md:px-2.5 max-md:min-h-11 max-md:min-w-11"
@click="$emit('submitCommand', 'spells')"
>
✨ Spells
✨<span class="max-md:hidden"> Spells</span>
</button>
<button
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-[375px]:min-h-11 max-[375px]:min-w-11 max-[375px]:px-3 max-[375px]:py-2"
aria-label="Help"
class="inline-flex items-center gap-1 px-3.5 py-1.5 rounded-full bg-chip-bg border border-border text-text-primary cursor-pointer text-sm whitespace-nowrap transition-colors hover:bg-chip-hover max-md:px-2.5 max-md:min-h-11 max-md:min-w-11"
@click="$emit('submitCommand', 'help')"
>
❓ Help
❓<span class="max-md:hidden"> Help</span>
</button>
Comment on lines 96 to 130

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quick-action buttons hide their text labels on max-md, leaving only emoji as the accessible name. This makes the controls ambiguous for screen readers/voice control users. Add explicit aria-labels (or keep an sr-only text label) so the buttons remain accessible when the visible text is hidden.

Copilot uses AI. Check for mistakes.
</div>

Expand Down
5 changes: 4 additions & 1 deletion web/src/components/GameLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,25 +71,28 @@ 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(() => {
updateMobile()
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 ---
Expand Down
35 changes: 24 additions & 11 deletions web/src/components/QuestModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,31 @@ onUnmounted(() => {
@click.self="$emit('dismiss')"
>
<div
class="bg-bg-card border border-border rounded-xl p-6 max-w-[480px] w-[90%] shadow-[0_12px_40px_rgba(0,0,0,0.5)]"
data-testid="quest-modal-card"
class="bg-bg-card border border-border rounded-xl p-4 md:p-6 max-w-[480px] w-[90%] max-h-[90vh] flex flex-col shadow-[0_12px_40px_rgba(0,0,0,0.5)]"
>
<div class="text-[1.1rem] font-bold text-quest mb-3">{{ title }}</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="leading-relaxed mb-4" v-html="body"></div>
<button
class="float-right px-6 py-2 rounded-md bg-accent text-white border-none cursor-pointer text-[0.9rem] hover:opacity-85"
@click="$emit('dismiss')"
>
Continue
</button>
<div class="clear-both"></div>
<div class="text-[1.1rem] font-bold text-quest mb-3 shrink-0">
{{ title }}
</div>
<!-- eslint-disable vue/no-v-html -->
<div class="relative flex-1 min-h-0">
<div
class="h-full overflow-y-auto leading-relaxed pr-1 touch-pan-y overscroll-contain"
v-html="body"
></div>
<div
class="pointer-events-none absolute bottom-0 inset-x-0 h-6 bg-gradient-to-t from-bg-card to-transparent"
></div>
</div>
<!-- eslint-enable vue/no-v-html -->
<div class="shrink-0 flex justify-end pt-2">
Comment on lines +42 to +53

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions a “bottom fade gradient” hinting at more content, but QuestModal template doesn’t add any gradient overlay element/class. Either implement the gradient effect or update the PR description so it matches the actual change set.

Copilot uses AI. Check for mistakes.
<button
class="px-6 py-2 rounded-md bg-accent text-white border-none cursor-pointer text-[0.9rem] hover:opacity-85"
@click="$emit('dismiss')"
>
Continue
</button>
</div>
</div>
</div>
</template>
20 changes: 12 additions & 8 deletions web/src/components/SidePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<!-- eslint-disable vue/no-v-html -->
<div class="font-semibold text-[0.9rem]" v-html="item.name"></div>
<div class="text-[0.8rem] text-text-secondary mt-0.5">
{{ item.description }}
</div>
<div
class="text-[0.8rem] text-text-secondary mt-0.5"
v-html="item.description"
></div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</div>
Expand Down Expand Up @@ -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)"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<!-- eslint-disable vue/no-v-html -->
<div class="font-semibold text-[0.9rem]" v-html="spell.name"></div>
<div class="text-[0.8rem] text-text-secondary mt-0.5">
{{ spell.description }}
</div>
<div
class="text-[0.8rem] text-text-secondary mt-0.5"
v-html="spell.description"
></div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</div>
Expand Down
66 changes: 66 additions & 0 deletions web/src/components/TopBar.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'

defineProps<{
title: string
musicMuted: boolean
Expand All @@ -15,6 +17,29 @@ defineEmits<{
help: []
toggleDrawer: []
}>()

const isFullscreen = ref(false)

function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}

function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {})
} else {
document.exitFullscreen().catch(() => {})
}
}

onMounted(() => {
isFullscreen.value = !!document.fullscreenElement
document.addEventListener('fullscreenchange', onFullscreenChange)
})

onUnmounted(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange)
})
</script>

<template>
Expand Down Expand Up @@ -66,6 +91,47 @@ defineEmits<{
>
{{ soundMuted ? '🔕' : '🔔' }}
</button>
<button
class="bg-chip-bg text-text-primary border border-border rounded-md px-2 py-1.5 cursor-pointer text-sm transition-colors hover:bg-chip-hover flex items-center justify-center"
:title="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
:aria-label="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
@click="toggleFullscreen"
>
<svg
v-if="!isFullscreen"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="10" y1="14" x2="3" y2="21" />
<line x1="21" y1="3" x2="14" y2="10" />
</svg>
</button>
<button
class="max-md:hidden bg-chip-bg text-text-primary border border-border rounded-md px-3.5 py-1.5 cursor-pointer text-sm transition-colors hover:bg-chip-hover"
@click="$emit('help')"
Expand Down
Loading
Loading