프로필 UI 글자 크기를 네 단계와 공통 축소 규칙으로 통일한다
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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에 넣지 않는다.
|
||||
@@ -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>'
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Typography audit
|
||||
|
||||
실행 명령과 검증 경계는 [전체 페이지 audit 안내](../../docs/frontend-typography-audit.md)를 따른다.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user