diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts
index 7983a16b..43ef9e18 100644
--- a/app/game-frontend/e2e/directoryLists.spec.ts
+++ b/app/game-frontend/e2e/directoryLists.spec.ts
@@ -710,6 +710,106 @@ test('trait and injury explanations appear on pointer hover and keyboard focus',
await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true });
});
+for (const width of [1200, 500]) {
+ test(`catalog description line breaks stay safe on hover and focus at ${width}px`, async ({ page }, testInfo) => {
+ await page.setViewportSize({ width, height: 900 });
+ const warInfo =
+ '[계략] 화계·탈취·파괴·선동 : 성공률 +10%p
[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p';
+ const literalMarkup = '
설명';
+ const directoryRows = generals.map((general) =>
+ general.id === 10
+ ? {
+ ...general,
+ specialDomestic: {
+ ...general.specialDomestic,
+ info: `상업 특기
수익 증가
매매 효율\r\n${literalMarkup}`,
+ },
+ specialWar: { key: 'che_신산', name: '신산', info: warInfo },
+ }
+ : general
+ );
+ await install(page, 'general', [], [], directoryRows);
+ await page.goto('general-list');
+ await page.reload();
+ const prefix = width <= 600 ? 'card-' : '';
+ const container = page
+ .locator(width <= 600 ? '[data-general-card-id="10"]' : 'tr[data-general-id="10"]')
+ .first();
+ await expect(container).toBeVisible();
+ await expect(page.getByRole('button', { name: '갱 신' })).not.toHaveAttribute('aria-busy');
+ await page.evaluate(async () => {
+ await document.fonts.ready;
+ await Promise.all(Array.from(document.images, (image) => image.decode().catch(() => undefined)));
+ });
+ const before = await container.boundingBox();
+ const evidence: Record = { width, url: page.url(), before };
+ for (const [kind, expected] of [
+ ['special-domestic', ['상업 특기', '수익 증가', '매매 효율', literalMarkup]],
+ ['special-war', warInfo.split('
')],
+ ] as const) {
+ const trigger = page.locator(`[data-directory-tooltip="${prefix}${kind}-10"]`);
+ const tooltip = trigger.getByRole('tooltip');
+ await page.mouse.move(0, 0);
+ await trigger.hover();
+ await expect(tooltip).toBeVisible();
+ await trigger.focus();
+ await expect(tooltip).toBeVisible();
+ await expect(trigger).toHaveCSS('text-decoration-line', 'none');
+ await expect(trigger).toHaveCSS('outline-style', 'solid');
+ const description = tooltip.locator('.directory-tooltip__description');
+ await expect(description.locator('br')).toHaveCount(expected.length - 1);
+ await expect(description.locator('img, b, script')).toHaveCount(0);
+ expect(await description.innerText()).not.toMatch(/
/iu);
+ const metrics = await description.evaluate((element) => {
+ const lines = Array.from(element.childNodes).filter(
+ (node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
+ );
+ return {
+ lines: lines.map((node) => {
+ const range = document.createRange();
+ range.selectNodeContents(node);
+ return { text: node.textContent?.trim(), top: range.getBoundingClientRect().top };
+ }),
+ html: element.outerHTML,
+ };
+ });
+ expect(metrics.lines.map((line) => line.text)).toEqual(expected);
+ for (let index = 1; index < metrics.lines.length; index += 1) {
+ expect(metrics.lines[index]!.top).toBeGreaterThan(metrics.lines[index - 1]!.top);
+ }
+ const geometry = await tooltip.evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ const style = getComputedStyle(element);
+ return {
+ left: rect.left,
+ right: rect.right,
+ top: rect.top,
+ bottom: rect.bottom,
+ font: style.font,
+ background: style.backgroundColor,
+ position: style.position,
+ };
+ });
+ expect(geometry.left).toBeGreaterThanOrEqual(8);
+ expect(geometry.right).toBeLessThanOrEqual(width - 8);
+ expect(geometry.top).toBeGreaterThanOrEqual(0);
+ expect(geometry.bottom).toBeLessThanOrEqual(900);
+ expect(await container.boundingBox()).toEqual(before);
+ evidence[kind] = { ...metrics, geometry };
+ await page.screenshot({ path: testInfo.outputPath(`catalog-${kind}-${width}.png`), fullPage: true });
+ await trigger.blur();
+ }
+ const empty = page.locator(`[data-directory-tooltip="${prefix}special-war-20"]`);
+ await expect(empty).not.toHaveAttribute('tabindex');
+ await expect(empty.getByRole('tooltip')).toHaveCount(0);
+ await writeFile(testInfo.outputPath('catalog-description.json'), JSON.stringify(evidence, null, 2));
+ await writeFile(
+ testInfo.outputPath('catalog-description.html'),
+ await page.locator('main').evaluate((element) => element.outerHTML)
+ );
+ });
+}
+
test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
await install(page);
await page.goto('npc-list');
diff --git a/app/game-frontend/src/components/directory/DirectoryTooltip.vue b/app/game-frontend/src/components/directory/DirectoryTooltip.vue
index 2c3f1b84..0d5b2859 100644
--- a/app/game-frontend/src/components/directory/DirectoryTooltip.vue
+++ b/app/game-frontend/src/components/directory/DirectoryTooltip.vue
@@ -1,9 +1,14 @@
@@ -16,7 +21,10 @@ defineProps<{
{{ title }}
- {{ description }}
+ {{ line }}
diff --git a/app/game-frontend/src/components/ui/RichTooltip.vue b/app/game-frontend/src/components/ui/RichTooltip.vue
index 3669be41..b4350f77 100644
--- a/app/game-frontend/src/components/ui/RichTooltip.vue
+++ b/app/game-frontend/src/components/ui/RichTooltip.vue
@@ -1,5 +1,6 @@