짧은 UI 텍스트의 세로 정렬과 두 축 정렬 원칙을 적용한다

This commit is contained in:
2026-09-29 08:51:05 +00:00
parent 295ecb39a3
commit 71155186b7
31 changed files with 293 additions and 3 deletions
+4
View File
@@ -126,6 +126,10 @@ UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.m
분석 편의를 위해 배포하는 산출물입니다. 보안상 비공개가 필요하다는 별도 요구와 분석 편의를 위해 배포하는 산출물입니다. 보안상 비공개가 필요하다는 별도 요구와
검토가 없는 한 번들 크기, build 시간 또는 일반적인 "최적화" 지시만으로 검토가 없는 한 번들 크기, build 시간 또는 일반적인 "최적화" 지시만으로
`build.sourcemap`을 끄거나 `.map` 파일을 배포 산출물에서 제외하지 말아 주세요. `build.sourcemap`을 끄거나 `.map` 파일을 배포 산출물에서 제외하지 말아 주세요.
- 짧은 제목·상태·버튼·정보 셀은 가로와 세로 정렬을 각각 결정합니다. 가로 가운데만
지정하고 끝내지 말고, 왼쪽/오른쪽 정렬 값도 세로 가운데가 적절한지 확인합니다.
여러 줄 본문·로그는 읽기 시작점을 보존하고, 좁은 화면의 줄바꿈과 overflow를
검증합니다. 구현 방법과 예외는 [두 축 텍스트 정렬](docs/frontend-css-architecture.md#text-alignment-on-both-axes-2026-09-29)을 따릅니다.
- 공통화는 동일한 렌더링 계약이 확인된 token/shell에 한합니다. `.error`, - 공통화는 동일한 렌더링 계약이 확인된 token/shell에 한합니다. `.error`,
`.stack`처럼 이름만 같은 page selector를 전역화하지 말아 주세요. `.stack`처럼 이름만 같은 page selector를 전역화하지 말아 주세요.
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을 - `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; 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); 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');
});
}
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { import {
@@ -5037,3 +5038,16 @@ for (const width of [1200, 390]) {
await expect.poll(() => JSON.stringify(requests)).toContain('"nationType":"che_덕가"'); 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']);
});
}
+10
View File
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; 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.getByText('문서 작성 권한은 군주/수뇌에게만 제공됩니다.')).toBeVisible();
await expect(page.getByRole('button', { name: '전송' })).toHaveCount(0); 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']);
});
}
+15
View File
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path'; import { basename, resolve } from 'node:path';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; 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 감찰 기록'); 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',
]);
});
}
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; 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 expect(amount).toHaveValue('100');
await page.screenshot({ path: testInfo.outputPath('nation-betting-amount-retained.png'), fullPage: true }); 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']);
});
}
@@ -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 { devices, expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; 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' }); 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']);
});
}
+10
View File
@@ -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 { devices, expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; 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.locator('#container')).toHaveCount(0);
await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible(); 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']);
});
}
+11
View File
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; 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.hallColumns.split(' ')).toHaveLength(3);
expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom); 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']);
});
}
@@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
// 기존 화면 fixture의 읽기 응답을 고정했다. 실제 계정이나 게임 서버에는 접근하지 않는다. // 기존 화면 fixture의 읽기 응답을 고정했다. 실제 계정이나 게임 서버에는 접근하지 않는다.
@@ -192,3 +193,29 @@ for (const width of [500, 1000]) {
await page.screenshot({ path: testInfo.outputPath('battle-log-exception.png'), fullPage: true }); 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);
});
}
}
@@ -39,6 +39,7 @@
} }
.game-shell__action { .game-shell__action {
align-content: safe center;
border: 1px solid var(--sammo-color-border); border: 1px solid var(--sammo-color-border);
padding: 6px 12px; padding: 6px 12px;
background: var(--sammo-color-action-bg); background: var(--sammo-color-action-bg);
@@ -1,4 +1,5 @@
.legacy-button { .legacy-button {
align-content: safe center;
display: inline-block; display: inline-block;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid var(--sammo-button-base1-border); border: 1px solid var(--sammo-button-base1-border);
@@ -310,6 +310,7 @@ small {
text-align: center; text-align: center;
} }
.recruitment-status > span { .recruitment-status > span {
align-content: safe center;
padding: 4px 2px; padding: 4px 2px;
} }
.injured { .injured {
@@ -157,6 +157,7 @@ const nationStyle = computed(() => {
.city-title, .city-title,
.city-nation { .city-nation {
align-content: safe center;
grid-column: 1 / -1; grid-column: 1 / -1;
min-width: 0; min-width: 0;
border-top: 1px solid gray; border-top: 1px solid gray;
@@ -481,6 +481,7 @@ const specialText = computed(() => {
} }
.general-basic-grid > * { .general-basic-grid > * {
align-content: safe center;
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -117,6 +117,7 @@ const killRate = computed(() => {
} }
.battle-general-extra > * { .battle-general-extra > * {
align-content: safe center;
min-height: 24px; min-height: 24px;
box-sizing: border-box; box-sizing: border-box;
border-right: 1px solid #777; border-right: 1px solid #777;
@@ -142,6 +142,7 @@ watch(
} }
.activity-status .status-row { .activity-status .status-row {
align-content: safe center;
padding-right: 0; padding-right: 0;
padding-left: 0; padding-left: 0;
text-align: center; text-align: center;
@@ -23,6 +23,7 @@ defineProps<{
} }
.record-title { .record-title {
align-content: safe center;
box-sizing: border-box; box-sizing: border-box;
height: 23px; height: 23px;
margin: 0; margin: 0;
@@ -114,6 +114,7 @@ const experiencePercent = computed(() =>
.stat-grid > *, .stat-grid > *,
.experience-row > *, .experience-row > *,
.dex-row > * { .dex-row > * {
align-content: safe center;
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
border-right: 1px solid #666; border-right: 1px solid #666;
@@ -1617,7 +1617,7 @@ button:disabled {
/* /*
* Ref renders this as a Bootstrap table: 7px padding on every side, a #444 * 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 * 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 th,
.summary-table td { .summary-table td {
@@ -1625,7 +1625,7 @@ button:disabled {
border-bottom: 1px solid #444; border-bottom: 1px solid #444;
box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0); box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0);
text-align: left; text-align: left;
vertical-align: top; vertical-align: middle;
} }
.summary-table th { .summary-table th {
@@ -693,6 +693,7 @@ onBeforeUnmount(() => {
} }
.panel-header { .panel-header {
align-content: safe center;
height: 18px; height: 18px;
text-align: center; text-align: center;
} }
+1
View File
@@ -1705,6 +1705,7 @@ small {
} }
.npc-token-status { .npc-token-status {
align-content: safe center;
min-height: 22px; min-height: 22px;
text-align: center; text-align: center;
font-size: var(--sammo-font-size-small); font-size: var(--sammo-font-size-small);
@@ -597,6 +597,7 @@ onMounted(() => {
} }
.payout-row > div { .payout-row > div {
align-content: safe center;
min-width: 0; min-width: 0;
padding: 0 4px; padding: 0 4px;
} }
@@ -677,6 +677,7 @@ select {
font: inherit; font: inherit;
} }
.nation-heading { .nation-heading {
align-content: safe center;
height: 32px; height: 32px;
text-align: center; text-align: center;
font-size: var(--sammo-font-size-title); font-size: var(--sammo-font-size-title);
@@ -530,6 +530,7 @@ textarea:focus-visible {
text-align: center; text-align: center;
} }
.diplomacy-row > div { .diplomacy-row > div {
align-content: safe center;
border-bottom: 1px solid gray; border-bottom: 1px solid gray;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -618,6 +619,7 @@ textarea {
width: 50%; width: 50%;
} }
.blue-heading { .blue-heading {
align-content: safe center;
height: 18.19px; height: 18.19px;
text-align: center; text-align: center;
background: var(--sammo-texture-blue); background: var(--sammo-texture-blue);
@@ -633,6 +635,9 @@ textarea {
.green-label { .green-label {
background: var(--sammo-texture-green); background: var(--sammo-texture-green);
} }
.budget-row > span {
align-content: safe center;
}
.budget-row > span, .budget-row > span,
.green-label, .green-label,
.policy-control, .policy-control,
@@ -647,6 +647,7 @@ small {
} }
.section_bar { .section_bar {
align-content: safe center;
min-height: 23px; min-height: 23px;
border: 0.5px solid #aaa; border: 0.5px solid #aaa;
box-sizing: border-box; box-sizing: border-box;
@@ -826,6 +827,7 @@ small {
} }
.sub_bar { .sub_bar {
align-content: safe center;
height: 22px; height: 22px;
margin: 0 5px; margin: 0 5px;
border: 0.5px solid #aaa; border: 0.5px solid #aaa;
@@ -661,6 +661,7 @@ th {
.hall-battle-record dt, .hall-battle-record dt,
.hall-battle-record dd { .hall-battle-record dd {
align-content: safe center;
margin: 0; margin: 0;
padding: 2px 5px; padding: 2px 5px;
} }
@@ -1,3 +1,4 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path'; 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(status).toHaveAttribute('title', '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00');
await expect(page.getByRole('button', { name: '현황 새로고침' })).toBeEnabled(); 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',
]);
});
}
@@ -1028,6 +1028,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
border-bottom: 1px solid #3f3f46; border-bottom: 1px solid #3f3f46;
} }
.profile-server-cell,
.profile-portrait-cell,
.profile-general-cell,
.profile-action-cell {
align-content: safe center;
}
.profile-portrait-cell { .profile-portrait-cell {
grid-area: portrait; grid-area: portrait;
} }
+41 -1
View File
@@ -3,7 +3,7 @@
The game frontend preserves the rendered contract of `ref/sam`; CSS reuse is 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 not a reason to normalize a page's width, height, typography, texture, or
interaction states. When reuse and the reference geometry conflict, the 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 ## 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 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 shared tier variables directly instead of the redundant `--bet-font-*` aliases.
The historical deferral reasons above describe the original 12px proposal. 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.
@@ -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<void> => {
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);
}
}
};