From 7c65cf60f304ffbf2c9ccac73d72cd06855469b6 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 29 Sep 2026 09:41:39 +0000 Subject: [PATCH] =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20UI=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=ED=81=AC=EA=B8=B0=EB=A5=BC=20=EB=84=A4=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EC=99=80=20=EA=B3=B5=ED=86=B5=20=EC=B6=95=EC=86=8C=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/commandArguments.spec.ts | 4 +- .../e2e/fixtures/typography.json | 121 ++++ app/game-frontend/e2e/inGameMenus.spec.ts | 26 +- .../e2e/typographyPolicy.spec.ts | 114 ++- app/game-frontend/src/assets/main.css | 5 +- .../src/assets/styles/tokens.css | 22 +- .../src/assets/styles/typography.css | 32 + .../src/components/chief/ChiefTurnCard.vue | 8 +- .../command/RecruitmentCommandForm.vue | 10 - .../command/ReservedCommandEditor.vue | 11 +- .../directory/GeneralDirectoryTable.vue | 5 - .../components/main/CommandArgumentForm.vue | 13 +- .../src/components/main/CommandSelectForm.vue | 5 - .../src/components/main/NationBasicCard.vue | 2 +- .../personnel/PersonnelSelectionDialog.vue | 6 - .../components/playAudit/AuditLineChart.vue | 14 +- .../tournament/TournamentBracket.vue | 2 +- .../src/components/ui/LegacyHtmlEditor.vue | 2 +- app/game-frontend/src/directives/fitText.ts | 46 ++ app/game-frontend/src/utils/formatLog.ts | 2 +- .../src/views/BestGeneralView.vue | 31 +- app/game-frontend/src/views/BettingView.vue | 9 - .../src/views/ChiefCenterView.vue | 34 +- .../src/views/GeneralListView.vue | 5 - .../src/views/HallOfFameView.vue | 23 +- app/game-frontend/src/views/InheritView.vue | 5 - app/game-frontend/src/views/JoinView.vue | 13 +- .../src/views/MySettingsView.vue | 5 - .../src/views/NationGeneralsView.vue | 5 - .../src/views/NationListView.vue | 5 - .../src/views/NationPersonnelView.vue | 23 +- app/game-frontend/src/views/NotFoundView.vue | 6 +- .../src/views/NpcControlView.vue | 6 - .../src/views/TournamentView.vue | 5 - app/game-frontend/test/fontContract.test.ts | 16 + docs/frontend-css-architecture.md | 105 +-- docs/frontend-typography-audit.md | 69 ++ docs/frontend-typography.md | 68 ++ .../common/src/logging/formatLegacyLogHtml.ts | 5 +- .../test/format-legacy-log-html.test.ts | 7 + tools/typography-audit/README.md | 3 + tools/typography-audit/capture.mjs | 673 ++++++++++++++++++ tools/typography-audit/load-fixtures.mjs | 75 ++ 43 files changed, 1347 insertions(+), 299 deletions(-) create mode 100644 app/game-frontend/src/assets/styles/typography.css create mode 100644 app/game-frontend/src/directives/fitText.ts create mode 100644 docs/frontend-typography-audit.md create mode 100644 docs/frontend-typography.md create mode 100644 tools/typography-audit/README.md create mode 100644 tools/typography-audit/capture.mjs create mode 100644 tools/typography-audit/load-fixtures.mjs 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(