Merge pull request #360 from Sycun/codex/fix-mobile-project-ui

fix: restore mobile project layout, scrolling, and table readability
This commit is contained in:
公明 authored and GitHub committed 2026-10-16 13:37:27 +08:00
commit 1da911fb10
3 files changed
+381 -7

No files matched your search

+265
View File
@@ -0,0 +1,265 @@
/* Offline browser regression for the project header overlap and tab hit targets.
* Requires Playwright and Chrome (or CHROME_PATH); no server/login/data is used.
* Run: node --test scripts/project-mobile-layout.test.cjs
*/
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const template = read('web/templates/index.html');
const start = template.indexOf('<main class="projects-detail"');
assert.ok(start >= 0, 'project detail fixture must exist');
const markup = template.slice(start, template.indexOf('</main>', start) + 7);
const source = read('web/static/js/projects.js');
const switchTab = source.slice(source.indexOf('function switchProjectTab('), source.indexOf('async function loadProjectAssets('));
const tabs = ['facts', 'graph', 'assets', 'conversations', 'vulns', 'settings'];
test('project headers contain their content and all tabs receive mobile taps', async () => {
const browser = await chromium.launch({
headless: true,
...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}),
});
try {
for (const width of [320, 390, 430, 767, 768, 900, 1440]) {
const context = await browser.newContext({ viewport: { width, height: 900 }, isMobile: width < 768, hasTouch: true });
try {
const page = await context.newPage();
// Keep the real markup and both complete stylesheets, including theme overrides.
await page.setContent(`<html><head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div class="container"><header><div class="header-content">CyberStrikeAI</div></header><div id="page-projects" class="page active"><div class="page-content projects-page-layout">${markup}</div></div></div></body></html>`);
for (const file of ['style.css', 'mobile.css']) await page.addStyleTag({ content: read(`web/static/css/${file}`) });
// Use the real tab handler; replace only data loaders to keep the test offline.
await page.addScriptTag({ content: `let currentProjectTab = 'facts'; window.loads = [];
${['Facts', 'FactGraph', 'Assets', 'Conversations', 'Vulnerabilities'].map(name => `function loadProject${name}() { window.loads.push('${name}'); }`).join('\n')}
${switchTab}` });
for (const locale of ['zh-CN', 'en-US', 'ru-RU']) {
const translations = JSON.parse(read(`web/static/i18n/${locale}.json`));
await page.evaluate(dict => {
document.querySelectorAll('[data-i18n]').forEach(el => {
if (el.children.length || el.hasAttribute('data-i18n-attr')) return;
const value = el.dataset.i18n.split('.').reduce((obj, key) => obj?.[key], dict);
if (typeof value === 'string') el.textContent = value;
});
document.querySelector('.projects-detail').classList.add('has-project');
document.querySelector('#projects-detail-inner').hidden = false;
document.querySelector('#projects-detail-title').textContent = '玉米 — Long project name / '.repeat(8);
const desc = document.querySelector('#projects-detail-desc');
desc.hidden = false;
desc.textContent = 'Long project description / 项目描述 '.repeat(20);
document.querySelector('#project-stat-sparse').hidden = false;
document.querySelector('#projects-detail-meta-time').textContent = '2 days ago';
}, translations);
for (const theme of ['light', 'dark']) {
const label = `${width}px ${locale} ${theme}`;
await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme);
await page.locator('.projects-detail-header').scrollIntoViewIfNeeded();
const layout = await page.evaluate(() => {
const header = document.querySelector('.projects-detail-header');
const bounds = header.getBoundingClientRect();
const children = ['.projects-detail-headline', '.projects-detail-desc', '.projects-detail-header-actions'];
return {
contained: children.every(selector => {
const r = document.querySelector(selector).getBoundingClientRect();
return r.top >= bounds.top && r.bottom <= bounds.bottom;
}),
separate: document.querySelector('.projects-tabs').getBoundingClientRect().top >= bounds.bottom,
appHeight: document.querySelector('.container > header').getBoundingClientRect().height,
overflow: document.documentElement.scrollWidth > innerWidth,
};
});
assert.ok(layout.contained, `${label}: header content escapes its bounds`);
assert.ok(layout.separate, `${label}: header overlaps tabs`);
assert.equal(layout.overflow, false, `${label}: page overflows horizontally`);
if (width < 768) {
assert.equal(layout.appHeight, 52, `${label}: app bar height changed`);
assert.ok(await page.locator('#project-facts-search').evaluate(input => {
const bounds = input.getBoundingClientRect();
const icon = document.querySelector('.projects-fact-search-icon').getBoundingClientRect();
const textStart = bounds.left + parseFloat(getComputedStyle(input).paddingLeft);
return textStart > icon.right && Math.abs((icon.top + icon.bottom - bounds.top - bounds.bottom) / 2) < 1;
}), `${label}: search icon overlaps text or is off center`);
}
for (const tab of [...tabs, 'facts']) {
const button = page.locator(`#project-tab-${tab}`);
await button.scrollIntoViewIfNeeded();
await button.tap({ timeout: 3000 });
assert.ok(await button.evaluate(el => {
const r = el.getBoundingClientRect();
const nav = el.parentElement.getBoundingClientRect();
const x = (Math.max(r.left, nav.left, 0) + Math.min(r.right, nav.right, innerWidth)) / 2;
return el.contains(document.elementFromPoint(x, r.y + r.height / 2));
}), `${label}: ${tab} hit target is covered`);
await assert.doesNotReject(() => page.waitForFunction(tab => {
return document.querySelector(`#project-tab-${tab}`).classList.contains('is-active') &&
[...document.querySelectorAll('.projects-panel')].every(panel => panel.hidden === (panel.id !== `project-panel-${tab}`));
}, tab, { timeout: 1000 }), `${label}: ${tab} failed to switch`);
}
}
}
} finally {
await context.close();
}
}
} finally {
await browser.close();
}
});
// Unlike the header-only fixture above, this uses the entire project page inside
// the actual flex shell, including its fixed heading, project list and bottom bar.
test('mobile project page scrolls its final row above the bottom navigation', async () => {
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}) });
try {
for (const [width, height, visibleHeight] of [[320, 568, 568], [393, 852, 700], [430, 932, 780], [767, 600, 500]]) {
const context = await browser.newContext({ viewport: { width, height }, isMobile: true, hasTouch: true });
try {
const page = await context.newPage();
const projectPage = await page.evaluate(template => new DOMParser().parseFromString(template, 'text/html').querySelector('#page-projects').outerHTML, template);
await page.setContent(`<html><head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div class="container"><header><div class="header-content">CyberStrikeAI</div></header><div class="main-layout"><div class="content-area">${projectPage}</div></div></div><nav id="m-tabbar">底部导航</nav></body></html>`);
for (const file of ['style.css', 'mobile.css']) await page.addStyleTag({ content: read(`web/static/css/${file}`) });
await page.evaluate(visibleHeight => {
document.documentElement.style.setProperty('--m-app-height', `${visibleHeight}px`);
document.documentElement.style.setProperty('--m-safe-bottom', '20px');
document.querySelector('#page-projects').classList.add('active');
document.querySelector('.projects-detail').classList.add('has-project');
document.querySelector('#projects-detail-inner').hidden = false;
document.querySelector('#projects-detail-title').textContent = 'Long project name / 项目名称 '.repeat(6);
document.querySelector('#projects-list').innerHTML = '<div class="projects-list-item">项目一</div>'.repeat(3);
document.querySelector('#projects-pagination').textContent = '1–3/3 1/1 每页 50';
}, visibleHeight);
await page.addScriptTag({ content: `let currentProjectTab = 'facts';
${['Facts', 'FactGraph', 'Assets', 'Conversations', 'Vulnerabilities'].map(name => `function loadProject${name}() {}`).join('\n')}
${switchTab}` });
for (const theme of ['light', 'dark']) {
await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme);
for (const rowCount of [1, 35]) {
for (const tab of ['facts', 'assets', 'conversations', 'vulns']) {
const label = `${width}x${height}/${visibleHeight} ${theme} ${tab} ${rowCount} rows`;
await page.evaluate(({tab, rowCount}) => {
window.switchProjectTab(tab);
const panel = document.querySelector(`#project-panel-${tab}`);
const cells = panel.querySelectorAll('thead th').length;
panel.querySelector('tbody').innerHTML = Array.from({length: rowCount}, (_, i) => `<tr><td><button type="button" ${i === rowCount - 1 ? 'data-last-row' : ''} onclick="window.lastRowClicked=true">${i + 1}</button></td>${'<td>test</td>'.repeat(cells - 1)}</tr>`).join('');
const scroller = document.querySelector('.projects-page-layout');
scroller.scrollTop = scroller.scrollHeight;
}, {tab, rowCount});
if (rowCount === 35 && tab === 'conversations' && theme === 'light') {
await page.evaluate(() => { document.querySelector('.projects-page-layout').scrollTop = 0; });
const bounds = await page.locator('.projects-page-layout').boundingBox();
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height - 20);
await page.mouse.wheel(0, 500);
await page.waitForFunction(() => document.querySelector('.projects-page-layout').scrollTop > 0);
await page.evaluate(() => {
const scroller = document.querySelector('.projects-page-layout');
scroller.scrollTop = scroller.scrollHeight;
});
}
const result = await page.evaluate(tab => {
const scroller = document.querySelector('.projects-page-layout');
const row = document.querySelector(`#project-panel-${tab} [data-last-row]`);
const r = row.getBoundingClientRect();
const area = scroller.getBoundingClientRect();
const bar = document.querySelector('#m-tabbar').getBoundingClientRect();
return {areaBottom: area.bottom, barTop: bar.top, rowTop: r.top, rowBottom: r.bottom, areaTop: area.top,
hit: row.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))};
}, tab);
assert.ok(result.areaBottom <= result.barTop + 1, `${label}: page scroller extends under bottom bar ${JSON.stringify(result)}`);
assert.ok(result.rowTop >= result.areaTop && result.rowBottom <= result.areaBottom, `${label}: final row is clipped ${JSON.stringify(result)}`);
assert.ok(result.hit, `${label}: final row cannot receive a tap`);
await page.evaluate(() => { window.lastRowClicked = false; });
await page.locator(`#project-panel-${tab} [data-last-row]`).tap();
assert.equal(await page.evaluate(() => window.lastRowClicked), true, `${label}: final row tap failed`);
}
}
for (const tab of ['graph', 'settings']) {
const result = await page.evaluate(tab => {
window.switchProjectTab(tab);
const scroller = document.querySelector('.projects-page-layout');
scroller.scrollTop = scroller.scrollHeight;
const footer = document.querySelector(tab === 'graph' ? '.project-fact-graph-footer' : '.projects-settings-footer');
const r = footer.getBoundingClientRect();
const area = scroller.getBoundingClientRect();
return {visible: r.top >= area.top && r.bottom <= area.bottom,
graphHeight: document.querySelector('.project-fact-graph-layout').getBoundingClientRect().height};
}, tab);
assert.ok(result.visible, `${width}px ${theme} ${tab}: footer is clipped`);
if (tab === 'graph') assert.ok(result.graphHeight >= 240, `${width}px ${theme}: graph canvas collapsed`);
}
}
} finally { await context.close(); }
}
} finally { await browser.close(); }
});
test('mobile facts keep badges and actions inside their columns while scrolling horizontally', async () => {
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}) });
try {
for (const width of [320, 393, 767]) {
const context = await browser.newContext({viewport: {width, height: 900}, isMobile: true, hasTouch: true});
try {
const page = await context.newPage();
await page.setContent(`<html><head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div class="container"><header>CyberStrikeAI</header><div id="page-projects" class="page active"><div class="page-content projects-page-layout">${markup}</div></div></div></body></html>`);
for (const file of ['style.css', 'mobile.css']) await page.addStyleTag({content: read(`web/static/css/${file}`)});
await page.addScriptTag({content: source});
const mobileSource = read('web/static/js/mobile-ui.js');
const wrapTables = mobileSource.slice(mobileSource.indexOf(' function wrapTables('), mobileSource.indexOf(' /* ---------------------------------------------------------------------\n 11.'));
await page.addScriptTag({content: `const isMobile = () => true;
const $$ = (selector, root) => Array.from(root.querySelectorAll(selector));
const el = (tag, cls) => Object.assign(document.createElement(tag), {className: cls});
${wrapTables}
wrapTables(document);`});
await page.evaluate(() => {
currentProjectId = 'fixture-project';
document.querySelector('.projects-detail').classList.add('has-project');
document.querySelector('#projects-detail-inner').hidden = false;
window.apiFetch = async () => ({ok: true, json: async () => [
{id:'note', fact_key:'note/automatic-continuation', category:'note', summary:'自动续接的项目记录 '.repeat(10), body:'详细记录', confidence:'tentative', updated_at:'2026-10-12T10:00:00Z'},
{id:'finding', fact_key:'finding/8080/exposure', category:'finding', summary:'8080 / exposed service', body:'## Steps\n1. curl http://target:8080\n## Request\nGET /', confidence:'confirmed', updated_at:'2026-10-12T10:00:00Z', link_counts:{incoming:2,outgoing:10}},
]});
refreshProjectHeaderStats = () => {};
window.showEditFactModal = () => { window.actionClicked = 'edit'; };
window.viewProjectFactBody = () => { window.actionClicked = 'details'; };
});
for (const locale of ['zh-CN', 'en-US', 'ru-RU']) {
await page.evaluate(dict => {
window.t = key => key.split('.').reduce((v, k) => v?.[k], dict) || key;
document.querySelectorAll('thead [data-i18n]').forEach(el => { el.textContent = window.t(el.dataset.i18n); });
}, JSON.parse(read(`web/static/i18n/${locale}.json`)));
await page.evaluate(() => loadProjectFacts());
for (const theme of ['light', 'dark']) {
const label = `${width}px ${locale} ${theme}`;
await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme);
const layout = await page.evaluate(() => {
const table = document.querySelector('#project-panel-facts table');
return {
width:table.getBoundingClientRect().width,
badCells: [...table.querySelectorAll('thead th, tbody td')].filter(cell => {
const r = cell.getBoundingClientRect();
return [...cell.children].some(child => {
const c = child.getBoundingClientRect();
return c.left < r.left - 1 || c.right > r.right + 1;
});
}).map(cell => cell.className || cell.textContent),
sticky: getComputedStyle(table.querySelector('td.col-actions')).position,
};
});
assert.ok(layout.width >= 1200, `${label}: eight columns are squeezed to the phone width`);
assert.deepEqual(layout.badCells, [], `${label}: badges or actions escape their cells`);
assert.notEqual(layout.sticky, 'sticky', `${label}: action column overlays fact data`);
const scroller = page.locator('#project-panel-facts .projects-table-wrap');
await scroller.evaluate(el => { el.scrollLeft = el.scrollWidth; });
for (const [selector, action] of [['.projects-action-btn--edit', 'edit'], ['.projects-action-btn--view', 'details']]) {
await page.locator(`#project-facts-tbody ${selector}`).first().tap({timeout: 3000});
assert.equal(await page.evaluate(() => window.actionClicked), action, `${label}: ${action} is unreachable`);
}
await scroller.evaluate(el => { el.scrollLeft = 0; });
}
}
} finally { await context.close(); }
}
} finally { await browser.close(); }
});
+115 -6
View File
@@ -174,7 +174,9 @@
3. 顶部应用栏
-------------------------------------------------------------------------- */
@media (max-width: 767.98px) {
header {
/* Only the application bar has a fixed height and overlays page content.
Nested headers (e.g. project details/settings) must grow with their content. */
.container > header {
height: var(--m-appbar-h);
min-height: var(--m-appbar-h);
padding: 0 var(--m-gutter);
@@ -876,6 +878,15 @@
border-radius: 10px;
}
/* Preserve the fact search icon gutter after the generic mobile input padding. */
.projects-fact-filter-field--search input {
padding-left: 34px;
}
.projects-fact-search-icon {
bottom: calc((var(--m-touch) - 16px) / 2);
}
textarea {
min-height: 96px;
resize: vertical;
@@ -2110,6 +2121,104 @@ body.m-panel-open .chat-input-container {
overflow: visible !important;
}
/* Projects: one vertical scroller must fit between the page heading and
bottom navigation. Desktop viewport minimums and nested flex scrollers
otherwise put the final rows outside the clipped mobile page. */
#page-projects .page-content.projects-page-layout {
display: block;
min-height: 0;
overflow-y: auto;
}
#page-projects .projects-sidebar-card {
height: auto;
min-height: 0;
max-height: none;
margin-bottom: 12px;
}
#page-projects .projects-list {
max-height: 220px;
}
#page-projects .projects-detail,
#page-projects .projects-detail-inner,
#page-projects .projects-panel,
#page-projects .projects-table-wrap,
#page-projects .projects-settings-layout {
flex: none;
height: auto;
min-height: 0;
max-height: none;
}
#page-projects .projects-panel,
#page-projects .projects-settings-layout {
overflow: visible;
}
#page-projects .projects-table-wrap {
overflow-x: auto;
overscroll-behavior-y: auto;
}
/* The project wrapper already owns scrolling; the generic mobile wrapper
must not add a second scrollbar or negative gutters inside the card. */
#page-projects .projects-table-wrap > .m-table-scroll {
overflow: visible;
margin: 0;
padding: 0;
background: none;
}
/* Eight fact columns need readable widths. Keep one horizontal scroll area
and let actions scroll with their row instead of covering other cells. */
#page-projects #project-panel-facts .data-table--projects {
width: 1440px;
min-width: 1440px;
}
#page-projects #project-panel-facts th:nth-child(1) { width: 160px; }
#page-projects #project-panel-facts th:nth-child(2) { width: 120px; }
#page-projects #project-panel-facts th:nth-child(3) { width: 280px; }
#page-projects #project-panel-facts th:nth-child(4) { width: 100px; }
#page-projects #project-panel-facts th:nth-child(5) { width: 120px; }
#page-projects #project-panel-facts th:nth-child(6) { width: 140px; }
#page-projects #project-panel-facts th:nth-child(7) { width: 160px; }
#page-projects #project-panel-facts th:nth-child(8) { width: 360px; }
#page-projects #project-panel-facts .data-table--projects .col-actions {
position: static;
max-width: none;
box-shadow: none;
}
#page-projects #project-panel-facts .col-actions .projects-table-actions {
flex-wrap: wrap;
}
#page-projects #project-panel-facts :is(.projects-category, .projects-fact-badge, .projects-confidence) {
max-width: 100%;
box-sizing: border-box;
white-space: normal;
overflow-wrap: anywhere;
}
#page-projects #project-panel-assets .data-table--projects {
height: auto;
}
#page-projects #project-panel-assets .data-table--projects tbody {
flex: none;
overflow: visible;
}
#page-projects .project-fact-graph-layout {
flex: none;
height: 360px;
max-height: none;
}
/* 21.5 C2 会话「重置」按钮实测 24x44,横向点不中 */
#page-c2-sessions button,
.c2-sessions-page button {
@@ -3047,10 +3156,10 @@ html[data-theme="dark"] {
@media (max-width: 767.98px) {
/* 25.1 顶栏 / 底栏:透出材质 + 发丝分隔线。
顶栏必须写成 html[data-theme] header:style.css 用同一条特异性给暗色塞了
顶栏必须带 html[data-theme]:style.css 用主题选择器给暗色塞了
不透明渐变,只写 header 会在暗色下丢掉材质(实测 background 被压成渐变)。 */
header,
html[data-theme] header {
.container > header,
html[data-theme] .container > header {
background: var(--m-material-solid);
border-bottom: 1px solid var(--m-hairline);
box-shadow: none;
@@ -3063,8 +3172,8 @@ html[data-theme="dark"] {
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
header,
html[data-theme] header {
.container > header,
html[data-theme] .container > header {
background: var(--m-material);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
+1 -1
View File
@@ -45,7 +45,7 @@
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<!-- 移动端样式层:全部规则限定在 767.98px 以下,桌面端零影响 -->
<link rel="stylesheet" href="/static/css/mobile.css?v=20261007-glass-1">
<link rel="stylesheet" href="/static/css/mobile.css?v=20261015-project-table-3">
<script src="/static/js/router.js?v=20261011-token-settings"></script>
</head>
<body>