프로필 UI 글자 크기를 네 단계와 공통 축소 규칙으로 통일한다

This commit is contained in:
2026-09-29 09:41:39 +00:00
parent 5bafdfc1e7
commit 7c65cf60f3
43 changed files with 1347 additions and 299 deletions
@@ -1956,8 +1956,8 @@ test('shows all 12 advanced chief turns before the actions and uses the full mob
expect(geometry.frameWidth).toBe(500);
expect(geometry.rowWidth).toBe(500);
expect(geometry.rowEdges).toEqual([
{ top: 0, bottom: 155 },
{ top: 155, bottom: 310 },
{ top: 0, bottom: 224 },
{ top: 224, bottom: 448 },
]);
expect(geometry.gutterWidths).toEqual([12, 12]);
expect(geometry.gutterEdges).toEqual([
@@ -2910,5 +2910,126 @@
}
}
}
},
"best": {
"lobby.info": {
"result": {
"data": {
"year": 197,
"month": 7,
"userCnt": 17,
"npcCnt": 31,
"nationCnt": 5,
"maxUserCnt": 50,
"turnTerm": 60,
"fictionMode": "가상",
"otherTextInfo": "표준",
"starttime": "0190-01-01T00:00:00.000Z",
"myGeneral": {
"id": 1,
"name": "가나다라마바사아자",
"troopId": 0
}
}
}
},
"auth.status": {
"result": {
"data": {
"userId": "frontend-legacy-fixture-user"
}
}
},
"ranking.getBestGeneral": {
"result": {
"data": {
"isUnited": true,
"sections": [
{
"title": "명 성",
"valueType": "int",
"entries": [
{
"id": 1,
"name": "가나다라마바사아자",
"ownerName": "시각검증",
"nationName": "대한민국천하통일국",
"bgColor": "#006400",
"fgColor": "#ffffff",
"picture": "default.jpg",
"imageServer": 0,
"value": 12000,
"printValue": "12,000"
},
{
"id": 2,
"name": "가나다라마바사아자",
"ownerName": "검증계정",
"nationName": "대한민국천하통일국",
"bgColor": "#8b0000",
"fgColor": "#ffffff",
"picture": "default.jpg",
"imageServer": 0,
"value": 11000,
"printValue": "11,000"
}
]
},
{
"title": "계 급",
"valueType": "int",
"entries": []
}
],
"uniqueItems": [
{
"title": "명 마",
"slot": "horse",
"entries": [
{
"itemKey": "che_명마_15_적토마",
"itemName": "적토마",
"itemInfo": "최고의 명마",
"owner": {
"id": 1,
"name": "가나다라마바사아자",
"nationName": "대한민국천하통일국",
"bgColor": "#006400",
"fgColor": "#ffffff",
"picture": "default.jpg",
"imageServer": 0
}
},
{
"itemKey": "che_명마_15_적토마",
"itemName": "적토마",
"itemInfo": "최고의 명마",
"owner": {
"id": 0,
"name": "가나다라마바사아자",
"nationName": "대한민국천하통일국",
"bgColor": "#00582c",
"fgColor": "#ffffff",
"picture": null,
"imageServer": 0
}
}
]
}
]
}
}
}
},
"join": {
"lobby.info": "result",
"auth.status": {
"result": {
"data": {
"ok": true
}
}
},
"join.getConfig": "result"
}
}
+19 -7
View File
@@ -592,11 +592,17 @@ const install = async (page: Page, state: FixtureState) => {
if (type === 'generalHistory') {
return response({
type,
logs: Array.from({ length: 25 }, (_, index) => ({ id: 100 - index, text: `열전-${index}` })),
logs: Array.from({ length: 25 }, (_, index) => ({
id: 100 - index,
text: `열전-${index}`,
})),
});
}
if (type === 'generalAction') {
const firstPage = Array.from({ length: 24 }, (_, index) => ({ id: 50 - index, text: `행동-${index}` }));
const firstPage = Array.from({ length: 24 }, (_, index) => ({
id: 50 - index,
text: `행동-${index}`,
}));
return response({
type,
logs: jsonInput.beforeId ? [{ id: 26, text: '이전 행동' }] : firstPage,
@@ -1034,10 +1040,10 @@ test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상·
expect(geometry.battle.top).toBeCloseTo(geometry.line.top, 0);
expect(geometry.styles).toEqual({
diamondColor: 'rgb(135, 206, 235)',
namePlateFontSize: '10.5px',
namePlateFontSize: '12px',
nameCoverColor: 'rgb(255, 255, 0)',
crewPlateColor: 'rgb(255, 69, 0)',
crewPlateFontSize: '12.6px',
crewPlateFontSize: '12px',
defenseArrowColor: 'rgb(255, 0, 255)',
});
};
@@ -1317,7 +1323,7 @@ test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자
expect(measurements.map((entry) => entry.row.fontSize)).toEqual(['14px', '14px', '14px']);
expect(measurements.map((entry) => entry.row.lineHeight)).toEqual(['21px', '21px', '21px']);
expect(measurements.map((entry) => entry.row.height)).toEqual([21, 21, 21]);
expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12.6px', '12.6px', '12.6px']);
expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12px', '12px', '12px']);
expect(measurements.map((entry) => entry.timeSpanCount)).toEqual([1, 1, 1]);
expect(measurements[0]?.nameFontSize).toBe('14px');
expect(measurements[0]?.actionFontSize).toBe('14px');
@@ -1387,7 +1393,12 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
test('내 정보 열전은 전체 표시 후 종료하고 행동 기록만 다음 페이지를 요청한다', async ({ page }) => {
const state: FixtureState = {
permission: 'head', myset: 3, settingMutations: [], accessPages: [], pagedMyLogs: true, myLogInputs: [],
permission: 'head',
myset: 3,
settingMutations: [],
accessPages: [],
pagedMyLogs: true,
myLogInputs: [],
};
await install(page, state);
await page.goto('my-page');
@@ -1414,7 +1425,8 @@ test('내 정보 열전은 전체 표시 후 종료하고 행동 기록만 다
await expect(action.getByRole('button', { name: '이전 로그 불러오기' })).toHaveCount(0);
expect(state.myLogInputs?.filter((input) => input.type === 'generalHistory')).toHaveLength(1);
expect(state.myLogInputs?.filter((input) => input.type === 'generalAction')).toEqual([
{ type: 'generalAction' }, { type: 'generalAction', beforeId: 27 },
{ type: 'generalAction' },
{ type: 'generalAction', beforeId: 27 },
]);
});
+101 -13
View File
@@ -92,7 +92,7 @@ for (const width of [500, 1000]) {
await page.screenshot({ path: testInfo.outputPath('main-maximum-name.png'), fullPage: true });
});
test(`네 단계와 밀집 화면 보류 계약 ${width}px ${name.label}`, async ({ page }, testInfo) => {
test(`인사부도 공통 네 단계를 사용한다 ${width}px ${name.label}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await install(page, 'personnel', name.general, name.nation);
await page.goto('nation/personnel');
@@ -100,11 +100,7 @@ for (const width of [500, 1000]) {
await expect(page.locator('.chief-entry-copy strong').first()).toHaveText(name.general);
await waitForFonts(page);
await expect(page.locator('.nation-heading')).toHaveCSS('font-size', width === 500 ? '16px' : '24px');
// 500px의 15px 이름/10px 잠금은 확대 시 표시 이름이 줄어들어 보류했다.
await expect(page.locator('.chief-entry-copy strong').first()).toHaveCSS(
'font-size',
width === 500 ? '15px' : '16px'
);
await expect(page.locator('.chief-entry-copy strong').first()).toHaveCSS('font-size', '16px');
const geometry = await page.locator('#personnel-container').evaluate((element) => ({
width: element.getBoundingClientRect().width,
scrollWidth: element.scrollWidth,
@@ -123,17 +119,17 @@ for (const width of [500, 1000]) {
});
}
test(`사령부 12턴 요약의 기존 밀도를 유지한다 ${width}px`, async ({ page }, testInfo) => {
test(`사령부 12턴 요약은 12px와 겹치지 않는 행을 사용한다 ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await install(page, 'chief', names[0].general, names[0].nation);
await page.goto('chief-center');
const row = page.locator('.chief-overview .chief-card.compact .chief-row').first();
await expect(row).toBeVisible();
await waitForFonts(page);
await expect(row).toHaveCSS('font-size', '8.8px');
await expect(row).toHaveCSS('line-height', '11.25px');
await expect(page.locator('.chief-overview .compact-name').first()).toHaveCSS('font-size', '10.4px');
expect((await row.boundingBox())?.height).toBe(11.25);
await expect(row).toHaveCSS('font-size', '12px');
await expect(row).toHaveCSS('line-height', '16px');
await expect(page.locator('.chief-overview .compact-name').first()).toHaveCSS('font-size', '12px');
expect((await row.boundingBox())?.height).toBe(16);
await page.screenshot({ path: testInfo.outputPath('chief-deferred-density.png'), fullPage: true });
});
@@ -187,8 +183,8 @@ for (const width of [500, 1000]) {
const summary = page.locator('.small_war_log').first();
await expect(summary).toContainText('【경국지색소교】');
await expect(summary).toHaveCSS('font-size', '14px');
await expect(summary.locator('.name_plate').first()).toHaveCSS('font-size', '10.5px');
await expect(summary.locator('.crew_plate').first()).toHaveCSS('font-size', '12.6px');
await expect(summary.locator('.name_plate').first()).toHaveCSS('font-size', '12px');
await expect(summary.locator('.crew_plate').first()).toHaveCSS('font-size', '12px');
await waitForFonts(page);
await page.screenshot({ path: testInfo.outputPath('battle-log-exception.png'), fullPage: true });
});
@@ -219,3 +215,95 @@ for (const width of [500, 1000]) {
});
}
}
// Actual page CSS, including the reset and scoped component rules, must implement the ladder.
test('small은 부모 단계에서 내려가고 다섯 번째 크기에서 멈춘다', async ({ page }, testInfo) => {
await install(page, 'main', names[0].general, names[0].nation);
await page.goto('./');
await expect(page.locator('.game-shell__title')).toBeVisible();
const actual = await page.evaluate(() => {
const host = document.createElement('section');
host.id = 'typography-ladder-probe';
document.body.append(host);
const result = ['title', 'emphasis', 'normal', 'small'].map((tier) => {
const parent = document.createElement('div');
parent.className = `sammo-text-${tier}`;
parent.innerHTML = '본문<small>보조<span>상속</span><small>중첩<small>최소</small></small></small>';
host.append(parent);
return [parent, ...parent.querySelectorAll('small')].map((node) => getComputedStyle(node).fontSize);
});
host.remove();
return result;
});
expect(actual).toEqual([
['24px', '16px', '14px', '12px'],
['16px', '14px', '12px', '10px'],
['14px', '12px', '10px', '10px'],
['12px', '10px', '10px', '10px'],
]);
await testInfo.attach('small-ladder', { body: JSON.stringify(actual), contentType: 'application/json' });
});
for (const width of [500, 1000]) {
for (const general of ['가나다라', names[0].general, names[1].general]) {
test(`명장 이름은 12px를 최대로 칸에 맞게 축소한다 ${width}px ${general.length}자`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await install(page, 'best', general, names[0].nation);
await page.goto('best-general');
const label = page.locator('.hall-name .sammo-fit-text').first();
await expect(label).toHaveText(general);
await waitForFonts(page);
await expect(label).toHaveAttribute('data-font-fit-max', '12');
await expect(label).toHaveAttribute('title', general);
const measured = await label.evaluate((element) => {
const style = getComputedStyle(element);
return {
size: Number.parseFloat(style.fontSize),
overflow: style.overflow,
ellipsis: style.textOverflow,
};
});
expect(measured.size).toBeLessThanOrEqual(12);
expect(measured.size).toBeGreaterThanOrEqual(10);
if (general.length === 4) expect(measured.size).toBe(12);
if (general.length === 18) expect(measured.size).toBeLessThan(12);
expect(measured).toMatchObject({ overflow: 'hidden', ellipsis: 'ellipsis' });
await page.screenshot({ path: testInfo.outputPath('ranking-fitted-name.png'), fullPage: true });
await testInfo.attach('fitted-name', { body: JSON.stringify(measured), contentType: 'application/json' });
});
}
}
for (const width of [500, 1000]) {
test(`장수 생성의 보조 설명 class는 small 단계를 덮어쓰지 않는다 ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await install(page, 'join', names[0].general, names[0].nation);
await page.goto('join');
await expect(page.locator('.create-form')).toBeVisible();
await page.locator('.advanced-options > summary').click();
await waitForFonts(page);
const steps = await page
.locator('small')
.evaluateAll((elements) =>
elements
.filter((element) => element.checkVisibility())
.map((element) => ({
parent: getComputedStyle(element.parentElement!).fontSize,
size: getComputedStyle(element).fontSize,
}))
);
expect(steps.length).toBeGreaterThan(0);
const next: Record<string, string> = {
'24px': '16px',
'16px': '14px',
'14px': '12px',
'12px': '10px',
'10px': '10px',
};
for (const step of steps) expect(step.size).toBe(next[step.parent]);
await expect(page.locator('.primary-field small.muted')).toHaveCSS('font-size', '10px');
await testInfo.attach('join-small-steps', { body: JSON.stringify(steps), contentType: 'application/json' });
});
}
+3 -2
View File
@@ -1,6 +1,7 @@
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
@import 'tailwindcss';
@import './styles/tokens.css';
@import './styles/typography.css';
@import './styles/legacy-controls.css';
@import './styles/game-shell.css';
@import './styles/ref-shell.css';
@@ -144,7 +145,7 @@ input[inputmode='search'] {
}
.small_war_log .name_plate {
font-size: var(--sammo-font-size-war-name);
font-size: var(--sammo-font-size-small);
}
.small_war_log .name_plate_cover {
@@ -153,5 +154,5 @@ input[inputmode='search'] {
.small_war_log .crew_plate {
color: orangered;
font-size: var(--sammo-font-size-war-crew);
font-size: var(--sammo-font-size-small);
}
+7 -15
View File
@@ -1,22 +1,14 @@
:root {
/* 검증된 화면이 공유하는 네 단계. 콘텐츠 내부 축소/숨김은 별도 계약이다. */
--sammo-font-size-small: calc(var(--sammo-font-size-normal) * 12 / 14);
/* 모든 프로필 UI의 기본 네 단계. smaller는 바로 아래 단계이며 10px에서 멈춘다. */
--sammo-font-size-small: 12px;
--sammo-font-size-normal: 14px;
--sammo-font-size-emphasis: 16px;
--sammo-font-size-title: 24px;
/* 크기 변경을 보류한 밀집 UI와 콘텐츠의 상대 축소 계약. */
--sammo-font-size-nation-card: 12px;
--sammo-font-size-chief-compact-base: 0.75rem;
--sammo-font-size-chief-compact-row: 0.55rem;
--sammo-font-size-chief-compact-header: 0.65rem;
--sammo-font-size-chief-compact-name: 0.6rem;
--sammo-font-size-hall-name: 11px;
--sammo-font-size-hall-secondary: 95%;
--sammo-font-size-personnel-mobile-name: 15px;
--sammo-font-size-personnel-mobile-lock: 10px;
--sammo-font-size-bracket-bet-button: 11px;
--sammo-font-size-war-name: 0.75em;
--sammo-font-size-war-crew: 90%;
--sammo-font-size-minimum: 10px;
--sammo-font-size-smaller: calc(
clamp(var(--sammo-font-size-minimum), 1em - 2px, var(--sammo-font-size-normal)) +
clamp(0px, (1em - var(--sammo-font-size-emphasis)) / 4, 2px)
);
--sammo-font-sans: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
--sammo-color-text: #fff;
--sammo-color-text-muted: rgba(232, 221, 196, 0.7);
@@ -0,0 +1,32 @@
/* 페이지와 공통 컴포넌트가 같은 단계 이름을 사용한다. */
.sammo-text-title {
font-size: var(--sammo-font-size-title);
}
.sammo-text-emphasis {
font-size: var(--sammo-font-size-emphasis);
}
.sammo-text-normal {
font-size: var(--sammo-font-size-normal);
}
.sammo-text-small {
font-size: var(--sammo-font-size-small);
}
/* 부모의 단계에서 한 단계 아래. 12px의 보조 설명만 10px, 중첩해도 더 줄지 않는다. */
small,
sub,
sup,
.sammo-text-smaller,
.legacy-small {
font-size: var(--sammo-font-size-smaller);
}
/* v-fit-text measures from the inherited tier; an extreme name keeps its full title. */
.sammo-fit-text {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@@ -107,7 +107,7 @@ const handleClick = () => {
.chief-card.compact {
padding: 0;
font-size: var(--sammo-font-size-chief-compact-base);
font-size: var(--sammo-font-size-small);
}
.chief-header {
@@ -175,7 +175,7 @@ const handleClick = () => {
height: 13px;
min-height: 0;
padding: 0 2px;
font-size: var(--sammo-font-size-chief-compact-row);
font-size: var(--sammo-font-size-small);
line-height: 11px;
}
@@ -183,7 +183,7 @@ const handleClick = () => {
box-sizing: border-box;
height: 20px;
min-height: 0;
font-size: var(--sammo-font-size-chief-compact-header);
font-size: var(--sammo-font-size-small);
}
.compact-name,
@@ -210,7 +210,7 @@ const handleClick = () => {
.chief-card.compact .chief-level,
.chief-card.compact .chief-name {
font-size: var(--sammo-font-size-chief-compact-name);
font-size: var(--sammo-font-size-small);
}
.chief-row.rest {
@@ -270,11 +270,6 @@ watch(
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.recruitment-command-form {
box-sizing: border-box;
width: 100%;
@@ -383,7 +378,6 @@ small {
}
.crew-name small {
display: block;
font-size: var(--sammo-font-size-small);
}
.crew-row small {
display: none;
@@ -515,12 +509,8 @@ small {
}
.crew-row small {
display: block;
font-size: var(--sammo-font-size-small);
line-height: 1;
}
.crew-row .crew-name small {
font-size: var(--sammo-font-size-small);
}
.crew-header .attack,
.crew-header .defence,
.crew-header .speed,
@@ -913,11 +913,6 @@ const clickOutsideMenu = (event: Event) => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.reserved-command-editor {
position: relative;
width: 100%;
@@ -1177,7 +1172,6 @@ small {
visibility: visible;
}
.action-column small {
font-size: var(--sammo-font-size-small);
line-height: 1;
}
.reserved-command-editor.mobile .action-column > div.autonomous {
@@ -1379,7 +1373,7 @@ small {
min-width: 0;
margin: 0;
overflow: hidden;
font-size: 18pt;
font-size: var(--sammo-font-size-title);
font-weight: normal;
line-height: 1.2;
white-space: nowrap;
@@ -1388,7 +1382,6 @@ small {
.overlay-title small {
overflow: hidden;
color: #ffe0a0;
font-size: var(--sammo-font-size-small);
text-overflow: ellipsis;
}
.command-picker.argument-overlay .selected-command,
@@ -1457,7 +1450,7 @@ small {
.argument-overlay-bar > .overlay-title {
grid-column: 1 / -1;
grid-row: 1;
font-size: 14pt;
font-size: var(--sammo-font-size-emphasis);
}
}
@@ -355,11 +355,6 @@ const sortHelp = (header: Header): string =>
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.directory-table {
width: 1000px;
border-collapse: collapse;
@@ -631,7 +631,10 @@ watch(
>
- {{ row.name }}
</button>
<span>: <span class="nation-type-pros">{{ row.pros }}</span>,</span>
<span
>: <span class="nation-type-pros">{{ row.pros }}</span
>,</span
>
<span class="nation-type-cons">{{ row.cons }}</span>
</li>
</ul>
@@ -967,11 +970,6 @@ watch(
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.target-search {
grid-column: 1 / -1;
display: flex;
@@ -1186,7 +1184,7 @@ small {
display: grid;
place-items: center;
min-height: 2em;
font-size: 1.3em;
font-size: var(--sammo-font-size-emphasis);
text-align: center;
}
@@ -1467,7 +1465,6 @@ small {
}
.assignment-target-list .target-option small {
font-size: inherit;
line-height: 1.45;
}
@@ -174,11 +174,6 @@ const commandTitle = (command: CommandAvailability) =>
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.command-form {
display: flex;
flex-direction: column;
@@ -214,7 +214,7 @@ const displayChiefName = (chief: NationChief | undefined): string => {
min-width: 0;
height: 193px;
color: #fff;
font-size: var(--sammo-font-size-nation-card);
font-size: var(--sammo-font-size-small);
}
.nation-grid {
@@ -219,11 +219,6 @@ onBeforeUnmount(() => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.personnel-picker-backdrop {
position: fixed;
z-index: 2050;
@@ -437,7 +432,6 @@ small {
.personnel-picker-stats small,
.personnel-picker-details small {
color: #aaa99f;
font-size: var(--sammo-font-size-small);
}
.personnel-picker-stats strong {
color: #f1d47e;
@@ -22,6 +22,11 @@ let chart: Chart<'line'> | undefined;
const render = () => {
if (!canvas.value) return;
chart?.destroy();
const style = getComputedStyle(canvas.value);
const font = {
family: style.fontFamily,
size: Number.parseFloat(style.getPropertyValue('--sammo-font-size-small')),
};
chart = new Chart(canvas.value, {
type: 'line',
data: {
@@ -42,10 +47,13 @@ const render = () => {
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: '#e5eee9' } } },
plugins: {
legend: { labels: { color: '#e5eee9', font } },
tooltip: { titleFont: font, bodyFont: font, footerFont: font },
},
scales: {
x: { ticks: { color: '#c3d0c8', maxTicksLimit: 12 }, grid: { color: '#34473e' } },
y: { ticks: { color: '#c3d0c8' }, grid: { color: '#34473e' } },
x: { ticks: { color: '#c3d0c8', maxTicksLimit: 12, font }, grid: { color: '#34473e' } },
y: { ticks: { color: '#c3d0c8', font }, grid: { color: '#34473e' } },
},
},
});
@@ -342,7 +342,7 @@ const mobilePairs = computed(() => {
border-radius: 3px;
color: #fff3cd;
background: #59400e;
font: 700 var(--sammo-font-size-bracket-bet-button)/1 var(--sammo-font-sans);
font: 700 var(--sammo-font-size-small)/1 var(--sammo-font-sans);
cursor: pointer;
}
.bracket-bet-button:hover,
@@ -228,7 +228,7 @@ onBeforeUnmount(() => editor.value?.destroy());
<span class="legacy-html-editor__sr-only">크기</span>
<select aria-label="글꼴 크기" @change="setFontSize">
<option value="">크기</option>
<option v-for="size in fontSizes" :key="size" :value="size" :style="{ fontSize: size }">
<option v-for="size in fontSizes" :key="size" :value="size">
{{ size }}
</option>
</select>
@@ -0,0 +1,46 @@
import type { ObjectDirective } from 'vue';
type FitState = { observer: ResizeObserver; frame: number; alive: boolean };
const states = new WeakMap<HTMLElement, FitState>();
const fit = (element: HTMLElement): void => {
// Always measure from the CSS tier, so shorter text and wider cells can grow back.
element.style.removeProperty('font-size');
const maximum = Number.parseFloat(getComputedStyle(element).fontSize);
const available = element.clientWidth;
if (!available) return;
const range = document.createRange();
range.selectNodeContents(element);
const natural = range.getBoundingClientRect().width;
const minimum = Number.parseFloat(getComputedStyle(element).getPropertyValue('--sammo-font-size-minimum'));
const size = Math.min(maximum, Math.max(minimum, (maximum * available) / Math.max(natural, 1)));
element.dataset.fontFitMax = String(maximum);
if (size < maximum) element.style.fontSize = `${Math.floor(size * 100) / 100}px`;
};
const schedule = (element: HTMLElement): void => {
const state = states.get(element);
if (!state?.alive) return;
cancelAnimationFrame(state.frame);
state.frame = requestAnimationFrame(() => fit(element));
};
/** One-line UI labels: CSS selects the maximum tier; 10px is the readability floor. */
export const vFitText: ObjectDirective<HTMLElement> = {
mounted(element) {
const observer = new ResizeObserver(() => schedule(element));
states.set(element, { observer, frame: 0, alive: true });
observer.observe(element);
schedule(element);
void document.fonts.ready.then(() => schedule(element));
},
updated: schedule,
beforeUnmount(element) {
const state = states.get(element);
if (!state) return;
state.alive = false;
cancelAnimationFrame(state.frame);
state.observer.disconnect();
states.delete(element);
},
};
+1 -1
View File
@@ -1,3 +1,3 @@
import { formatLegacyLogHtml } from '@sammo-ts/common/logging/formatLegacyLogHtml';
export const formatLog = (text?: string): string => formatLegacyLogHtml(text);
export const formatLog = (text?: string): string => formatLegacyLogHtml(text, { smallText: 'semantic' });
+17 -14
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { vFitText } from '../directives/fitText';
import { usePageExit } from '../composables/usePageExit';
import { onMounted, ref, watch } from 'vue';
@@ -117,11 +118,18 @@ watch(viewMode, () => {
@error="useDefaultGeneralIcon"
/>
</div>
<div class="hall-nation" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
<div
v-fit-text
class="hall-nation sammo-fit-text"
:title="entry.nationName || '-'"
:style="{ backgroundColor: entry.bgColor, color: entry.fgColor }"
>
{{ entry.nationName || '-' }}
</div>
<div class="hall-name" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
<span>{{ entry.name || '-' }}</span>
<span v-fit-text class="sammo-fit-text" :title="entry.name || '-'">{{
entry.name || '-'
}}</span>
<small v-if="entry.ownerName">({{ entry.ownerName }})</small>
</div>
<div class="hall-value">{{ entry.printValue }}</div>
@@ -152,7 +160,9 @@ watch(viewMode, () => {
/>
</div>
<div
class="hall-nation"
v-fit-text
class="hall-nation sammo-fit-text"
:title="entry.owner.nationName || '-'"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
{{ entry.owner.nationName || '-' }}
@@ -161,7 +171,9 @@ watch(viewMode, () => {
class="hall-name"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
<span>{{ entry.owner.name || '-' }}</span>
<span v-fit-text class="sammo-fit-text" :title="entry.owner.name || '-'">{{
entry.owner.name || '-'
}}</span>
</div>
</li>
</ul>
@@ -179,11 +191,6 @@ watch(viewMode, () => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
:global(body:has(.legacy-ranking-page)) {
min-width: 500px;
overflow-x: hidden;
@@ -346,7 +353,7 @@ small {
.hall-nation,
.hall-name {
font-size: var(--sammo-font-size-hall-name);
font-size: var(--sammo-font-size-small);
}
.hall-name {
@@ -356,10 +363,6 @@ small {
justify-content: center;
}
.hall-name small {
font-size: var(--sammo-font-size-hall-secondary);
}
.hall-value {
box-sizing: border-box;
padding: 3px 0;
@@ -281,11 +281,6 @@ const placeBet = async (target: TournamentBracketSlot) => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.betting-page {
width: 100%;
max-width: 1200px;
@@ -347,7 +342,6 @@ small {
}
.section-title small {
color: orange;
font-size: var(--sammo-font-size-normal);
}
select {
width: 100%;
@@ -552,9 +546,6 @@ select:disabled {
white-space: normal;
}
}
.betting-page small {
font-size: var(--sammo-font-size-small);
}
.bet-preset {
position: relative;
display: grid;
+17 -17
View File
@@ -796,27 +796,27 @@ const repeatTurns = async (amount: number) => {
}
.chief-overview-frame {
width: 500px;
height: 310px;
height: 448px;
margin-top: 0;
margin-bottom: 11px;
overflow: hidden;
}
.chief-overview {
width: 500px;
height: 310px;
height: 448px;
margin-top: 0;
display: flex;
flex-direction: column;
}
.chief-overview-row {
width: 500px;
height: 155px;
height: 224px;
display: grid;
grid-template-columns: 12px repeat(4, 119px) 12px;
}
.chief-overview :deep(.chief-card) {
width: 119px;
height: 155px;
height: 224px;
border: 0;
border-left: 1px solid #fff;
box-shadow: none;
@@ -827,26 +827,26 @@ const repeatTurns = async (amount: number) => {
}
.chief-overview :deep(.chief-card.compact .chief-row) {
grid-template-columns: 38px minmax(0, 1fr);
height: 11.25px !important;
height: 16px !important;
min-height: 0;
padding: 0;
gap: 0;
text-align: center;
font-size: var(--sammo-font-size-chief-compact-row);
line-height: 11.25px !important;
font-size: var(--sammo-font-size-small);
line-height: 16px !important;
}
.chief-overview :deep(.chief-card.compact .chief-header) {
box-sizing: border-box;
height: 20px !important;
min-height: 20px !important;
height: 32px !important;
min-height: 32px !important;
grid-template-columns: 1fr;
grid-template-rows: 10px 10px;
line-height: 10px;
grid-template-rows: 16px 16px;
line-height: 16px;
}
.chief-overview :deep(.compact-name),
.chief-overview :deep(.compact-meta) {
height: 10px;
line-height: 10px;
height: 16px;
line-height: 16px;
}
.chief-overview :deep(.row-time),
.chief-overview :deep(.row-action) {
@@ -855,12 +855,12 @@ const repeatTurns = async (amount: number) => {
}
.overview-turn-index {
display: grid;
grid-template-rows: 20px repeat(12, 11.25px);
grid-template-rows: 32px repeat(12, 16px);
width: 12px;
height: 155px;
height: 224px;
color: #fff;
font-size: var(--sammo-font-size-chief-compact-row);
line-height: 11.25px;
font-size: var(--sammo-font-size-small);
line-height: 16px;
text-align: center;
}
.overview-turn-index span {
@@ -133,11 +133,6 @@ onMounted(() => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.directory-page {
width: 1000px;
margin: 0 auto;
+11 -12
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { vFitText } from '../directives/fitText';
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref, watch } from 'vue';
@@ -169,11 +170,18 @@ onMounted(loadOptions);
>
{{ entry.serverName }}{{ entry.serverIdx }}기
</div>
<div class="hall-nation" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
<div
v-fit-text
class="hall-nation sammo-fit-text"
:title="entry.nationName || '-'"
:style="{ backgroundColor: entry.bgColor, color: entry.fgColor }"
>
{{ entry.nationName || '-' }}
</div>
<div class="hall-name" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
<span>{{ entry.name || '-' }}</span>
<span v-fit-text class="sammo-fit-text" :title="entry.name || '-'">{{
entry.name || '-'
}}</span>
<small v-if="entry.ownerName">({{ entry.ownerName }})</small>
</div>
<div class="hall-value">{{ entry.printValue }}</div>
@@ -193,11 +201,6 @@ onMounted(loadOptions);
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
/* Ref's document does not clip; the page column stays 500px wide instead. */
:global(body:has(.legacy-hall-page)) {
min-width: 500px;
@@ -349,7 +352,7 @@ small {
.hall-server,
.hall-nation,
.hall-name {
font-size: var(--sammo-font-size-hall-name);
font-size: var(--sammo-font-size-small);
}
.hall-name {
@@ -359,10 +362,6 @@ small {
justify-content: center;
}
.hall-name small {
font-size: var(--sammo-font-size-hall-secondary);
}
.hall-value {
box-sizing: border-box;
padding: 3px 0;
@@ -787,11 +787,6 @@ onMounted(() => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.top-back-bar {
width: min(100%, 1000px);
height: 32px;
+3 -10
View File
@@ -1270,11 +1270,6 @@ onMounted(() => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.join-page {
min-height: 100vh;
padding: 24px;
@@ -1519,7 +1514,6 @@ small {
.advanced-title small {
color: #ccc;
font-size: var(--sammo-font-size-small);
font-weight: 400;
}
@@ -1871,10 +1865,6 @@ small {
max-width: none;
}
.npc-general-name small {
font-size: var(--sammo-font-size-small);
}
#btn-print-more-generals {
width: 100%;
min-height: 28px;
@@ -1883,6 +1873,9 @@ small {
.muted {
color: rgba(232, 221, 196, 0.6);
}
.muted:not(small) {
font-size: var(--sammo-font-size-small);
}
@@ -314,11 +314,6 @@ onBeforeUnmount(() => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.legacy-page {
box-sizing: border-box;
width: 100%;
@@ -1015,11 +1015,6 @@ onBeforeUnmount(() => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.general-page {
width: 1000px;
min-width: 1000px;
@@ -422,11 +422,6 @@ onBeforeUnmount(() => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.directory-page {
width: 1000px;
margin: 0 auto;
@@ -609,11 +609,6 @@ onMounted(() => void loadPersonnel());
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.legacy-office {
width: 1000px;
min-height: 100vh;
@@ -758,7 +753,6 @@ select {
.chief-entry-copy small {
overflow: hidden;
color: #c8c5bc;
font-size: var(--sammo-font-size-small);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -855,9 +849,6 @@ select {
.city-identity strong {
display: block;
}
.city-identity small {
font-size: var(--sammo-font-size-small);
}
.city-officer-cell {
padding: 5px !important;
vertical-align: middle;
@@ -880,7 +871,6 @@ select {
}
.city-officer-copy small {
color: #c9c6bd;
font-size: var(--sammo-font-size-small);
}
.city-change-button {
min-width: 70px;
@@ -949,10 +939,7 @@ select {
height: 48px;
}
.chief-entry-copy strong {
font-size: var(--sammo-font-size-personnel-mobile-name);
}
.chief-entry-copy small {
font-size: var(--sammo-font-size-small);
font-size: var(--sammo-font-size-emphasis);
}
.personnel-change-button {
min-height: 30px;
@@ -961,7 +948,7 @@ select {
line-height: 18px;
}
.personnel-lock-label {
font-size: var(--sammo-font-size-personnel-mobile-lock);
font-size: var(--sammo-font-size-smaller);
}
.award-label {
font-size: var(--sammo-font-size-small);
@@ -995,9 +982,6 @@ select {
width: 16%;
font-size: var(--sammo-font-size-small);
}
.city-identity small {
font-size: var(--sammo-font-size-small);
}
.city-officer-cell {
padding: 4px !important;
}
@@ -1008,9 +992,6 @@ select {
.city-officer-copy strong {
font-size: var(--sammo-font-size-small);
}
.city-officer-copy small {
font-size: var(--sammo-font-size-small);
}
.city-change-button {
width: 100%;
min-width: 0;
+2 -4
View File
@@ -2,9 +2,7 @@
<template>
<main class="min-h-screen px-6 py-8">
<h1 class="text-2xl font-semibold">Not Found</h1>
<p class="mt-2 text-sm text-amber-200/80">
The requested page does not exist.
</p>
<h1 class="sammo-text-title font-semibold">Not Found</h1>
<p class="mt-2 sammo-text-normal text-amber-200/80">The requested page does not exist.</p>
</main>
</template>
@@ -555,11 +555,6 @@ const submitPriority = async (section: PrioritySectionKey) => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
/*
* The document contract belongs to this page only. An unscoped html/body rule
* here leaked a 21px line-height and a 500px min-width onto every other screen.
@@ -813,7 +808,6 @@ small {
}
.priority-description small {
font-size: var(--sammo-font-size-small);
line-height: 18.375px;
}
@@ -363,11 +363,6 @@ const start = async () => {
</template>
<style scoped>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.legacy-page {
width: 100%;
max-width: 1200px;
@@ -57,3 +57,19 @@ void describe('game content font contract', () => {
assert.deepEqual(violations, []);
});
});
void describe('game typography size contract', () => {
void it('keeps page and component size declarations on shared tiers', async () => {
const violations: string[] = [];
for (const file of await listStyleSources()) {
const source = await readFile(file, 'utf8');
for (const match of source.matchAll(/\bfont-size\s*:\s*([^;]+);/g)) {
const value = match[1]!.trim();
if (value === '0' || value === 'inherit') continue;
if (/^var\(--sammo-font-size-(?:title|emphasis|normal|small|smaller)\)$/.test(value)) continue;
violations.push(`${path.relative(sourceRoot, file)}: ${value}`);
}
}
assert.deepEqual(violations, []);
});
});
+23 -82
View File
@@ -132,84 +132,25 @@ The CSS variables contain `/image/game/*` URLs but do not import or copy image
files. Caddy continues to own `/image/*`; Vite must not rewrite the image tree
as application assets.
## Game typography tiers
The game UI uses four starting sizes from `assets/styles/tokens.css`:
All profile pages share the four sizes in `assets/styles/tokens.css`, applied by
page styles and `assets/styles/typography.css`. Gateway is outside this policy.
This is an intentional Core UX decision, expanded on 2026-09-29 from the initial
2026-09-15 policy. See [the typography contract](frontend-typography.md) for the
step-down rule, measured fitting, content exceptions, and browser audit command.
| Token suffix (`--sammo-font-size-`) | CSS size | Role |
| --- | --- | --- |
| `small` | `normal × 12 / 14` (12px) | Compact controls, metadata, secondary labels |
| `normal` | 14px | Body, tables, ordinary controls |
| `emphasis` | 16px | Emphasized names and section labels |
| `title` | 24px | Page and major section headings |
| Token suffix (`--sammo-font-size-`) | Size | Shared class |
| ----------------------------------- | ---- | --------------------- |
| `title` | 24px | `sammo-text-title` |
| `emphasis` | 16px | `sammo-text-emphasis` |
| `normal` | 14px | `sammo-text-normal` |
| `small` | 12px | `sammo-text-small` |
This is an intentional Core UX policy adopted on 2026-09-15. It is a starting
policy for verified UI, not a claim that every rendered glyph has one of four
computed sizes. Existing geometry and media queries remain owned by each page.
A breakpoint can choose another tier (for example, the personnel nation heading
uses 16px on its narrow layout and 24px on its wide layout).
Use the tokens for new UI. Do not round arbitrary content or all remaining
relative sizes automatically. Before moving an existing label to a tier, compare
actual Chromium screenshots and text geometry at 500px and 1000px, with the
same font, data, DPR and zoom. Also check the real mobile viewport modes: a
390px-wide device scales a 500px layout to approximately 78% and a 1000px layout
to 39%; these do not change the CSS token values. A page with a fixed 1000px
minimum width can still render at about 39% in 500px mode because Chromium
fits the overflowing content. Record `visualViewport.scale` for each route;
do not infer every route's scale from the selected mode alone.
### Preserved exceptions
- Battle log `.small_war_log .name_plate` remains `0.75em`, and `.crew_plate`
remains `90%`. Legacy inline `0.9em` conversions and zero-sized hidden markers
remain unchanged. At a 14px parent these are 10.5px and 12.6px respectively;
nested content must be calculated from its actual parent.
- NPC possession names retain their existing length rule: 4 and 7 characters
render at 16px, and the existing `length >= 9` branch renders at 12px. Do not
remove the smaller branch or enlarge long names to match adjacent labels.
- Player HTML, editor size choices, and personal CSS remain content/user settings.
Scoped `small` rules normalize UI metadata without rewriting injected HTML.
### Deferred conversions
| Area | Preserved size | Reason |
| --- | --- | --- |
| Chief overview compact rows and turn indices | `0.55rem` (8.8px) | 12px text overlaps the existing 11.25px rows |
| Chief compact header/name | `0.65rem` / `0.6rem` | Preserve the same dense card contract |
| Narrow personnel chief name / lock label | 15px / 10px | Enlarging them reduces visible maximum-length names |
| Best generals / hall name and nation cells | 11px; inner `small` 95% | 12px worsens overflow in the fixed name cells |
| General selection and main nation basic card | Existing local/inherited sizes | Maximum-width names already overflow; conversion is deferred |
User-created general names and founded/renamed nation names are limited by
legacy width 18 (CJK counts 2, ASCII counts 1): test both 9 CJK characters and
18 wide ASCII characters. Selection-pool and scenario names have a different
source contract and are not proven bounded by that user-input guard.
Existing ellipsis, horizontal scrolling, and pre-existing maximum-name overflow
are not fixed by these tokens. The deferred areas need a separate layout decision
before their fonts are enlarged. The `typographyPolicy.spec.ts` fixture tests
protect the safe main labels, personnel breakpoints, dense chief rows, NPC
length branches, and battle log ratios. They use mocked read responses and do
not constitute live game or public deployment verification.
### Relative small tier and exception variables (2026-09-15)
`small` is `calc(var(--sammo-font-size-normal) * 12 / 14)`. The reference is the
14px normal tier, not the immediate parent. An 80% declaration on nested `small`
elements would shrink repeatedly and would yield 12.8px inside a 16px parent;
the token keeps UI metadata at 12px regardless of nesting.
The retained exceptions are also named in `tokens.css`: `nation-card`,
`chief-compact-{base,row,header,name}`, `hall-{name,secondary}`,
`personnel-mobile-{name,lock}`, `bracket-bet-button`, and `war-{name,crew}`
(all with the `--sammo-font-size-` prefix). Their values preserve the previous
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.
`small`, `sub`, `sup`, and semantic log annotations move down one tier:
24 → 16 → 14 → 12 → 10px. The last size is only for subordinate text inside the
smallest tier; repeated nesting stops at 10px. Do not reintroduce scoped `small`
resets, per-page exception tokens, or implicit percentage reductions.
## Text alignment on both axes (2026-09-29)
@@ -218,14 +159,14 @@ 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` |
| 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
+69
View File
@@ -0,0 +1,69 @@
# 전체 프로필 페이지 typography audit
실제 Chromium에서 router의 모든 페이지와 주요 열린 상태를 조회한다. 기존 E2E의
합성 fixture를 재사용하며 실제 게임 API와 계정에는 접속하지 않는다.
`load-fixtures.mjs`는 TypeScript compiler로 fixture 선언만 추출한다. test 등록,
`beforeEach`, 테스트 본문은 실행하지 않는다. fixture 구조가 바뀌면 audit도 함께
수정하고, 빈 화면이나 오류 화면을 정상 페이지 근거로 삼지 않는다.
## 실행
Core root에서 Node 24와 설치된 Playwright Chromium을 사용한다. prefix에 맞게
production frontend를 build하고 preview를 먼저 띄운다. frontend 준비는
[테스트 정책](testing-policy.md)을 따른다.
```sh
VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc \
VITE_GAME_PROFILE=che:default pnpm --filter @sammo-ts/game-frontend build
VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc \
VITE_GAME_PROFILE=che:default pnpm --filter @sammo-ts/game-frontend preview --host 127.0.0.1 --port 15331
```
다른 terminal에서:
```sh
CAPTURE_ORIGIN=http://127.0.0.1:15331 PHASE=che-cjk \
node tools/typography-audit/capture.mjs
CAPTURE_ORIGIN=http://127.0.0.1:15331 PHASE=che-ascii NAME_KIND=ascii \
node tools/typography-audit/capture.mjs
CAPTURE_ORIGIN=http://127.0.0.1:15331 PHASE=che-mobile MOBILE=1 \
node tools/typography-audit/capture.mjs
```
HWE는 build의 세 변수를 `/hwe`, `/hwe/api/trpc`, `hwe:default`로 바꾸고,
audit에도 `PLAYWRIGHT_GAME_BASE_PATH=hwe PLAYWRIGHT_GAME_PROFILE=hwe:default`를
지정한다. 같은 dist를 쓰는 profile build는 직렬로 실행한다. 활성 공개 profile을
새로 배포하거나 서버 설정을 바꾸는 명령이 아니다.
| 환경 변수 | 의미 |
| ---------------------------- | ----------------------------------------------------------------- |
| `CAPTURE_ORIGIN` | 이미 실행 중인 frontend origin |
| `PHASE` | 결과 하위 디렉터리 이름 |
| `TYPOGRAPHY_OUTPUT_DIR` | 기본 `test-results/typography-audit` |
| `TYPOGRAPHY_FONT_ROOT` | 선택적 Pretendard cache: `source.css`와 CSS가 참조하는 woff2 파일 |
| `FRONTEND_PARITY_IMAGE_ROOT` | 기본 workspace의 `image/` |
| `NAME_KIND` | 기본 `cjk`, 또는 `ascii`, `short4`, `mid7` |
| `WIDTHS` | 기본 `500,1000` CSS px |
| `MOBILE` | `1`이면 실제 390×844 touch 기기에서 500/1000 화면 모드 |
| `SCENES` | 쉼표로 나눈 일부 scene만 실행; 전체 페이지 검증으로 보고하지 않음 |
각 실행은 현재 router의 `path` 목록과 전체 scene 목록의 대응을 먼저 검사한다.
font 로딩 실패, fixture/page 오류, 설명할 수 없는 computed 크기는 실패로 기록한다.
자동 축소는 `data-font-fit-max`와 10px 하한을 함께 검사한다. 원본 screenshot,
DOM, computed style, text range, viewport scale, 이미지 natural size/object-fit,
canvas font와 합성 응답이 scene별 파일로 남는다. 실패 manifest와 성공 수를 따로
확인한다. snapshot 개수만으로 성공이라고 판단하지 않는다.
`clipped`는 overflow를 가진 조상의 경계를 검사한다. overflow가 visible인 글자가
옆 칸을 침범하는 경우까지 자동 판정하지 못하므로 이름의 range와 element bounds,
screenshot도 비교한다. 긴 반각 이름의 기존 문제를 숨기려고 glyph mask를 늘리지
않는다. 별도의 Playwright interaction suite로 dropdown, dialog, hover/focus,
active, disabled 상태를 확인한다.
## 검증 경계
이 도구는 fixture production Chromium의 화면 근거다. 운영 DB/계정, 공개 HTTPS,
개인 CSS나 임의의 저장 HTML 전체를 검사했다는 뜻이 아니다. 감사 화면의 tab,
국가 회의실/기밀실, 동적 왕조 상세, 명령·인사 선택창을 별도 scene으로 포함한다.
새 사용자 페이지가 추가되면 router coverage 오류를 해결하며 scene과 fixture를
추가한다. 생성물은 Git에 넣지 않는다.
+68
View File
@@ -0,0 +1,68 @@
# 프로필 UI 글자 크기
Gateway를 제외한 모든 게임 profile의 공통 정책이다. 2026-09-29 사용자 요청에
따른 의도적 Core UX 차이이며 Ref의 작은 글자를 그대로 유지하는 계약을 대체한다.
권한, 게임 데이터, 로그 내용·색상, 이미지와 사용자 작성 HTML 저장값은 바꾸지 않는다.
## 기본 단계와 작은 설명
| 역할 | 토큰 / 공통 class | 최대 기본 크기 |
| ------------------ | ---------------------------------- | -------------- |
| 큰 제목 | `title` / `sammo-text-title` | 24px |
| 강조, 소제목 | `emphasis` / `sammo-text-emphasis` | 16px |
| 본문, 일반 조작 | `normal` / `sammo-text-normal` | 14px |
| 보조 정보, 밀집 표 | `small` / `sammo-text-small` | 12px |
토큰 접두사는 `--sammo-font-size-`이다. 화면 selector도 같은 토큰을 쓴다.
반응형 breakpoint는 위 네 단계 중 하나를 선택한다.
공통 `typography.css`는 `small`, `sub`, `sup`, `.sammo-text-smaller`, 게임 로그의
`.legacy-small`에 바로 아래 단계를 적용한다. 24→16→14→12px 순서이며 12px 안의
작은 설명만 10px를 허용한다. 중첩해도 10px 아래로 내려가지 않는다. 일반 본문이나
조작 버튼에 다섯 번째 단계를 직접 지정하지 않는다. 개별 화면의 `small` reset은
추가하지 않는다. 로그 formatter의 semantic 옵션은 game frontend에서만 사용하므로
Gateway 등 다른 소비자의 기존 상대 크기는 유지된다.
사령부 12턴 요약은 12px, 행 높이 16px를 사용한다. 기존 11.25px 행에서 글자만
키우면 겹치므로 두 줄 header는 32px, 카드 224px, 전체 요약 448px로 함께 조정했다.
인사부 모바일 이름은 16px, 명장·명예의 전당 이름과 토너먼트 작은 조작은 12px다.
전투 요약의 이름·병력도 12px로 표시하며 의미와 색상은 유지한다.
## 칸에 맞춘 축소
자동 축소의 최대값도 위 네 단계 중 하나여야 한다. 짧은 글자를 임의의 크기로
확대하지 않는다. 기존 빙의 이름은 16px를 기본으로 긴 이름을 12px로 낮춘다.
명장·명예의 전당의 고정 폭 이름칸은 공통 `vFitText` directive와
`.sammo-fit-text`를 사용한다. CSS의 12px에서 실제 로드된 폰트의 text range를
측정하고 칸보다 길 때만 축소한다. 너비·텍스트·font 로딩이 바뀌면 최대 단계부터
다시 계산하므로 짧아진 이름은 원래 크기를 회복한다. 10px에서도 넘치는 극단적인
반각 이름은 말줄임하며 원문을 DOM과 `title`에 남긴다. 이 경우 10~12px 사이의
실측값은 고정 예외 크기가 아니라 네 단계에 상한을 둔 자동 축소 결과다.
## 별도 콘텐츠 경계
- 국가 소개·임관 권유문 등 사용자 작성 HTML의 명시적 크기와 개인 CSS는 사용자
설정이다. 저장된 HTML이나 editor의 글자 크기 선택지는 변환하지 않는다.
editor의 선택지 UI 자체는 일반 크기를 상속한다.
- 위 HTML을 미리 보여 주는 축소 preview의 transform과 사용자가 고른 화면 배율은
글자 크기 토큰과 별개다. audit에서 computed 크기와 viewport scale을 함께 기록한다.
- 숨김·복사용 marker는 0px를 유지한다. 화면 본문 단계로 세지 않는다.
- Canvas 차트의 축·범례·tooltip도 공통 small 토큰과 화면 font family를 사용한다.
새 고정 크기 예외는 화면 목적과 geometry 근거, 후속 설계 필요성을 문서화한다.
단순히 칸이 좁다는 이유로 11px, 15px 같은 별도 토큰을 만들지 않는다.
## 검증
`typographyPolicy.spec.ts`는 production Chromium에서 실제 폰트 로딩, 단계별 중첩,
최대 이름, 사령부 행 높이, 자동 축소 상한과 말줄임, 로그 크기를 확인한다.
전체 페이지 audit는 [audit 실행 안내](frontend-typography-audit.md)를 따른다.
router의 모든 선언을 scene 목록과 대조하고, 각 scene의 screenshot·DOM·computed
style·text range·이미지 크기와 fixture 응답을 남긴다. 500/1000px와 실제 390px
기기의 화면 모드를 별도로 실행한다. 9개 전각·18개 반각 이름을 사용한다.
API 응답을 고정한 frontend 검증이다. 운영 DB, 실제 계정의 임의 HTML, 공개 HTTPS나
배포 성공의 증거로 확대하지 않는다. 값이 바뀔 수 있는 예외는 고정 토큰으로
위장하지 않고 생성 이유와 상한을 함께 검사한다.
@@ -65,6 +65,8 @@ const normalizeStructuralTag = (tag: string): string | null => {
export type FormatLegacyLogHtmlOptions = {
colorize?: boolean;
/** Game UI can use its shared step-down class; other consumers retain the legacy ratio. */
smallText?: 'relative' | 'semantic';
};
/**
@@ -88,7 +90,8 @@ export const formatLegacyLogHtml = (value?: string | null, options: FormatLegacy
if (legacy === '/') return '</span>';
const colorCode = legacy === '1' ? null : legacy[0]!;
const small = legacy === '1' || legacy.endsWith('1');
return `<span style="${colorCode ? (legacyStyleMap[colorCode] ?? '') : ''}${small ? legacyStyleMap['1'] : ''}">`;
const semanticSmall = small && options.smallText === 'semantic';
return `<span${semanticSmall ? ' class="legacy-small"' : ''} style="${colorCode ? (legacyStyleMap[colorCode] ?? '') : ''}${small && !semanticSmall ? legacyStyleMap['1'] : ''}">`;
}
return normalizeStructuralTag(part) ?? escapeText(part);
@@ -3,6 +3,13 @@ import { describe, expect, it } from 'vitest';
import { formatLegacyLogHtml } from '../src/logging/formatLegacyLogHtml.js';
describe('formatLegacyLogHtml', () => {
it('can render semantic small spans without changing the legacy default or color markup', () => {
expect(formatLegacyLogHtml('<Y1>작게<1>중첩</></>', { smallText: 'semantic' })).toBe(
'<span class="legacy-small" style="color: yellow;">작게<span class="legacy-small" style="">중첩</span></span>'
);
expect(formatLegacyLogHtml('<Y1>작게</>', { smallText: 'semantic', colorize: false })).toBe('작게');
});
it('converts legacy colors and preserves intentional emphasis and line breaks', () => {
expect(formatLegacyLogHtml('<R><b>위험</b></><br><Y1>작게</><1>작게만</>')).toBe(
'<span style="color: red;"><b>위험</b></span><br><span style="color: yellow;font-size: 0.9em;">작게</span><span style="font-size: 0.9em;">작게만</span>'
+3
View File
@@ -0,0 +1,3 @@
# Typography audit
실행 명령과 검증 경계는 [전체 페이지 audit 안내](../../docs/frontend-typography-audit.md)를 따른다.
+673
View File
@@ -0,0 +1,673 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { fixture } from './load-fixtures.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const require = createRequire(path.join(root, 'app/game-frontend/package.json'));
const { chromium } = require('@playwright/test');
const out = process.env.TYPOGRAPHY_OUTPUT_DIR ?? path.join(root, 'test-results/typography-audit');
const phase = process.env.PHASE ?? 'capture';
const origin = process.env.CAPTURE_ORIGIN ?? 'http://127.0.0.1:15329';
const prefix = '/' + (process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '');
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
const fontRoot = process.env.TYPOGRAPHY_FONT_ROOT;
process.env.FRONTEND_PARITY_IMAGE_ROOT = path.resolve(
process.env.FRONTEND_PARITY_IMAGE_ROOT ?? path.join(root, '../image')
);
process.env.LEGACY_IMAGE_ROOT = path.resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT ?? path.join(root, '../image'));
const modules = {};
async function m(name) {
return (modules[name] ??= await fixture(name.includes('/') ? name : `app/game-frontend/e2e/${name}.spec.ts`));
}
const stateMain = () => ({
officerLevel: 12,
permission: 4,
nationLevel: 3,
stage: 5,
npcMode: 1,
generalMeCalls: 0,
operations: [],
validMapImages: true,
});
const stateMenus = () => ({ permission: 'head', myset: 0, settingMutations: [], accessPages: [], richMyInfo: true });
const setup = {
audit: async (p) => (await m('playAudit')).install(p),
login: async (p) => {
await p.route('**/gateway/', (r) => r.fulfill({ status: 204 }));
},
selection: async (p) => {
await p.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_selection_fixture');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await p.route('**/che/api/trpc/**', async (r) => {
const ops = decodeURIComponent(new URL(r.request().url()).pathname.split('/trpc/')[1]).split(',');
const rows = ops.map((op) => ({
result: {
data:
{
'auth.status': { ok: true },
'lobby.info': { myGeneral: null },
'join.getConfig': {
nations: [{ id: 1, name: '촉', color: '#008000', scoutMessage: '임관 안내' }],
selectionPool: { enabled: true, hasGeneral: false, allowOptions: [] },
personalities: [],
user: { icons: [] },
iconChoices: [],
},
'join.getSelectionPool': {
reservationId: 'fixture',
hasGeneral: false,
validUntil: '2030-01-01T00:00:00Z',
candidates: [0, 1, 2].map((i) => ({
uniqueName: 'candidate' + i,
generalName: '장수이름',
leadership: 90,
strength: 80,
intel: 70,
dex: [1000, 2000, 3000, 4000, 5000],
picture: 'default.jpg',
imageServer: 0,
specialDomesticName: '인덕',
specialDomesticInfo: '내정 특기',
specialWarName: '귀모',
specialWarInfo: '전투 특기',
})),
},
}[op] ?? {},
},
}));
await r.fulfill({ contentType: 'application/json', body: JSON.stringify(rows) });
});
},
public: async (p) => {
const x = await m('tools/frontend-legacy-parity/fixtures/canonical.ts');
const f = x.canonicalFrontendFixture.game;
await p.route('**/che/api/trpc/**', async (r) => {
const ops = decodeURIComponent(new URL(r.request().url()).pathname.split('/trpc/')[1]).split(',');
const data = ops.map((op) => ({
result: {
data:
{
'auth.status': { ok: true },
'lobby.info': { myGeneral: null },
'public.getMapLayout': f.mapLayout,
'public.getCachedMap': {
...f.map,
nationList: f.map.nationList.map((row, i) => [
row[0],
row[1],
i ? '#800000' : '#008000',
row[3],
]),
},
'public.getWorldTrend': { year: 217, month: 7, turnTerm: 10, records: [], history: [] },
'public.getNationList': [
{ id: 1, name: '촉', color: '#008000', generalCount: 100, cityCount: 8 },
],
'public.getGeneralList': [
{
id: 1,
name: '유비',
npcState: 0,
nationName: '촉',
nationId: 1,
officerLevel: 12,
picture: 'default.jpg',
cityName: '낙양',
},
],
}[op] ?? {},
},
}));
await r.fulfill({ contentType: 'application/json', body: JSON.stringify(data) });
});
},
messages: async (p) => {
const x = await m('mainNavigation');
await x.installRealtimeHarness(p);
const state = stateMain();
state.messages = {
...x.emptyMessages(4),
public: [
{
id: 801,
text: '최대 길이 장수명과 국가명을 확인합니다.',
time: '2026-09-15 12:00:00',
msgType: 'public',
src: {
generalId: 7,
generalName: '작성장수',
nationId: 1,
nationName: '위',
color: '#008000',
icon: '/image/icons/default.jpg',
},
dest: null,
option: {},
},
],
private: [
{
id: 802,
text: '메시지 본문과 삭제 동작의 간격을 확인합니다.',
time: '2026-09-15 12:00:00',
msgType: 'private',
src: {
generalId: 7,
generalName: '작성장수',
nationId: 1,
nationName: '위',
color: '#008000',
icon: '/image/icons/default.jpg',
},
dest: { generalId: 8, generalName: '수신장수', nationId: 2, nationName: '촉', color: '#800000' },
option: {},
},
],
};
await x.installFixture(p, state);
},
main: async (p) => {
const x = await m('mainNavigation');
await x.installRealtimeHarness(p);
await x.installFixture(p, stateMain());
},
commands: async (p) => (await m('commandArguments')).install(p),
menus: async (p) => (await m('inGameMenus')).install(p, stateMenus()),
info: async (p) => (await m('inGameInfo')).install(p),
directories: async (p) => (await m('directoryLists')).install(p),
officers: async (p) => (await m('nationOffices')).installFixture(p, { role: 'leader', rate: 20 }),
tournament: async (p) => (await m('tournamentBracket')).installFixture(p),
nationBetting: async (p) => (await m('nationBetting')).installFixture(p),
archives: async (p) => (await m('legacyArchiveViews')).installArchiveViews(p),
secret: async (p) => (await m('nationGeneralSecret')).install(p, true, false, 4),
troop: async (p) => {
const x = await m('troop');
await x.installApiFixture(p, { me: { id: 1, troopId: 1 }, permission: 4, troops: x.baseTroops() });
},
board: async (p) => {
const x = await m('board');
await x.installApi(p, {
permission: 4,
canMeeting: true,
canSecret: true,
articles: x.initialArticles(),
requests: [],
});
},
auction: async (p) => (await m('auction')).installFixture(p, { resourceBidCount: 0, uniqueBidCount: 0 }),
diplomacy: async (p) => (await m('diplomacy')).installFixture(p, 4),
past: async (p) => (await m('pastPlays')).installArchive(p),
npc: async (p) => (await m('npcPolicy')).installFixture(p, { permissionLevel: 4, mutations: [] }),
possession: async (p) =>
(await m('npcPossession')).installFixture(p, {
reservationCalls: 0,
reservationInputs: [],
rawBodies: [],
possessInputs: [],
hasGeneral: false,
injectTimeout: false,
}),
join: async (p) => (await m('joinLayout')).installFixture(p, { mapRequests: 0, generalRequests: 0 }),
simulator: async (p) =>
(await m('battleSimulator')).installApi(p, {
hasGeneral: true,
requests: [],
preparedPayloads: [],
serverResults: [],
}),
ranking: async (p) =>
(await m('tools/frontend-legacy-parity/visual-parity.spec.ts')).installAuthenticatedGameFixture(p),
inherit: async (p) => (await m('tools/frontend-legacy-parity/inheritance-management.spec.ts')).installFixture(p),
};
const scenes = [
['audit', 'audit', 'play-audit', '#play-audit-container'],
[
'audit-nation',
'audit',
'play-audit?tab=nations&nation=2&fromYear=190&fromMonth=1&year=190&month=6',
'#play-audit-container',
],
['audit-generals', 'audit', 'play-audit?tab=generals&at=month&year=190&month=6', '#play-audit-container'],
['audit-cities', 'audit', 'play-audit?tab=cities&year=190&month=6', '#play-audit-container'],
[
'audit-diplomacy',
'audit',
'play-audit?tab=diplomacy&nation=2&otherNation=3&year=190&month=6',
'#play-audit-container',
],
['audit-policy', 'audit', 'play-audit?tab=policies&nation=2&year=190&month=6', '#play-audit-container'],
['board-secret', 'board', 'board/secret', 'main'],
['affairs-redirect', 'officers', 'nation/affairs', 'main'],
['recruit-redirect', 'officers', 'nation/recruit-message', 'main'],
['login', 'login', 'login', '.login-redirect'],
['messages', 'messages', '', '.msg-plate'],
['recruitment', 'commands', '', '.main-page'],
['personnel-picker', 'officers', 'nation/personnel', 'main'],
['unique-auction', 'auction', 'auction?type=unique', 'main'],
['main', 'main', '', '.main-page'],
['selection', 'selection', 'select-general', 'main'],
['notfound', 'public', 'typography-not-found', 'h1'],
['public', 'public', 'public', '.public-page'],
['my-page', 'menus', 'my-page', '.legacy-page'],
['settings', 'menus', 'my-settings', 'main'],
['traffic', 'menus', 'traffic', 'main'],
['battle-center', 'menus', 'battle-center', '.battle-page'],
['chief', 'commands', 'chief-center', '.chief-page'],
['nation-info', 'info', 'nation/info', 'main'],
['global', 'info', 'global-info', 'main'],
['current-city', 'info', 'current-city', 'main'],
['nation-cities', 'info', 'nation/cities', 'main'],
['nation-list', 'directories', 'nation-list', 'main'],
['general-list', 'directories', 'general-list', 'main'],
['npc-list', 'directories', 'npc-list', 'main'],
['personnel', 'officers', 'nation/personnel', 'main'],
['finance', 'officers', 'nation/finance', 'main'],
['tournament', 'tournament', 'tournament', 'main'],
['betting', 'tournament', 'betting', 'main'],
['nation-betting', 'nationBetting', 'nation-betting', 'main'],
['hall', 'archives', 'hall-of-fame', 'main'],
['dynasty', 'archives', 'dynasty', 'main'],
['dynasty-detail', 'archives', 'dynasty/1', 'main'],
['yearbook', 'archives', 'yearbook', 'main'],
['nation-generals', 'secret', 'nation/generals', 'main'],
['secret', 'secret', 'nation/secret', 'main'],
['troop', 'troop', 'troop', 'main'],
['board', 'board', 'board', 'main'],
['auction', 'auction', 'auction', 'main'],
['diplomacy', 'diplomacy', 'diplomacy', '.diplomacy-view'],
['past', 'past', 'past-plays', 'main'],
['npc-policy', 'npc', 'npc-control', 'main'],
['possession', 'possession', 'join', 'main'],
['join', 'join', 'join', 'main'],
['simulator', 'simulator', 'battle-simulator', 'main'],
['best', 'ranking', 'best-general', 'main'],
['survey', 'ranking', 'survey', 'main'],
['inherit', 'inherit', 'inherit', 'main'],
];
// Keep the inventory tied to the active router, including redirects and fallback.
const routerSource = await fs.readFile(path.join(root, 'app/game-frontend/src/router/index.ts'), 'utf8');
const routePaths = [...routerSource.matchAll(/\bpath: '([^']+)'/g)].map((match) => match[1]);
const normalizeRoute = (route) => {
const pathname = '/' + route.split('?')[0];
if (pathname === '/typography-not-found') return '/:pathMatch(.*)*';
return pathname.replace(/^\/dynasty\/[^/]+$/, '/dynasty/:id');
};
const sceneRoutes = new Set(scenes.map((scene) => normalizeRoute(scene[2])));
const missingRoutes = routePaths.filter((route) => !sceneRoutes.has(route));
if (missingRoutes.length) throw new Error('Routes without typography scenes: ' + missingRoutes.join(', '));
await fs.mkdir(out, { recursive: true });
await fs.writeFile(path.join(out, 'route-coverage.json'), JSON.stringify({ routePaths, scenes }, null, 2));
const filter = process.env.SCENES?.split(',');
const nameKind = process.env.NAME_KIND ?? 'cjk';
const generalName =
nameKind === 'ascii'
? 'W'.repeat(18)
: nameKind === 'short4'
? '가나다라'
: nameKind === 'mid7'
? '가나다라마바사'
: '가나다라마바사아자';
const nationName = nameKind === 'ascii' ? 'M'.repeat(18) : '대한민국천하통일국';
function transform(value, key = '', parent = '') {
if (Array.isArray(value)) {
if (key === 'nationList')
return value.map((row) =>
Array.isArray(row) ? row.map((v, i) => (i === 1 ? nationName : v)) : transform(row, key, parent)
);
return value.map((v) => transform(v, key, parent));
}
if (!value || typeof value !== 'object') return value;
const obj = {};
const isGeneral =
['npcState', 'officerLevel', 'picture', 'stats', 'leadership', 'crew', 'generalId'].some((k) => k in value) ||
/general|chief|officer|leader|participant|candidate|winner|loser|owner/i.test(key);
const isNation =
/nation/i.test(key) || ('color' in value && ('level' in value || 'capital' in value || 'power' in value));
for (const [k, v] of Object.entries(value)) {
if (typeof v === 'string' && /^(nationName|nation|국가)$/i.test(k)) obj[k] = nationName;
else if (
typeof v === 'string' &&
/^(generalName|authorName|sellerName|hostName|bidderName|leaderName|chiefName|officerName|kingName|l\d+name)$/i.test(
k
)
)
obj[k] = generalName;
else if (k === 'name' && typeof v === 'string' && (isGeneral || isNation))
obj[k] = isGeneral ? generalName : nationName;
else obj[k] = transform(v, k, key);
}
return obj;
}
const browser = await chromium.launch({ headless: true });
const manifest = [];
for (const [id, group, route, ready] of scenes.filter((s) => !filter || filter.includes(s[0]))) {
for (const width of (process.env.WIDTHS ?? '500,1000').split(',').map(Number)) {
const dir = path.join(out, phase, `${id}-${width}-${nameKind}`);
await fs.mkdir(dir, { recursive: true });
const context = await browser.newContext({
baseURL: origin + prefix + '/',
viewport: { width: process.env.MOBILE ? 390 : width, height: process.env.MOBILE ? 844 : 900 },
isMobile: !!process.env.MOBILE,
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
colorScheme: 'dark',
});
const page = await context.newPage();
await page.addInitScript(() => {
window.__typographyCanvasFonts = [];
const original = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (...args) {
if (!window.__typographyCanvasFonts.includes(this.font)) window.__typographyCanvasFonts.push(this.font);
return original.apply(this, args);
};
});
const errors = [],
responses = [];
page.on('pageerror', (e) => errors.push(e.message));
const proxy = new Proxy(page, {
get(target, key) {
if (key === 'route')
return async (pattern, handler, options) =>
target.route(
typeof pattern === 'string' ? pattern.replaceAll('/che/', prefix + '/') : pattern,
async (route, request) => {
const rp = new Proxy(route, {
get(rt, rk) {
if (rk === 'fulfill')
return async (options) => {
let body = options.json;
if (
body === undefined &&
typeof options.body === 'string' &&
(options.body.startsWith('[') || options.body.startsWith('{'))
)
try {
body = JSON.parse(options.body);
} catch {
// Non-JSON fixture bodies are passed through unchanged.
}
if (body !== undefined) {
body = transform(body);
responses.push({ url: request.url(), body });
return rt.fulfill({
...options,
json: undefined,
body: JSON.stringify(body),
});
}
return rt.fulfill(options);
};
const v = Reflect.get(rt, rk, rt);
return typeof v === 'function' ? v.bind(rt) : v;
},
});
try {
return await handler(rp, request);
} catch (e) {
errors.push('fixture: ' + e.message);
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({
error: {
message: e.message,
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
},
}),
});
}
},
options
);
const v = Reflect.get(target, key, target);
return typeof v === 'function' ? v.bind(target) : v;
},
});
try {
await setup[group](proxy);
await page.addInitScript(
({ mode, profile }) => {
localStorage.setItem('sammo-game-profile', profile);
localStorage.setItem('sam.screenMode', mode);
localStorage.removeItem('sam_customCSS');
},
{ mode: process.env.MOBILE ? `${width}px` : 'auto', profile }
);
// Serve the exact installed source font faces and real workspace images.
if (fontRoot) {
await page.route('**/pretendard.css', (r) =>
r.fulfill({ contentType: 'text/css', path: path.join(fontRoot, 'source.css') })
);
await page.route('**/*.woff2', async (r) => {
const file = new URL(r.request().url()).pathname.split('/').at(-1);
try {
await r.fulfill({ contentType: 'font/woff2', path: path.join(fontRoot, file) });
} catch {
await r.abort();
}
});
}
await page.route(/\/(?:image\/|sam-image\.hided\.net\/)/, async (r) => {
const u = new URL(r.request().url());
let rel = u.pathname.replace(/^\/image\//, '');
if (u.hostname === 'sam-image.hided.net') rel = u.pathname.slice(1);
if (rel.includes('..')) return r.abort();
try {
const body = await fs.readFile(path.resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, rel));
await r.fulfill({
body,
contentType: rel.endsWith('.png')
? 'image/png'
: rel.endsWith('.gif')
? 'image/gif'
: 'image/jpeg',
});
} catch {
await r.fallback();
}
});
await page.goto(origin + prefix + '/' + route, { waitUntil: 'networkidle', timeout: 30000 });
await page.locator(ready).first().waitFor({ state: 'visible', timeout: 5000 });
const requestedPath = route.split('?')[0];
const expectedPath = ['nation/affairs', 'nation/recruit-message'].includes(requestedPath)
? 'nation/finance'
: requestedPath;
if (new URL(page.url()).pathname !== `${prefix}/${expectedPath}`)
throw new Error(`Unexpected route after guard: ${page.url()}`);
if (id === 'messages') await page.locator('.msg-plate:visible').first().scrollIntoViewIfNeeded();
if (id === 'recruitment') {
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 page.getByTestId('recruitment-command-form').waitFor();
}
if (id === 'personnel-picker') {
await page.getByRole('button', { name: '주부 변경하기', exact: true }).click();
await page.getByTestId('personnel-selection-dialog').waitFor();
}
if (id === 'nation-betting') {
await page.getByRole('button', { name: /두 번째 천통국 베팅/u }).click();
await page.getByRole('spinbutton', { name: '베팅 금액' }).waitFor();
}
if (id === 'join') {
await page.locator('input[type=text]').first().fill(generalName);
}
if (id === 'simulator') {
for (const input of await page.getByLabel('이름', { exact: true }).all()) await input.fill(generalName);
}
await page.evaluate(async () => {
await document.fonts.ready;
await Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
});
await page.waitForTimeout(150);
const data = await page.evaluate(
({ generalName, nationName }) => {
const nodes = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let n;
function selector(e) {
const parts = [];
for (let p = e; p && p !== document.body; p = p.parentElement) {
let s = p.tagName.toLowerCase();
if (p.id) s += '#' + p.id;
else if (p.classList.length) s += '.' + [...p.classList].join('.');
parts.unshift(s);
}
return parts.join(' > ');
}
while ((n = walker.nextNode())) {
const text = n.textContent.trim();
const e = n.parentElement;
if (!text || !e || ['SCRIPT', 'STYLE', 'OPTION'].includes(e.tagName)) continue;
const s = getComputedStyle(e);
if (s.display === 'none' || s.visibility === 'hidden' || !e.getClientRects().length) continue;
const range = document.createRange();
range.selectNodeContents(n);
const r = range.getBoundingClientRect();
if (!r.width || !r.height) continue;
const er = e.getBoundingClientRect();
const isName = text.includes(generalName) || text.includes(nationName);
const clipped = [];
for (let p = e; p && p !== document.body; p = p.parentElement) {
const ps = getComputedStyle(p),
pr = p.getBoundingClientRect();
if (
['hidden', 'clip', 'auto', 'scroll'].includes(ps.overflowX) &&
(r.left < pr.left - 1 || r.right > pr.right + 1)
)
clipped.push({ selector: selector(p), axis: 'x', overflow: ps.overflowX });
if (
['hidden', 'clip'].includes(ps.overflowY) &&
(r.top < pr.top - 1 || r.bottom > pr.bottom + 1)
)
clipped.push({ selector: selector(p), axis: 'y', overflow: ps.overflowY });
}
nodes.push({
text,
fitMax: e.dataset.fontFitMax ?? null,
isSubordinate: !!e.closest('small, sub, sup, .legacy-small, .sammo-text-smaller'),
selector: selector(e),
isName,
font: s.fontSize,
lineHeight: s.lineHeight,
weight: s.fontWeight,
whiteSpace: s.whiteSpace,
textOverflow: s.textOverflow,
rect: { x: r.x, y: r.y, width: r.width, height: r.height },
element: { x: er.x, y: er.y, width: er.width, height: er.height },
clipped,
});
}
return {
url: location.href,
canvasFonts: window.__typographyCanvasFonts,
smallSteps: [...document.querySelectorAll('small, sub, sup, .legacy-small')]
.filter(
(element) =>
element.getClientRects().length && getComputedStyle(element).visibility !== 'hidden'
)
.map((element) => ({
selector: selector(element),
font: getComputedStyle(element).fontSize,
parentFont: getComputedStyle(element.parentElement).fontSize,
})),
viewport: {
width: innerWidth,
height: innerHeight,
scale: visualViewport?.scale,
visualWidth: visualViewport?.width,
mode: localStorage.getItem('sam.screenMode'),
},
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
fontLoaded: document.fonts.check('14px Pretendard'),
fontFaces: [...document.fonts].map((f) => ({
family: f.family,
weight: f.weight,
status: f.status,
})),
nodes,
images: [...document.images].map((i) => ({
src: i.getAttribute('src'),
naturalWidth: i.naturalWidth,
naturalHeight: i.naturalHeight,
objectFit: getComputedStyle(i).objectFit,
})),
};
},
{ generalName, nationName }
);
if (!data.fontFaces.some((f) => f.family.includes('Pretendard') && f.status === 'loaded'))
throw new Error('Pretendard was not loaded');
const nextSize = { '24px': '16px', '16px': '14px', '14px': '12px', '12px': '10px', '10px': '10px' };
const wrongSteps = data.smallSteps.filter(
(node) => nextSize[node.parentFont] && node.font !== nextSize[node.parentFont]
);
if (wrongSteps.length) errors.push('Small step mismatch: ' + JSON.stringify(wrongSteps));
const offTier = data.nodes.filter(
(n) =>
!['0px', '12px', '14px', '16px', '24px'].includes(n.font) &&
!(n.font === '10px' && n.isSubordinate) &&
!(
n.fitMax &&
[12, 14, 16, 24].includes(Number(n.fitMax)) &&
parseFloat(n.font) <= Number(n.fitMax) &&
parseFloat(n.font) >= 10
)
);
if (offTier.length)
errors.push(
'Off-tier text: ' +
JSON.stringify(offTier.map((n) => ({ font: n.font, selector: n.selector })).slice(0, 20))
);
await page.screenshot({ path: path.join(dir, 'full.png'), fullPage: true });
await page.screenshot({ path: path.join(dir, 'viewport.png') });
await fs.writeFile(path.join(dir, 'dom.json'), JSON.stringify(data, null, 2));
await fs.writeFile(path.join(dir, 'dom.html'), await page.content());
await fs.writeFile(path.join(dir, 'responses.json'), JSON.stringify(responses, null, 2));
const entry = {
id,
width,
nameKind,
status: 'captured',
nodes: data.nodes.length,
names: data.nodes.filter((n) => n.isName).length,
clippedNames: data.nodes.filter((n) => n.isName && n.clipped.length).length,
fontLoaded: data.fontLoaded,
errors,
};
manifest.push(entry);
console.log(JSON.stringify(entry));
} catch (e) {
const entry = { id, width, nameKind, status: 'failed', error: e.message, errors };
manifest.push(entry);
console.log(JSON.stringify(entry));
await page.screenshot({ path: path.join(dir, 'failure.png'), fullPage: true }).catch(() => {});
await fs.writeFile(path.join(dir, 'failure.html'), await page.content());
}
await context.close();
await fs.writeFile(path.join(out, `${phase}-${nameKind}-manifest.json`), JSON.stringify(manifest, null, 2));
}
}
await browser.close();
const failures = manifest.filter((row) => row.status !== 'captured' || row.errors.length);
console.log(
JSON.stringify({
captures: manifest.length,
failures: failures.length,
profile,
prefix,
browser: browser.version(),
})
);
if (failures.length) process.exitCode = 1;
+75
View File
@@ -0,0 +1,75 @@
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL, fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const require = createRequire(path.join(root, 'app/game-frontend/package.json'));
const ts = require('typescript');
const out = path.join(process.env.TYPOGRAPHY_OUTPUT_DIR ?? path.join(root, 'test-results/typography-audit'), 'modules');
fs.mkdirSync(out, { recursive: true });
const cache = new Map();
export async function fixture(relative) {
const url = compile(path.join(root, relative));
return import(url);
}
function compile(filename) {
if (cache.has(filename)) return cache.get(filename);
const target = path.join(
out,
path
.relative(root, filename)
.replaceAll('/', '__')
.replace(/\.tsx?$/, '.mjs')
);
const url = pathToFileURL(target).href;
cache.set(filename, url);
let source = fs
.readFileSync(filename, 'utf8')
.replaceAll('import.meta.url', JSON.stringify(pathToFileURL(filename).href))
.replaceAll('import.meta.dirname', JSON.stringify(path.dirname(filename)));
const ast = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const pieces = [],
names = [];
for (const node of ast.statements) {
// Reuse only original fixture definitions, never register/execute the tests.
if (
ts.isImportDeclaration(node) ||
ts.isTypeAliasDeclaration(node) ||
ts.isInterfaceDeclaration(node) ||
ts.isFunctionDeclaration(node) ||
ts.isClassDeclaration(node) ||
ts.isVariableStatement(node)
) {
pieces.push(node.getFullText(ast));
if (ts.isVariableStatement(node))
for (const dec of node.declarationList.declarations)
if (ts.isIdentifier(dec.name)) names.push(dec.name.text);
if (ts.isFunctionDeclaration(node) && node.name) names.push(node.name.text);
}
}
const isSpec = filename.endsWith('.spec.ts');
const input = isSpec ? pieces.join('\n') + '\nexport { ' + names.join(', ') + ' };' : source;
let js = ts.transpileModule(input, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
}).outputText;
js = js.replace(/(from\s*|import\s*)['"]([^'"]+)['"]/g, (match, prefix, spec) => {
if (spec.startsWith('node:')) return match;
let dest;
if (spec.startsWith('.')) {
dest = path.resolve(path.dirname(filename), spec);
if (!fs.existsSync(dest) && dest.endsWith('.js')) dest = dest.replace(/\.js$/, '.ts');
if (!fs.existsSync(dest)) {
if (fs.existsSync(dest + '.ts')) dest += '.ts';
else if (fs.existsSync(dest + '.js')) dest += '.js';
}
dest = dest.endsWith('.ts') ? compile(dest) : pathToFileURL(dest).href;
} else {
let resolved = createRequire(filename).resolve(spec);
if (spec === '@playwright/test') resolved = resolved.replace(/index\.js$/, 'index.mjs');
dest = pathToFileURL(resolved).href;
}
return prefix + JSON.stringify(dest);
});
fs.writeFileSync(target, js);
return url;
}