Skip to content

Commit a9051eb

Browse files
authored
Merge pull request #276 from zlxlabs/card/vta-layout-overflow
根治前端长文本挤压按钮/溢出卡片,补布局几何回归测试
2 parents 5f1e935 + ed83f27 commit a9051eb

10 files changed

Lines changed: 259 additions & 34 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
## 里程碑 1:布局回归测试在基线变红
2+
- 当前阶段:implementing
3+
- 本段结论:新增 Playwright 布局测试并以原始生产代码运行,首页最近任务在 1280×800 下因无断行导致页面 `scrollWidth=2608`、`clientWidth=1280` 断言失败,证明测试能捕获原缺陷。
4+
- 关键决策与已否决方案:先验证未修改生产代码的基线红;保留真正的 document 几何断言,不以截图或元素可见性冒充布局判据。
5+
- 下一步唯一动作:落全局文本断行与动作控件基线,再按盘点结果修正遗留窄屏布局。
6+
7+
## 里程碑 2:布局根因修复并完成验证
8+
- 当前阶段:implementation complete,等待范围核查与提交
9+
- 本段结论:两个样式根已统一应用长文本断行和动作控件防压缩基线,首页去重、history 窄屏动作列和第三方文本截断点已处理。`npm run test:browser` 31 项、`npm run test:web` 188 项、`uv run --frozen pytest -q tests/unit/web` 与 `make test` 全部通过;桌面/手机首页截图已写入 `/tmp`。
10+
- 关键决策与已否决方案:长标题/URL 优先完整换行;确有单行列时以 `min-width:0` 和 `title` 保留完整值。TOC 章节标题保留省略号以兼容既有行高契约,同时 hover/focus 展开完整标题,移动吸顶条完整换行显示;不动视觉配色/字号/间距。
11+
- 下一步唯一动作:对 `<base>..HEAD` 执行 `card_scope_check.py` 并据结果收尾。

‎e2e/tests/layout-overflow.spec.ts‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { expect, test } from '@playwright/test';
2+
import { resetBackend } from '../reset-backend';
3+
4+
const baseURL = process.env.VTA_BROWSER_PORT
5+
? `http://127.0.0.1:${process.env.VTA_BROWSER_PORT}`
6+
: '';
7+
const longUrl = `https://x.com/${'L'.repeat(220)}`;
8+
const longTitle = `LongTitle${'T'.repeat(220)}`;
9+
const viewports = [
10+
{ name: 'desktop', width: 1280, height: 800 },
11+
{ name: 'mobile', width: 375, height: 667 },
12+
];
13+
14+
test.beforeEach(async ({ request }) => {
15+
if (!baseURL) throw new Error('VTA_BROWSER_PORT must be set by the browser test runner');
16+
await resetBackend(request, baseURL);
17+
});
18+
19+
async function expectNoHorizontalOverflow(page: import('@playwright/test').Page): Promise<void> {
20+
const dimensions = await page.evaluate(() => ({
21+
clientWidth: document.documentElement.clientWidth,
22+
scrollWidth: document.documentElement.scrollWidth,
23+
}));
24+
expect(dimensions.scrollWidth, 'document must not scroll horizontally').toBeLessThanOrEqual(
25+
dimensions.clientWidth,
26+
);
27+
}
28+
29+
async function expectControlsInsideCards(
30+
page: import('@playwright/test').Page,
31+
cardSelector: string,
32+
controlSelector: string,
33+
referenceSelector: string,
34+
): Promise<void> {
35+
const reference = await page.locator(referenceSelector).first().boundingBox();
36+
expect(reference, 'short-text reference control must be visible').not.toBeNull();
37+
const controls = await page.locator(cardSelector).evaluateAll((cards, selector) =>
38+
cards.flatMap((card) => {
39+
const cardBox = card.getBoundingClientRect();
40+
return Array.from(card.querySelectorAll(selector)).map((control) => {
41+
const box = control.getBoundingClientRect();
42+
return {
43+
label: control.textContent?.trim() ?? '',
44+
cardLeft: cardBox.left,
45+
cardRight: cardBox.right,
46+
cardTop: cardBox.top,
47+
cardBottom: cardBox.bottom,
48+
left: box.left,
49+
right: box.right,
50+
top: box.top,
51+
bottom: box.bottom,
52+
height: box.height,
53+
};
54+
});
55+
}),
56+
controlSelector,
57+
);
58+
expect(controls.length, 'cards must contain measurable controls').toBeGreaterThan(0);
59+
for (const control of controls) {
60+
expect(control.left, `${control.label} left edge must stay inside its card`).toBeGreaterThanOrEqual(
61+
control.cardLeft - 0.5,
62+
);
63+
expect(control.right, `${control.label} right edge must stay inside its card`).toBeLessThanOrEqual(
64+
control.cardRight + 0.5,
65+
);
66+
expect(control.top, `${control.label} top edge must stay inside its card`).toBeGreaterThanOrEqual(
67+
control.cardTop - 0.5,
68+
);
69+
expect(control.bottom, `${control.label} bottom edge must stay inside its card`).toBeLessThanOrEqual(
70+
control.cardBottom + 0.5,
71+
);
72+
expect(control.height, `${control.label} must remain a single-line control`).toBeLessThanOrEqual(
73+
(reference?.height ?? 0) * 1.3,
74+
);
75+
}
76+
}
77+
78+
for (const viewport of viewports) {
79+
test(`recent tasks keep long text and actions inside cards at ${viewport.name}`, async ({ page }) => {
80+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
81+
await page.addInitScript(({ longUrlValue, longTitleValue }) => {
82+
localStorage.setItem('vta_task_history', JSON.stringify([
83+
{
84+
id: 'layout-long',
85+
title: longTitleValue,
86+
url: longUrlValue,
87+
original_text: ` ${longUrlValue} `,
88+
timestamp: '2026-10-21T12:00:00.000Z',
89+
view_token: 'browser-fixture-view',
90+
},
91+
{
92+
id: 'layout-short',
93+
title: 'Short reference task',
94+
url: 'https://x.com/short',
95+
original_text: 'https://x.com/short',
96+
timestamp: '2026-10-21T11:00:00.000Z',
97+
view_token: 'browser-fixture-view',
98+
},
99+
]));
100+
}, { longUrlValue: longUrl, longTitleValue: longTitle });
101+
await page.goto('/add_task_by_web');
102+
103+
const longCard = page.locator('.history-item:has([data-task-id="layout-long"])');
104+
await expect(longCard).toBeVisible();
105+
await expect(longCard.locator('.history-title')).toHaveText(longTitle);
106+
await expectNoHorizontalOverflow(page);
107+
await expectControlsInsideCards(
108+
page,
109+
'.history-item',
110+
'.history-btn',
111+
'.history-item:has([data-task-id="layout-short"]) .history-btn',
112+
);
113+
await expect(longCard.locator('.history-original-text')).toHaveCount(1);
114+
await expect(longCard.locator('.history-url')).toHaveCount(0);
115+
await page.locator('.fade-in').evaluateAll((elements) => {
116+
elements.forEach((element) => element.getAnimations().forEach((animation) => animation.finish()));
117+
});
118+
119+
if (viewport.width === 1280) {
120+
await page.screenshot({ path: '/tmp/vta-layout-overflow-home-1280.png', fullPage: true });
121+
} else {
122+
await page.screenshot({ path: '/tmp/vta-layout-overflow-home-375.png', fullPage: true });
123+
}
124+
});
125+
126+
test(`history rows keep long text and actions inside cards at ${viewport.name}`, async ({ page }) => {
127+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
128+
await page.goto('/static/history.html');
129+
await page.locator('#apiKeyInput').fill('browser-fixture-token');
130+
await page.locator('#historyQueryBtn').click();
131+
await expect(page.locator('#filterBar')).toBeVisible();
132+
await page.locator('#filterSource').selectOption('upload');
133+
const longRow = page.locator('#listArea .task-row').filter({ hasText: longTitle });
134+
await expect(longRow).toBeVisible();
135+
await expect(longRow.locator('.task-title')).toHaveAttribute('title', longTitle);
136+
await expect(longRow.locator('.task-author')).toContainText(longUrl);
137+
await expectNoHorizontalOverflow(page);
138+
await expectControlsInsideCards(
139+
page,
140+
'#listArea .task-row',
141+
'.btn-stop-share',
142+
'#listArea .task-row:has(.task-title:text("浏览器本地上传样本")) .btn-stop-share',
143+
);
144+
});
145+
146+
test(`transcript page keeps a long title and actions inside sections at ${viewport.name}`, async ({ page }) => {
147+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
148+
await page.goto('/view/browser-layout-overflow');
149+
await expect(page.locator('.header h1')).toContainText(longTitle);
150+
await expect(page.locator('#summary-content-block')).toContainText(longUrl);
151+
await expectNoHorizontalOverflow(page);
152+
await expectControlsInsideCards(
153+
page,
154+
'.section',
155+
'button, a.btn',
156+
'.section button',
157+
);
158+
});
159+
160+
test(`processing and error pages do not scroll horizontally at ${viewport.name}`, async ({ page }) => {
161+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
162+
for (const path of ['/view/processing', '/view/failed']) {
163+
await page.goto(path);
164+
await expectNoHorizontalOverflow(page);
165+
}
166+
});
167+
}

‎src/web/static/css/floating-toc.css‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,7 @@ body.toc-wide-margin {
658658
flex: 1 1 auto;
659659
min-width: 0;
660660
display: flex;
661+
white-space: normal;
661662
align-items: baseline;
662663
gap: 8px;
663664
width: 100%;
@@ -692,6 +693,13 @@ body.toc-wide-margin {
692693
text-overflow: ellipsis;
693694
}
694695

696+
.toc-chapter-main:hover .toc-chapter-title,
697+
.toc-chapter-main:focus-visible .toc-chapter-title {
698+
white-space: normal;
699+
overflow: visible;
700+
text-overflow: clip;
701+
}
702+
695703
/* 摘要全文:不截断、无展开交互,文档级行距;
696704
整条目(标题行+摘要)都是跳转目标,故用 pointer;
697705
灰化由 .toc-chapter-disabled 整体 opacity 继承 */
@@ -727,7 +735,6 @@ body.toc-wide-margin {
727735
font-size: 0.82em;
728736
font-weight: 500;
729737
text-decoration: none;
730-
white-space: nowrap;
731738
}
732739

733740
.notes-source-link:hover,
@@ -763,9 +770,8 @@ body.toc-wide-margin {
763770
.chapter-sticky-bar-label {
764771
flex: 1;
765772
min-width: 0;
766-
overflow: hidden;
767-
text-overflow: ellipsis;
768-
white-space: nowrap;
773+
white-space: normal;
774+
overflow-wrap: anywhere;
769775
text-align: left;
770776
}
771777

‎src/web/static/css/styles.css‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
body {
7474
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
7575
line-height: 1.6;
76+
overflow-wrap: anywhere;
7677
color: var(--text-primary);
7778
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
7879
min-height: 100vh;
@@ -301,9 +302,6 @@ body {
301302
border-radius: 4px;
302303
font-size: 0.9rem;
303304
max-width: 400px;
304-
overflow: hidden;
305-
text-overflow: ellipsis;
306-
white-space: nowrap;
307305
}
308306

309307
.url-score {
@@ -635,7 +633,6 @@ body {
635633
font-size: 0.85rem;
636634
color: var(--text-secondary);
637635
line-height: 1.4;
638-
word-break: break-word;
639636
}
640637

641638
.history-meta {
@@ -922,7 +919,6 @@ body {
922919
.url-display {
923920
max-width: 100%;
924921
font-size: 0.85rem;
925-
word-break: break-all;
926922
}
927923

928924
.url-score {
@@ -959,7 +955,6 @@ body {
959955

960956
.history-url {
961957
font-size: 0.8rem;
962-
word-break: break-all;
963958
line-height: 1.2;
964959
}
965960

@@ -1238,3 +1233,13 @@ body {
12381233
font-size: 1.35rem;
12391234
}
12401235
}
1236+
1237+
/* Shared wrapping and sizing baseline for third-party text and action controls. */
1238+
button,
1239+
.btn,
1240+
.history-btn,
1241+
.page-btn,
1242+
.site-nav-link {
1243+
white-space: nowrap;
1244+
flex-shrink: 0;
1245+
}

‎src/web/static/history.html‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
font-size: 0.75rem;
154154
color: var(--text-tertiary);
155155
flex: 1;
156+
min-width: 0;
156157
overflow: hidden;
157158
text-overflow: ellipsis;
158159
white-space: nowrap;
@@ -210,8 +211,6 @@
210211
align-items: center;
211212
gap: 4px;
212213
min-width: 120px;
213-
white-space: nowrap;
214-
overflow: hidden;
215214
}
216215
.multi-select:hover { border-color: var(--accent-primary); }
217216
.multi-dropdown {
@@ -291,6 +290,7 @@
291290

292291
/* 标题列容器:min-width:0 是让 grid 1fr 内 ellipsis 生效的关键 */
293292
.task-title-cell { min-width: 0; overflow: hidden; }
293+
.task-row-action { min-width: 0; }
294294

295295
.task-title {
296296
font-size: 0.875rem;
@@ -313,7 +313,6 @@
313313
background: var(--bg-secondary);
314314
color: var(--text-secondary);
315315
text-align: center;
316-
white-space: nowrap;
317316
}
318317
.task-webhook {
319318
font-size: 0.7rem;
@@ -455,6 +454,7 @@
455454
padding: 10px;
456455
}
457456
.task-platform { display: none; }
457+
.task-row-action { grid-column: 3; }
458458
.task-time { font-size: 0.65rem; }
459459

460460
.summary-tooltip { width: calc(100vw - 24px); }
@@ -1351,9 +1351,9 @@ <h3 data-i18n="web.history.alias_heading">Webhook 别名(保存在本地浏览
13511351
}
13521352

13531353
const titleHtml = `
1354-
<div class="task-title">${esc(displayTitle)}</div>
1354+
<div class="task-title" title="${esc(displayTitle)}">${esc(displayTitle)}</div>
13551355
<div class="task-author" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
1356-
${item.filename ? `<span>📁 ${esc(item.filename)}</span>` : ''}
1356+
${item.filename ? `<span title="${esc(item.filename)}">📁 ${esc(item.filename)}</span>` : ''}
13571357
<span>⏱️ ${esc(retentionLabel)}</span>
13581358
${shareBadge}
13591359
${item.source_url ? `<span title="${esc(item.source_url)}">${t('web.history.source_link')}</span>` : ''}
@@ -1379,7 +1379,7 @@ <h3 data-i18n="web.history.alias_heading">Webhook 别名(保存在本地浏览
13791379
<div style="display:flex; flex-direction:column; gap:4px; align-items:center;">
13801380
<div class="task-platform">📁 ${t('web.history.local_upload')}</div>
13811381
<button type="button" class="btn btn-ghost btn-stop-share"
1382-
style="font-size:0.7rem; padding:2px 8px; color:var(--error-text); border-color:var(--error-border); white-space:nowrap;"
1382+
style="font-size:0.7rem; padding:2px 8px; color:var(--error-text); border-color:var(--error-border);"
13831383
data-upload-id="${esc(item.upload_id)}"
13841384
onclick="event.stopPropagation(); stopShare('${esc(item.upload_id)}')">
13851385
${t('web.history.stop_share')}
@@ -1394,7 +1394,7 @@ <h3 data-i18n="web.history.alias_heading">Webhook 别名(保存在本地浏览
13941394
${checkboxHtml}
13951395
<div class="task-time">${esc(time)}</div>
13961396
<div class="task-title-cell">${titleHtml}</div>
1397-
${actionHtml}
1397+
<div class="task-row-action">${actionHtml}</div>
13981398
</div>`;
13991399
}
14001400

@@ -1407,8 +1407,8 @@ <h3 data-i18n="web.history.alias_heading">Webhook 别名(保存在本地浏览
14071407
: '—';
14081408

14091409
const titleHtml = item.title
1410-
? `<div class="task-title">${esc(item.title)}</div><div class="task-author">${esc(item.author || '')}</div>`
1411-
: `<div class="task-title" style="color:var(--text-tertiary)">${esc(item.video_url || t('web.history.no_title'))}</div>
1410+
? `<div class="task-title" title="${esc(item.title)}">${esc(item.title)}</div><div class="task-author" title="${esc(item.author || '')}">${esc(item.author || '')}</div>`
1411+
: `<div class="task-title" title="${esc(item.video_url || t('web.history.no_title'))}" style="color:var(--text-tertiary)">${esc(item.video_url || t('web.history.no_title'))}</div>
14121412
${!isCompleted ? `<div class="task-author"><span class="task-status-badge status-${esc(item.status)}">${esc(item.status)}</span></div>` : ''}`;
14131413

14141414
const platformLabel = esc(item.platform ? (PLATFORM_LABELS[item.platform] || item.platform) : '—');
@@ -1433,7 +1433,7 @@ <h3 data-i18n="web.history.alias_heading">Webhook 别名(保存在本地浏览
14331433
${checkboxHtml}
14341434
<div class="task-time">${esc(time)}</div>
14351435
<div class="task-title-cell">${titleHtml}</div>
1436-
<div class="task-platform">${platformLabel}</div>
1436+
<div class="task-platform" title="${platformLabel}">${platformLabel}</div>
14371437
</div>`;
14381438
}
14391439

‎src/web/static/index.html‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,7 @@
125125
font-weight: 600;
126126
font-size: 0.9rem;
127127
color: var(--text-primary);
128-
white-space: nowrap;
129-
overflow: hidden;
130-
text-overflow: ellipsis;
128+
overflow-wrap: anywhere;
131129
}
132130
.upload-file-size {
133131
font-size: 0.8rem;

‎src/web/static/js/app.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ function buildHistoryItemHTML(task) {
142142
<span class="original-text-content">${escapeHTML(originalTextPreview)}</span>
143143
</div>
144144
` : ''}
145-
${task.url ? `<div class="history-url">${escapeHTML(task.url)}</div>` : ''}
145+
${task.url && (!task.original_text || task.original_text.trim() !== task.url.trim()) ? `<div class="history-url">${escapeHTML(task.url)}</div>` : ''}
146146
<div class="history-meta">
147147
<span>${timeStr}</span>
148148
${isUpload ? `<span class="feature-tag">• ${window.t('web.js.local_upload')}</span>` : ''}

0 commit comments

Comments
 (0)