diff --git a/AGENTS.md b/AGENTS.md index 35a139bf..8ea2c798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,10 @@ UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.m 분석 편의를 위해 배포하는 산출물입니다. 보안상 비공개가 필요하다는 별도 요구와 검토가 없는 한 번들 크기, build 시간 또는 일반적인 "최적화" 지시만으로 `build.sourcemap`을 끄거나 `.map` 파일을 배포 산출물에서 제외하지 말아 주세요. +- 짧은 제목·상태·버튼·정보 셀은 가로와 세로 정렬을 각각 결정합니다. 가로 가운데만 + 지정하고 끝내지 말고, 왼쪽/오른쪽 정렬 값도 세로 가운데가 적절한지 확인합니다. + 여러 줄 본문·로그는 읽기 시작점을 보존하고, 좁은 화면의 줄바꿈과 overflow를 + 검증합니다. 구현 방법과 예외는 [두 축 텍스트 정렬](docs/frontend-css-architecture.md#text-alignment-on-both-axes-2026-09-29)을 따릅니다. - 공통화는 동일한 렌더링 계약이 확인된 token/shell에 한합니다. `.error`, `.stack`처럼 이름만 같은 page selector를 전역화하지 말아 주세요. - `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을 diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index 2a30ae85..44f9ff13 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -1,3 +1,4 @@ +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { expect, test, type Page, type Route } from '@playwright/test'; import { readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; @@ -618,3 +619,18 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex }); expect(workerUrls.some((url) => url.includes('battleSimulator.worker'))).toBe(true); }); + +for (const width of [500, 1000]) { + test(`전투 시뮬레이터 요약표 세로 정렬 ${width}px`, async ({ page }, testInfo) => { + await installApi(page, { + hasGeneral: true, + requests: [], + preparedPayloads: [], + serverResults: [], + prepareResponseBytes: [], + }); + await page.setViewportSize({ width, height: 900 }); + await gotoSimulator(page); + await verifyVerticalAlignment(page, testInfo, ['.summary-table th', '.summary-table td'], 'table'); + }); +} diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 29edcc90..990a49d1 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -1,3 +1,4 @@ +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { expect, test, type Page, type Route } from '@playwright/test'; import { readFile, writeFile } from 'node:fs/promises'; import { @@ -5037,3 +5038,16 @@ for (const width of [1200, 390]) { await expect.poll(() => JSON.stringify(requests)).toContain('"nationType":"che_덕가"'); }); } + +for (const width of [500, 1000]) { + test(`징병 현황 세로 정렬 ${width}px`, async ({ page }, testInfo) => { + await install(page); + await page.setViewportSize({ width, height: 900 }); + await page.goto('/'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '내정', exact: true }).click(); + await picker.getByRole('button', { name: '징병', exact: true }).click(); + await verifyVerticalAlignment(page, testInfo, ['.recruitment-status > span']); + }); +} diff --git a/app/game-frontend/e2e/diplomacy.spec.ts b/app/game-frontend/e2e/diplomacy.spec.ts index 07a6da19..d9fbc6a9 100644 --- a/app/game-frontend/e2e/diplomacy.spec.ts +++ b/app/game-frontend/e2e/diplomacy.spec.ts @@ -1,3 +1,4 @@ +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { mkdir, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; @@ -161,3 +162,12 @@ test('keeps diplomacy detail redacted below permission three', async ({ page }) await expect(page.getByText('문서 작성 권한은 군주/수뇌에게만 제공됩니다.')).toBeVisible(); await expect(page.getByRole('button', { name: '전송' })).toHaveCount(0); }); + +for (const width of [500, 1000]) { + test(`외교 제목 세로 정렬 ${width}px`, async ({ page }, testInfo) => { + await installFixture(page, 4); + await page.setViewportSize({ width, height: 900 }); + await page.goto('diplomacy'); + await verifyVerticalAlignment(page, testInfo, ['.panel-header']); + }); +} diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 5097bc8e..14e49a4a 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -1,3 +1,4 @@ +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; @@ -2680,3 +2681,17 @@ test('감찰부 사관 연수를 채운 일반 장수는 개인 기록 거부에 await expect(page.locator('[data-log-type="battleResult"]')).toContainText('battleResult 감찰 기록'); } }); + +for (const width of [500, 1000]) { + test(`내 정보의 오른쪽 수치와 왼쪽 날짜 세로 정렬 ${width}px`, async ({ page }, testInfo) => { + await install(page, { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }); + await page.setViewportSize({ width, height: 900 }); + await page.goto('my-page'); + await verifyVerticalAlignment(page, testInfo, [ + '.battle-general-extra > span', + '.battle-general-extra > strong', + '.dex-row > span', + '.dex-row > strong', + ]); + }); +} diff --git a/app/game-frontend/e2e/nationBetting.spec.ts b/app/game-frontend/e2e/nationBetting.spec.ts index 1ed10487..c6eff9c5 100644 --- a/app/game-frontend/e2e/nationBetting.spec.ts +++ b/app/game-frontend/e2e/nationBetting.spec.ts @@ -1,3 +1,4 @@ +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { expect, test, type Page, type Route } from '@playwright/test'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; @@ -115,3 +116,13 @@ test('keeps the nation-betting amount while changing candidates, submitting, and await expect(amount).toHaveValue('100'); await page.screenshot({ path: testInfo.outputPath('nation-betting-amount-retained.png'), fullPage: true }); }); + +for (const width of [500, 1000]) { + test(`국가 베팅 지급표 세로 정렬 ${width}px`, async ({ page }, testInfo) => { + await installFixture(page); + await page.setViewportSize({ width, height: 900 }); + await page.goto('nation-betting'); + await page.getByRole('button', { name: /두 번째 천통국 베팅/u }).click(); + await verifyVerticalAlignment(page, testInfo, ['.payout-row > div']); + }); +} diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index b01b2ae5..8468dfcf 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -1,3 +1,4 @@ +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { devices, expect, test, type Page, type Route } from '@playwright/test'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; @@ -1089,3 +1090,12 @@ for (const width of [1365, 390]) { await testInfo.attach('icon-dom', { body: await page.content(), contentType: 'text/html' }); }); } + +for (const width of [500, 1000]) { + test(`재정 격자 세로 정렬 ${width}px`, async ({ page }, testInfo) => { + await installFixture(page, { role: 'head', rate: 20 }); + await page.setViewportSize({ width, height: 900 }); + await gotoOffice(page, 'nation/finance'); + await verifyVerticalAlignment(page, testInfo, ['.diplomacy-row > div', '.budget-row > span', '.blue-heading']); + }); +} diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index 7e6d275b..59f8e5cd 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -1,3 +1,4 @@ +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { devices, expect, test, type Page, type Route } from '@playwright/test'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; @@ -454,3 +455,12 @@ test('a user below secret read permission receives a recoverable page error', as await expect(page.locator('#container')).toHaveCount(0); await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible(); }); + +for (const width of [500, 1000]) { + test(`NPC 정책 제목 세로 정렬 ${width}px`, async ({ page }, testInfo) => { + await installFixture(page, { permissionLevel: 4, mutations: [] }); + await page.setViewportSize({ width, height: 900 }); + await gotoPolicy(page); + await verifyVerticalAlignment(page, testInfo, ['.section_bar', '.sub_bar']); + }); +} diff --git a/app/game-frontend/e2e/pastPlays.spec.ts b/app/game-frontend/e2e/pastPlays.spec.ts index be294422..a5f912bb 100644 --- a/app/game-frontend/e2e/pastPlays.spec.ts +++ b/app/game-frontend/e2e/pastPlays.spec.ts @@ -1,3 +1,4 @@ +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { mkdir, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; @@ -378,3 +379,13 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport', expect(detailMetrics.hallColumns.split(' ')).toHaveLength(3); expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom); }); + +for (const width of [500, 1000]) { + test(`지난 플레이 전투 수치 세로 정렬 ${width}px`, async ({ page }, testInfo) => { + await installArchive(page); + await page.setViewportSize({ width, height: 900 }); + await page.goto('past-plays'); + await page.locator('.detail-toggle').click(); + await verifyVerticalAlignment(page, testInfo, ['.hall-battle-record dt', '.hall-battle-record dd']); + }); +} diff --git a/app/game-frontend/e2e/typographyPolicy.spec.ts b/app/game-frontend/e2e/typographyPolicy.spec.ts index 7c7e79b7..c7febd36 100644 --- a/app/game-frontend/e2e/typographyPolicy.spec.ts +++ b/app/game-frontend/e2e/typographyPolicy.spec.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { expect, test, type Page } from '@playwright/test'; +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; // 기존 화면 fixture의 읽기 응답을 고정했다. 실제 계정이나 게임 서버에는 접근하지 않는다. @@ -192,3 +193,29 @@ for (const width of [500, 1000]) { await page.screenshot({ path: testInfo.outputPath('battle-log-exception.png'), fullPage: true }); }); } + +for (const width of [500, 1000]) { + for (const scene of [ + { + name: 'main', + route: './', + selectors: [ + '.general-basic-grid > .cell-label', + '.general-basic-grid > strong', + '.city-title', + '.city-nation', + '.record-title', + '.activity-status .status-row', + ], + }, + { name: 'personnel', route: 'nation/personnel', selectors: ['.nation-heading', '.personnel-change-button'] }, + { name: 'possession', route: 'join', selectors: ['.npc-token-status'] }, + ]) { + test(`세로 정렬은 셀 크기와 가로 정렬을 보존한다 ${scene.name} ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + await install(page, scene.name, names[0].general, names[0].nation); + await page.goto(scene.route); + await verifyVerticalAlignment(page, testInfo, scene.selectors); + }); + } +} diff --git a/app/game-frontend/src/assets/styles/game-shell.css b/app/game-frontend/src/assets/styles/game-shell.css index 6b92bb25..92c7401e 100644 --- a/app/game-frontend/src/assets/styles/game-shell.css +++ b/app/game-frontend/src/assets/styles/game-shell.css @@ -39,6 +39,7 @@ } .game-shell__action { + align-content: safe center; border: 1px solid var(--sammo-color-border); padding: 6px 12px; background: var(--sammo-color-action-bg); diff --git a/app/game-frontend/src/assets/styles/legacy-controls.css b/app/game-frontend/src/assets/styles/legacy-controls.css index db304b9a..a4b647e6 100644 --- a/app/game-frontend/src/assets/styles/legacy-controls.css +++ b/app/game-frontend/src/assets/styles/legacy-controls.css @@ -1,4 +1,5 @@ .legacy-button { + align-content: safe center; display: inline-block; box-sizing: border-box; border: 1px solid var(--sammo-button-base1-border); diff --git a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue index b4196720..4ad4426f 100644 --- a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue +++ b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue @@ -310,6 +310,7 @@ small { text-align: center; } .recruitment-status > span { + align-content: safe center; padding: 4px 2px; } .injured { diff --git a/app/game-frontend/src/components/main/CityBasicCard.vue b/app/game-frontend/src/components/main/CityBasicCard.vue index 72dca256..6bdaccc5 100644 --- a/app/game-frontend/src/components/main/CityBasicCard.vue +++ b/app/game-frontend/src/components/main/CityBasicCard.vue @@ -157,6 +157,7 @@ const nationStyle = computed(() => { .city-title, .city-nation { + align-content: safe center; grid-column: 1 / -1; min-width: 0; border-top: 1px solid gray; diff --git a/app/game-frontend/src/components/main/GeneralBasicCard.vue b/app/game-frontend/src/components/main/GeneralBasicCard.vue index fd01f151..aa71724d 100644 --- a/app/game-frontend/src/components/main/GeneralBasicCard.vue +++ b/app/game-frontend/src/components/main/GeneralBasicCard.vue @@ -481,6 +481,7 @@ const specialText = computed(() => { } .general-basic-grid > * { + align-content: safe center; box-sizing: border-box; min-width: 0; min-height: 0; diff --git a/app/game-frontend/src/components/main/GeneralBattleSummary.vue b/app/game-frontend/src/components/main/GeneralBattleSummary.vue index b7b97216..3d73ae3b 100644 --- a/app/game-frontend/src/components/main/GeneralBattleSummary.vue +++ b/app/game-frontend/src/components/main/GeneralBattleSummary.vue @@ -117,6 +117,7 @@ const killRate = computed(() => { } .battle-general-extra > * { + align-content: safe center; min-height: 24px; box-sizing: border-box; border-right: 1px solid #777; diff --git a/app/game-frontend/src/components/main/MainFrontStatus.vue b/app/game-frontend/src/components/main/MainFrontStatus.vue index 47ae6ef6..60cb6e54 100644 --- a/app/game-frontend/src/components/main/MainFrontStatus.vue +++ b/app/game-frontend/src/components/main/MainFrontStatus.vue @@ -142,6 +142,7 @@ watch( } .activity-status .status-row { + align-content: safe center; padding-right: 0; padding-left: 0; text-align: center; diff --git a/app/game-frontend/src/components/main/RecordPanel.vue b/app/game-frontend/src/components/main/RecordPanel.vue index 45bb9b0e..d6b9f9d3 100644 --- a/app/game-frontend/src/components/main/RecordPanel.vue +++ b/app/game-frontend/src/components/main/RecordPanel.vue @@ -23,6 +23,7 @@ defineProps<{ } .record-title { + align-content: safe center; box-sizing: border-box; height: 23px; margin: 0; diff --git a/app/game-frontend/src/components/ui/LegacyGeneralProgress.vue b/app/game-frontend/src/components/ui/LegacyGeneralProgress.vue index a54ef0b2..a6a585ea 100644 --- a/app/game-frontend/src/components/ui/LegacyGeneralProgress.vue +++ b/app/game-frontend/src/components/ui/LegacyGeneralProgress.vue @@ -114,6 +114,7 @@ const experiencePercent = computed(() => .stat-grid > *, .experience-row > *, .dex-row > * { + align-content: safe center; box-sizing: border-box; min-width: 0; border-right: 1px solid #666; diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index 3df8ba93..a99755a9 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -1617,7 +1617,7 @@ button:disabled { /* * Ref renders this as a Bootstrap table: 7px padding on every side, a #444 * bottom rule and the transparent accent inset shadow the framework always - * emits. + * emits. Core centers the compact label/value cells vertically. */ .summary-table th, .summary-table td { @@ -1625,7 +1625,7 @@ button:disabled { border-bottom: 1px solid #444; box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0); text-align: left; - vertical-align: top; + vertical-align: middle; } .summary-table th { diff --git a/app/game-frontend/src/views/DiplomacyView.vue b/app/game-frontend/src/views/DiplomacyView.vue index cd0698b5..7425fd31 100644 --- a/app/game-frontend/src/views/DiplomacyView.vue +++ b/app/game-frontend/src/views/DiplomacyView.vue @@ -693,6 +693,7 @@ onBeforeUnmount(() => { } .panel-header { + align-content: safe center; height: 18px; text-align: center; } diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index e1b7e6df..81a29b47 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -1705,6 +1705,7 @@ small { } .npc-token-status { + align-content: safe center; min-height: 22px; text-align: center; font-size: var(--sammo-font-size-small); diff --git a/app/game-frontend/src/views/NationBettingView.vue b/app/game-frontend/src/views/NationBettingView.vue index 05ca59fa..490f3c36 100644 --- a/app/game-frontend/src/views/NationBettingView.vue +++ b/app/game-frontend/src/views/NationBettingView.vue @@ -597,6 +597,7 @@ onMounted(() => { } .payout-row > div { + align-content: safe center; min-width: 0; padding: 0 4px; } diff --git a/app/game-frontend/src/views/NationPersonnelView.vue b/app/game-frontend/src/views/NationPersonnelView.vue index fdd26ed2..860570d4 100644 --- a/app/game-frontend/src/views/NationPersonnelView.vue +++ b/app/game-frontend/src/views/NationPersonnelView.vue @@ -677,6 +677,7 @@ select { font: inherit; } .nation-heading { + align-content: safe center; height: 32px; text-align: center; font-size: var(--sammo-font-size-title); diff --git a/app/game-frontend/src/views/NationStratFinanView.vue b/app/game-frontend/src/views/NationStratFinanView.vue index aa0519aa..e96519ec 100644 --- a/app/game-frontend/src/views/NationStratFinanView.vue +++ b/app/game-frontend/src/views/NationStratFinanView.vue @@ -530,6 +530,7 @@ textarea:focus-visible { text-align: center; } .diplomacy-row > div { + align-content: safe center; border-bottom: 1px solid gray; overflow: hidden; white-space: nowrap; @@ -618,6 +619,7 @@ textarea { width: 50%; } .blue-heading { + align-content: safe center; height: 18.19px; text-align: center; background: var(--sammo-texture-blue); @@ -633,6 +635,9 @@ textarea { .green-label { background: var(--sammo-texture-green); } +.budget-row > span { + align-content: safe center; +} .budget-row > span, .green-label, .policy-control, diff --git a/app/game-frontend/src/views/NpcControlView.vue b/app/game-frontend/src/views/NpcControlView.vue index 8312a7d9..196e7a7b 100644 --- a/app/game-frontend/src/views/NpcControlView.vue +++ b/app/game-frontend/src/views/NpcControlView.vue @@ -647,6 +647,7 @@ small { } .section_bar { + align-content: safe center; min-height: 23px; border: 0.5px solid #aaa; box-sizing: border-box; @@ -826,6 +827,7 @@ small { } .sub_bar { + align-content: safe center; height: 22px; margin: 0 5px; border: 0.5px solid #aaa; diff --git a/app/game-frontend/src/views/PastPlaysView.vue b/app/game-frontend/src/views/PastPlaysView.vue index fdedb7ca..56a10a08 100644 --- a/app/game-frontend/src/views/PastPlaysView.vue +++ b/app/game-frontend/src/views/PastPlaysView.vue @@ -661,6 +661,7 @@ th { .hall-battle-record dt, .hall-battle-record dd { + align-content: safe center; margin: 0; padding: 2px 5px; } diff --git a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts index e39ed0da..925a4ed2 100644 --- a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts +++ b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts @@ -1,3 +1,4 @@ +import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { mkdirSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; @@ -1050,3 +1051,17 @@ test('renders the same Ref season status on the public gateway page', async ({ p await expect(status).toHaveAttribute('title', '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00'); await expect(page.getByRole('button', { name: '현황 새로고침' })).toBeEnabled(); }); + +for (const width of [390, 500]) { + test(`모바일 로비 식별 정보와 동작 세로 정렬 ${width}px`, async ({ page }, testInfo) => { + await installFixture(page); + await page.setViewportSize({ width, height: 900 }); + await page.goto('lobby'); + await verifyVerticalAlignment(page, testInfo, [ + '.profile-server-cell', + '.profile-portrait-cell', + '.profile-general-cell', + '.profile-action-cell', + ]); + }); +} diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 5df0c276..355d0302 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -1028,6 +1028,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { border-bottom: 1px solid #3f3f46; } + .profile-server-cell, + .profile-portrait-cell, + .profile-general-cell, + .profile-action-cell { + align-content: safe center; + } + .profile-portrait-cell { grid-area: portrait; } diff --git a/docs/frontend-css-architecture.md b/docs/frontend-css-architecture.md index 812f9dee..85ee3bd3 100644 --- a/docs/frontend-css-architecture.md +++ b/docs/frontend-css-architecture.md @@ -3,7 +3,7 @@ The game frontend preserves the rendered contract of `ref/sam`; CSS reuse is not a reason to normalize a page's width, height, typography, texture, or interaction states. When reuse and the reference geometry conflict, the -reference geometry wins. +reference geometry wins unless an explicit Core UX policy below changes that contract. ## Layers @@ -210,3 +210,43 @@ rendering, including rem/em/% semantics. Zero-sized hidden markers and arbitrary user HTML remain content contracts. The betting page and bracket now reference the shared tier variables directly instead of the redundant `--bet-font-*` aliases. The historical deferral reasons above describe the original 12px proposal. + +## Text alignment on both axes (2026-09-29) + +Core intentionally centers compact UI text vertically when its cell has spare +height. Horizontal alignment remains independent: numerical values can stay +right-aligned and dates or field labels left-aligned. This policy does not change +game state, field order, font tiers, cell dimensions, or the meaning of Ref data. + +| Content | Vertical alignment | +| --- | --- | +| A short heading, status, button/link label, identity or scalar value | Center within its own cell when it fits | +| A short label that wraps on a narrow viewport | Center the complete label if it fits; allow natural height and preserve wrapping | +| A form label next to one input | Center against the input; if the field also has help/error text, align to the input rather than the entire help block | +| Prose, logs, notices, editor content, multi-paragraph detail, card collections | Preserve the reading start at the top | +| A table cell with text/numbers/actions | Use table-cell `vertical-align: middle`; retain explicit top alignment for narrative/detail cells | +| A column flex container | The vertical axis is `justify-content`, not `align-items` | + +Choose the smallest owner of the text. For a block or grid-item cell with inline +markup, `align-content: safe center` preserves inline wrapping, ellipsis and the +existing text-align. It also leaves overflowing content at the start. Do not +turn mixed inline text/links/tooltips into separate flex items to center a label. +For an existing row flex/grid layout use its appropriate axis; centering the +whole grid is different from centering the contents of each bordered cell. +Do not globally style every `td`, `span`, `.center` or user HTML. + +Avoid fixed-height line-height hacks and manual top padding to imitate centering. +Existing equal-height line boxes and native table cells need no redundant rewrite. +Keep raised-button face/edge geometry and hover/active bottom coordinates intact. +The shared legacy control uses block content alignment without changing display. +On the mobile Gateway lobby grid, center server identity, portrait, general and +action cells; the multi-line server information keeps its reading order. + +For new or changed UI, record the role and both alignment axes before choosing +CSS. Review shared components plus every rendered desktop/mobile variant. Verify +in Chromium after fonts/images load: actual text bounds, cell bounds, horizontal +alignment, wrapping/ellipsis, overflow and focus/hover/active/disabled behavior. +Test maximum names, narrow layouts and content that outgrows a cell. Preserve +original screenshots and DOM/computed-style measurements. The alignment checks +in the game typography/menu/NPC/command suites and Gateway lobby suite use mocked +API data and production bundles; they are not live-server or deployment proof. diff --git a/tools/frontend-legacy-parity/verticalAlignment.ts b/tools/frontend-legacy-parity/verticalAlignment.ts new file mode 100644 index 00000000..8f1920d1 --- /dev/null +++ b/tools/frontend-legacy-parity/verticalAlignment.ts @@ -0,0 +1,79 @@ +import { writeFile } from 'node:fs/promises'; +import { expect, type Page, type TestInfo } from '@playwright/test'; + +// Keep inline markup/ellipsis and cell borders in place. Measure text rather than +// assuming that a computed alignment declaration proves the rendered result. +export const verifyVerticalAlignment = async ( + page: Page, + testInfo: TestInfo, + selectors: string[], + kind: 'block' | 'table' = 'block' +): Promise => { + await page.waitForLoadState('networkidle'); + await page.evaluate(async () => { + await document.fonts.ready; + await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined))); + }); + for (const selector of selectors) await expect(page.locator(selector).first()).toBeVisible(); + const selector = selectors.join(', '); + const measure = () => + page.locator(selector).evaluateAll((elements) => + elements.flatMap((element) => { + const box = element.getBoundingClientRect(); + if (!box.width || !box.height) return []; + const style = getComputedStyle(element); + const range = document.createRange(); + range.selectNodeContents(element); + const text = range.getBoundingClientRect(); + const top = box.top + parseFloat(style.borderTopWidth) + parseFloat(style.paddingTop); + const bottom = box.bottom - parseFloat(style.borderBottomWidth) - parseFloat(style.paddingBottom); + return [ + { + className: element.className, + text: element.textContent?.trim(), + box: { x: box.x, y: box.y, width: box.width, height: box.height }, + alignContent: style.alignContent, + verticalAlign: style.verticalAlign, + textAlign: style.textAlign, + display: style.display, + font: style.font, + lineHeight: style.lineHeight, + offset: (text.top + text.bottom - top - bottom) / 2, + textHeight: text.height, + available: bottom - top, + html: element.outerHTML, + }, + ]; + }) + ); + const baseline = await page.addStyleTag({ + content: `${selector} { ${kind === 'table' ? 'vertical-align: top' : 'align-content: normal'} !important; }`, + }); + const before = await measure(); + await page.screenshot({ path: testInfo.outputPath('alignment-before.png'), fullPage: true }); + await baseline.evaluate((element) => element.parentNode?.removeChild(element)); + const after = await measure(); + await page.screenshot({ path: testInfo.outputPath('alignment-after.png'), fullPage: true }); + await writeFile( + testInfo.outputPath('alignment.json'), + JSON.stringify({ url: page.url(), viewport: page.viewportSize(), before, after }, null, 2) + ); + expect(after.length).toBeGreaterThan(0); + expect(after.length).toBe(before.length); + for (const [index, item] of after.entries()) { + const previous = before[index]!; + expect(item.box, item.className).toEqual(previous.box); + expect(item.textAlign, item.className).toBe(previous.textAlign); + expect(item.text, item.className).toBe(previous.text); + if (kind === 'table') expect(item.verticalAlign, item.className).toBe('middle'); + else expect(item.alignContent, item.className).toBe('safe center'); + if ( + item.textHeight > 0 && + item.textHeight <= item.available && + !['flex', 'inline-flex'].includes(item.display) + ) { + // Font ascenders/descenders need not have identical ink extents. + expect(Math.abs(item.offset), item.className).toBeLessThanOrEqual(2); + } + } +};