diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts
index 990a49d1..f80d2425 100644
--- a/app/game-frontend/e2e/commandArguments.spec.ts
+++ b/app/game-frontend/e2e/commandArguments.spec.ts
@@ -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([
diff --git a/app/game-frontend/e2e/fixtures/typography.json b/app/game-frontend/e2e/fixtures/typography.json
index 9e55a1ca..3f9d3122 100644
--- a/app/game-frontend/e2e/fixtures/typography.json
+++ b/app/game-frontend/e2e/fixtures/typography.json
@@ -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"
}
}
diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts
index 14e49a4a..ba305eee 100644
--- a/app/game-frontend/e2e/inGameMenus.spec.ts
+++ b/app/game-frontend/e2e/inGameMenus.spec.ts
@@ -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 },
]);
});
diff --git a/app/game-frontend/e2e/typographyPolicy.spec.ts b/app/game-frontend/e2e/typographyPolicy.spec.ts
index c7febd36..785ec288 100644
--- a/app/game-frontend/e2e/typographyPolicy.spec.ts
+++ b/app/game-frontend/e2e/typographyPolicy.spec.ts
@@ -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 = '본문보조상속중첩최소';
+ 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 = {
+ '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' });
+ });
+}
diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css
index 5df1ea2e..ef3c5323 100644
--- a/app/game-frontend/src/assets/main.css
+++ b/app/game-frontend/src/assets/main.css
@@ -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);
}
diff --git a/app/game-frontend/src/assets/styles/tokens.css b/app/game-frontend/src/assets/styles/tokens.css
index 72fc115b..d77293d6 100644
--- a/app/game-frontend/src/assets/styles/tokens.css
+++ b/app/game-frontend/src/assets/styles/tokens.css
@@ -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);
diff --git a/app/game-frontend/src/assets/styles/typography.css b/app/game-frontend/src/assets/styles/typography.css
new file mode 100644
index 00000000..c8c89c1c
--- /dev/null
+++ b/app/game-frontend/src/assets/styles/typography.css
@@ -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;
+}
diff --git a/app/game-frontend/src/components/chief/ChiefTurnCard.vue b/app/game-frontend/src/components/chief/ChiefTurnCard.vue
index ebc346e7..659824d3 100644
--- a/app/game-frontend/src/components/chief/ChiefTurnCard.vue
+++ b/app/game-frontend/src/components/chief/ChiefTurnCard.vue
@@ -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 {
diff --git a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue
index 4ad4426f..8c7eef39 100644
--- a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue
+++ b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue
@@ -270,11 +270,6 @@ watch(