짧은 UI 텍스트의 세로 정렬과 두 축 정렬 원칙을 적용한다
This commit is contained in:
@@ -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을
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -310,6 +310,7 @@ small {
|
||||
text-align: center;
|
||||
}
|
||||
.recruitment-status > span {
|
||||
align-content: safe center;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.injured {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -481,6 +481,7 @@ const specialText = computed(() => {
|
||||
}
|
||||
|
||||
.general-basic-grid > * {
|
||||
align-content: safe center;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -142,6 +142,7 @@ watch(
|
||||
}
|
||||
|
||||
.activity-status .status-row {
|
||||
align-content: safe center;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
text-align: center;
|
||||
|
||||
@@ -23,6 +23,7 @@ defineProps<{
|
||||
}
|
||||
|
||||
.record-title {
|
||||
align-content: safe center;
|
||||
box-sizing: border-box;
|
||||
height: 23px;
|
||||
margin: 0;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -693,6 +693,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
align-content: safe center;
|
||||
height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -597,6 +597,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.payout-row > div {
|
||||
align-content: safe center;
|
||||
min-width: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -661,6 +661,7 @@ th {
|
||||
|
||||
.hall-battle-record dt,
|
||||
.hall-battle-record dd {
|
||||
align-content: safe center;
|
||||
margin: 0;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user