fix(game-ui): 자동 화면 폭과 검색 포커스 확대를 바로잡음

Ref의 700px 자동 판정과 940px 레이아웃 경계를 연결하고 태블릿에서 1000px 모드를 선택한다. 검색 입력에는 pinch zoom을 보존하는 touch-action 계약을 적용한다.
This commit is contained in:
2026-08-21 01:13:22 +00:00
parent 5da554a869
commit 70eefc4720
7 changed files with 277 additions and 10 deletions
+140 -7
View File
@@ -885,9 +885,9 @@ const expectMobilePanelVisualOrder = async (page: Page, expectedOrder: readonly
expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder); expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder);
expect(audit.visualOrder).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(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true);
expect( expect(audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)).toBe(
audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom) true
).toBe(true); );
for (const panel of audit.panels) { for (const panel of audit.panels) {
expect(panel.display, `${panel.id}: display`).not.toBe('none'); expect(panel.display, `${panel.id}: display`).not.toBe('none');
expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position); 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('.main-mobile-bottom')).toBeHidden();
await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-desktop')).toBeVisible();
await expect(page.locator('.layout-mobile')).toHaveCount(0); await expect(page.locator('.layout-mobile')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount( await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1);
1
);
await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); 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('현재: 185년 1월');
await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); 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`); 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 = { const state: NavigationFixture = {
officerLevel: 5, officerLevel: 5,
permission: 2, 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<string, unknown> = {};
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<HTMLMetaElement>('meta[name="viewport"]')?.content)
).toBe(expectedWideLayout ? 'width=1000' : 'width=device-width, initial-scale=1');
const modeMeasurements: Record<string, unknown> = {
auto: await mobilePage.locator('.main-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
return {
viewportMeta: document.querySelector<HTMLMetaElement>('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<HTMLMetaElement>('meta[name="viewport"]')?.content
)
).toBe('width=500');
modeMeasurements.forced500 = await mobilePage.locator('.main-page').evaluate(() => {
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
return {
viewportMeta: document.querySelector<HTMLMetaElement>('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<HTMLMetaElement>('meta[name="viewport"]')?.content
)
).toBe('width=1000');
modeMeasurements.forced1000 = await mobilePage.locator('.main-page').evaluate(() => {
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
return {
viewportMeta: document.querySelector<HTMLMetaElement>('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<HTMLMetaElement>('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 }) => { test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 1, officerLevel: 1,
@@ -294,8 +294,19 @@ test('nation generals filter buttons open Ref operator menus and apply compound
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000); 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<HTMLMetaElement>('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 nameMenuButton.click();
await expect(namePopup).toBeVisible(); await expect(namePopup).toBeVisible();
await expect(page.getByLabel('장수명 첫 번째 필터 값')).toHaveCSS('touch-action', 'manipulation');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true });
}); });
+9
View File
@@ -63,6 +63,15 @@ textarea {
font: inherit; 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 * 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 * element stays transparent where the texture does not cover it. Adding a
+2
View File
@@ -4,8 +4,10 @@ import App from './App.vue';
import router from './router'; import router from './router';
import './assets/main.css'; import './assets/main.css';
import { installImageAssetCssVariables } from './utils/imageAssets'; import { installImageAssetCssVariables } from './utils/imageAssets';
import { installScreenModeViewport } from './utils/screenModeViewport';
installImageAssetCssVariables(); installImageAssetCssVariables();
installScreenModeViewport();
const app = createApp(App); const app = createApp(App);
@@ -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<HTMLMetaElement>('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);
};
+2 -3
View File
@@ -9,6 +9,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
import { import {
DEFAULT_MOBILE_MAIN_PANEL_ORDER, DEFAULT_MOBILE_MAIN_PANEL_ORDER,
loadMobileMainPanelOrder, loadMobileMainPanelOrder,
@@ -18,11 +19,9 @@ import {
type MobileMainPanelId, type MobileMainPanelId,
} from '../utils/mobileMainPanelOrder'; } from '../utils/mobileMainPanelOrder';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS'; const CUSTOM_CSS_KEY = 'sam_customCSS';
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart'; const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback(); const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback();
type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item'; type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>; type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
@@ -387,7 +386,7 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin
watch(screenMode, (mode) => { watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode); localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
}); });
watch(customCss, (text) => { watch(customCss, (text) => {
@@ -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');
});