From 70eefc4720493b8451114373da71b32b21faf6ba Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 01:13:22 +0000 Subject: [PATCH] =?UTF-8?q?fix(game-ui):=20=EC=9E=90=EB=8F=99=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=8F=AD=EA=B3=BC=20=EA=B2=80=EC=83=89=20=ED=8F=AC?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=20=ED=99=95=EB=8C=80=EB=A5=BC=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=EC=9E=A1=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref의 700px 자동 판정과 940px 레이아웃 경계를 연결하고 태블릿에서 1000px 모드를 선택한다. 검색 입력에는 pinch zoom을 보존하는 touch-action 계약을 적용한다. --- app/game-frontend/e2e/mainNavigation.spec.ts | 147 +++++++++++++++++- .../e2e/nationGeneralSecret.spec.ts | 11 ++ app/game-frontend/src/assets/main.css | 9 ++ app/game-frontend/src/main.ts | 2 + .../src/utils/screenModeViewport.ts | 78 ++++++++++ app/game-frontend/src/views/MyPageView.vue | 5 +- .../test/screenModeViewport.test.ts | 35 +++++ 7 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 app/game-frontend/src/utils/screenModeViewport.ts create mode 100644 app/game-frontend/test/screenModeViewport.test.ts diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index df63fc59..2d752e4d 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -885,9 +885,9 @@ const expectMobilePanelVisualOrder = async (page: Page, expectedOrder: readonly expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder); expect(audit.visualOrder).toEqual(expectedOrder); expect(audit.panels.every(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true); - expect( - audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom) - ).toBe(true); + expect(audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)).toBe( + true + ); for (const panel of audit.panels) { expect(panel.display, `${panel.id}: display`).not.toBe('none'); expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position); @@ -1125,9 +1125,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-mobile')).toHaveCount(0); - await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount( - 1 - ); + await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); @@ -1279,7 +1277,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => { +test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ + page, +}) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -2794,6 +2794,139 @@ test('real mobile devices initially fit the complete 500px game canvas', async ( } }); +test('automatic screen mode switches wide mobile screens to the 1000px layout at the Ref boundary', async ({ + browser, +}, testInfo) => { + test.setTimeout(60_000); + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the automatic screen-mode contract'); + } + + const measurements: Record = {}; + for (const deviceWidth of [699, 700, 820]) { + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: deviceWidth, height: 1180 }, + screen: { width: deviceWidth, height: 1180 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 6, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }; + await installFixture(mobilePage, state); + await waitForMain(mobilePage); + + const expectedWideLayout = deviceWidth >= 700; + await expect(mobilePage.locator(expectedWideLayout ? '.layout-desktop' : '.layout-mobile')).toBeVisible(); + expect( + await mobilePage.evaluate(() => document.querySelector('meta[name="viewport"]')?.content) + ).toBe(expectedWideLayout ? 'width=1000' : 'width=device-width, initial-scale=1'); + const modeMeasurements: Record = { + auto: await mobilePage.locator('.main-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const mobileLayout = document.querySelector('.layout-mobile'); + const desktopLayout = document.querySelector('.layout-desktop'); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + screenWidth: screen.availWidth, + innerWidth: window.innerWidth, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + visualViewportScale: window.visualViewport?.scale ?? null, + mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null, + desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null, + canvas: { left: rect.left, right: rect.right, width: rect.width }, + }; + }), + }; + + if (deviceWidth === 820) { + await mobilePage.evaluate(() => { + localStorage.setItem('sam.screenMode', '500px'); + document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + }); + await expect(mobilePage.locator('.layout-mobile')).toBeVisible(); + expect( + await mobilePage.evaluate( + () => document.querySelector('meta[name="viewport"]')?.content + ) + ).toBe('width=500'); + modeMeasurements.forced500 = await mobilePage.locator('.main-page').evaluate(() => { + const mobileLayout = document.querySelector('.layout-mobile'); + const desktopLayout = document.querySelector('.layout-desktop'); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null, + desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null, + }; + }); + + await mobilePage.evaluate(() => { + localStorage.setItem('sam.screenMode', '1000px'); + document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + }); + await expect(mobilePage.locator('.layout-desktop')).toBeVisible(); + expect( + await mobilePage.evaluate( + () => document.querySelector('meta[name="viewport"]')?.content + ) + ).toBe('width=1000'); + modeMeasurements.forced1000 = await mobilePage.locator('.main-page').evaluate(() => { + const mobileLayout = document.querySelector('.layout-mobile'); + const desktopLayout = document.querySelector('.layout-desktop'); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null, + desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null, + }; + }); + + await mobilePage.evaluate(() => { + localStorage.setItem('sam.screenMode', 'auto'); + document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + }); + await expect(mobilePage.locator('.layout-desktop')).toBeVisible(); + expect( + await mobilePage.evaluate( + () => document.querySelector('meta[name="viewport"]')?.content + ) + ).toBe('width=1000'); + } + + measurements[String(deviceWidth)] = modeMeasurements; + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await mobilePage.screenshot({ + path: resolve(artifactRoot, `auto-screen-mode-${deviceWidth}.png`), + fullPage: true, + }); + } + await context.close(); + } + + if (artifactRoot) { + await writeFile( + resolve(artifactRoot, 'auto-screen-mode-computed-dom.json'), + `${JSON.stringify(measurements, null, 2)}\n` + ); + } +}); + test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index c4a95cf8..8259b737 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -294,8 +294,19 @@ test('nation generals filter buttons open Ref operator menus and apply compound await page.setViewportSize({ width: 500, height: 900 }); expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000); + const generalSearch = page.getByLabel('장수명 필터'); + await expect(generalSearch).toHaveCSS('touch-action', 'manipulation'); + const viewportContract = await page.evaluate(() => ({ + content: document.querySelector('meta[name="viewport"]')?.content ?? '', + scale: window.visualViewport?.scale ?? 1, + })); + expect(viewportContract.content).not.toMatch(/(?:user-scalable|minimum-scale|maximum-scale)/u); + await generalSearch.focus(); + await expect(generalSearch).toBeFocused(); + expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(viewportContract.scale); await nameMenuButton.click(); await expect(namePopup).toBeVisible(); + await expect(page.getByLabel('장수명 첫 번째 필터 값')).toHaveCSS('touch-action', 'manipulation'); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true }); }); diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 4e0d1317..65148076 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -63,6 +63,15 @@ textarea { font: inherit; } +/* + * Firefox for Android may zoom to a focused search field. `manipulation` + * suppresses that focus-only zoom while retaining ordinary pan and pinch zoom. + */ +input[type='search'], +input[inputmode='search'] { + touch-action: manipulation; +} + /* * Ref's `.bg0/.bg1/.bg2` set a background image and nothing else, so the * element stays transparent where the texture does not cover it. Adding a diff --git a/app/game-frontend/src/main.ts b/app/game-frontend/src/main.ts index 025d426c..fa3cafb7 100644 --- a/app/game-frontend/src/main.ts +++ b/app/game-frontend/src/main.ts @@ -4,8 +4,10 @@ import App from './App.vue'; import router from './router'; import './assets/main.css'; import { installImageAssetCssVariables } from './utils/imageAssets'; +import { installScreenModeViewport } from './utils/screenModeViewport'; installImageAssetCssVariables(); +installScreenModeViewport(); const app = createApp(App); diff --git a/app/game-frontend/src/utils/screenModeViewport.ts b/app/game-frontend/src/utils/screenModeViewport.ts new file mode 100644 index 00000000..260fec55 --- /dev/null +++ b/app/game-frontend/src/utils/screenModeViewport.ts @@ -0,0 +1,78 @@ +export const SCREEN_MODE_KEY = 'sam.screenMode'; +export const SCREEN_MODE_CHANGE_EVENT = 'tryChangeScreenMode'; + +export type ScreenMode = 'auto' | '500px' | '1000px'; + +export type AutoViewportMeasurements = { + deviceWidth: number; + viewportHeight: number; + targetHeight?: number; +}; + +export const normalizeScreenMode = (value: string | null): ScreenMode => + value === '500px' || value === '1000px' ? value : 'auto'; + +export const resolveAutoViewportContent = ({ + deviceWidth, + viewportHeight, + targetHeight = 700, +}: AutoViewportMeasurements): string => { + if (deviceWidth < 500) { + return 'width=500'; + } + + if (viewportHeight < targetHeight) { + const widthAtTargetHeight = (deviceWidth / viewportHeight) * targetHeight; + return widthAtTargetHeight >= 700 ? 'width=1000' : `height=${Math.ceil(targetHeight)}`; + } + + return deviceWidth >= 700 ? 'width=1000' : 'width=device-width, initial-scale=1'; +}; + +export const resolveViewportContent = (mode: ScreenMode, measurements: AutoViewportMeasurements): string => { + if (mode === '500px') return 'width=500'; + if (mode === '1000px') return 'width=1000'; + return resolveAutoViewportContent(measurements); +}; + +const findOrCreateViewportMeta = (): HTMLMetaElement => { + const existing = document.querySelector('meta[name="viewport"]'); + if (existing) return existing; + + const viewportMeta = document.createElement('meta'); + viewportMeta.name = 'viewport'; + document.head.appendChild(viewportMeta); + return viewportMeta; +}; + +export const installScreenModeViewport = (targetHeight = 700): void => { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + + const viewportMeta = findOrCreateViewportMeta(); + let previousMode: ScreenMode | null = null; + let previousDeviceWidth: number | null = null; + + const adjustViewport = () => { + const mode = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY)); + const deviceWidth = window.screen.availWidth; + + if (mode === previousMode && mode === 'auto' && deviceWidth === previousDeviceWidth) return; + if (mode === previousMode && mode !== 'auto') return; + + previousMode = mode; + previousDeviceWidth = deviceWidth; + viewportMeta.content = resolveViewportContent(mode, { + deviceWidth, + viewportHeight: window.innerHeight, + targetHeight, + }); + }; + + adjustViewport(); + window.addEventListener('resize', adjustViewport); + window.addEventListener('orientationchange', adjustViewport); + window.addEventListener('storage', (event) => { + if (event.key === SCREEN_MODE_KEY) adjustViewport(); + }); + document.addEventListener(SCREEN_MODE_CHANGE_EVENT, adjustViewport); +}; diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 28d367d7..63fe756f 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -9,6 +9,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; +import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport'; import { DEFAULT_MOBILE_MAIN_PANEL_ORDER, loadMobileMainPanelOrder, @@ -18,11 +19,9 @@ import { type MobileMainPanelId, } from '../utils/mobileMainPanelOrder'; -const SCREEN_MODE_KEY = 'sam.screenMode'; const CUSTOM_CSS_KEY = 'sam_customCSS'; const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart'; const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback(); -type ScreenMode = 'auto' | '500px' | '1000px'; type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item'; type MyGeneralResponse = Awaited>; @@ -387,7 +386,7 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin watch(screenMode, (mode) => { localStorage.setItem(SCREEN_MODE_KEY, mode); - document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT)); }); watch(customCss, (text) => { diff --git a/app/game-frontend/test/screenModeViewport.test.ts b/app/game-frontend/test/screenModeViewport.test.ts new file mode 100644 index 00000000..45e426a9 --- /dev/null +++ b/app/game-frontend/test/screenModeViewport.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + normalizeScreenMode, + resolveAutoViewportContent, + resolveViewportContent, +} from '../src/utils/screenModeViewport.ts'; + +void test('automatic mode follows the Ref physical-screen thresholds', () => { + assert.equal(resolveAutoViewportContent({ deviceWidth: 390, viewportHeight: 844 }), 'width=500'); + assert.equal( + resolveAutoViewportContent({ deviceWidth: 699, viewportHeight: 900 }), + 'width=device-width, initial-scale=1' + ); + assert.equal(resolveAutoViewportContent({ deviceWidth: 700, viewportHeight: 900 }), 'width=1000'); + assert.equal(resolveAutoViewportContent({ deviceWidth: 820, viewportHeight: 1180 }), 'width=1000'); +}); + +void test('automatic mode preserves the Ref short-viewport aspect-ratio branch', () => { + assert.equal(resolveAutoViewportContent({ deviceWidth: 600, viewportHeight: 650 }), 'height=700'); + assert.equal(resolveAutoViewportContent({ deviceWidth: 650, viewportHeight: 600 }), 'width=1000'); +}); + +void test('explicit modes override automatic measurements and invalid storage falls back to auto', () => { + const phone = { deviceWidth: 390, viewportHeight: 844 }; + const tablet = { deviceWidth: 820, viewportHeight: 1180 }; + + assert.equal(resolveViewportContent('1000px', phone), 'width=1000'); + assert.equal(resolveViewportContent('500px', tablet), 'width=500'); + assert.equal(normalizeScreenMode('1000px'), '1000px'); + assert.equal(normalizeScreenMode('500px'), '500px'); + assert.equal(normalizeScreenMode('unexpected'), 'auto'); + assert.equal(normalizeScreenMode(null), 'auto'); +});