diff --git a/scripts/project-mobile-layout.test.cjs b/scripts/project-mobile-layout.test.cjs
index 396e7351..9c11ca8c 100644
--- a/scripts/project-mobile-layout.test.cjs
+++ b/scripts/project-mobile-layout.test.cjs
@@ -98,6 +98,7 @@ test('project headers contain their content and all tabs receive mobile taps', a
}, tab, { timeout: 1000 }), `${label}: ${tab} failed to switch`);
}
}
+
}
} finally {
await context.close();
@@ -107,3 +108,89 @@ test('project headers contain their content and all tabs receive mobile taps', a
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(`
`);
+ 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 = '项目一
'.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) => ` | ${'test | '.repeat(cells - 1)}
`).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(); }
+});
diff --git a/web/static/css/mobile.css b/web/static/css/mobile.css
index c5a21ab3..fbc8c7cd 100644
--- a/web/static/css/mobile.css
+++ b/web/static/css/mobile.css
@@ -2121,6 +2121,62 @@ 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;
+ }
+
+ #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 {
diff --git a/web/templates/index.html b/web/templates/index.html
index 72ca91af..1fce50fa 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -45,7 +45,7 @@
-
+