=32 且 label 两个方向都到下限才算合格,否则照报 tiny */
+ var pass=false;
+ /* 本层对复选/单选框的口径是 30px(见 mobile.css 24.11),整行/label 才是命中区 */
+ if((el.type==='checkbox'||el.type==='radio')&&w>=30&&h>=30){
+ /* 命中区常常由 label 或整行提供(本层 24.11 就是按"整行可点"写的):
+ 向上找第一个真正可点的祖先,它到下限就算合格 */
+ var n=el, d=0;
+ while(n&&n!==document.body&&d++<5){
+ var clickable=n.tagName==='LABEL'||n.hasAttribute('onclick')||getComputedStyle(n).cursor==='pointer';
+ if(clickable){var nb=n.getBoundingClientRect(); if(nb.width>=MIN&&nb.height>=MIN){pass=true;break;}}
+ n=n.parentElement;
+ }
+ }
+ if(!pass){ out.why='tiny'; bad.push(out); return; }
+ }
+ var cx=r.left+w/2, cy=r.top+h/2;
+ if(cx<0||cy<0||cx>vw||cy>vh){
+ var cl=clipAncestor(el), sx=scrollAncestor(el,'x'), sy=scrollAncestor(el,'y');
+ out.why='offscreen'; out.clip=cl?nm(cl):null; out.swipes=[sx?'x':null,sy?'y':null].filter(Boolean).join('');
+ bad.push(out); return;
+ }
+ var hitTop=document.elementFromPoint(cx,cy);
+ var ok=false, cur=hitTop;
+ while(cur){ if(cur===el){ok=true;break} cur=cur.parentElement; }
+ if(!ok){
+ cur=el;
+ while(cur&&!ok){ if(cur.contains&&hitTop&&cur.contains(hitTop)) ok=true; cur=cur.parentElement; }
+ }
+ if(!ok&&hitTop){
+ /* delegation: a handler on an ancestor is a legitimate target */
+ var a=el.parentElement, d=0;
+ while(a&&d++<4){ if(a.contains(hitTop)){ok=true;break} a=a.parentElement; }
+ }
+ if(!ok){
+ out.why='covered';
+ out.blocker=hitTop?nm(hitTop):'none';
+ out.blockerPath=hitTop?path(hitTop):'';
+ var of=overFixed(r); if(of) out.blockerFixed=of;
+ bad.push(out); return;
+ }
+ if(el.id) chromeSeen[el.id]=1;
+ });
+
+ var scr=document.scrollingElement||document.documentElement;
+ return {
+ vw:vw,vh:vh,scanned:scanned,bad:bad,
+ doc:{h:Math.round(scr.scrollHeight),clientH:scr.clientHeight,canScrollY:scr.scrollHeight>scr.clientHeight+2,
+ bodyOverflowY:getComputedStyle(document.body).overflowY,htmlTouch:getComputedStyle(document.documentElement).touchAction},
+ chrome:{tabbar:!!document.querySelector('#m-tabbar'),sheet:!!document.querySelector('#m-sheet'),
+ menu:!!document.querySelector('.m-menu-btn'),more:!!document.querySelector('.m-more-btn'),
+ chatbar:!!document.querySelector('#m-chat-bar'),scrim:!!document.querySelector('#m-scrim')},
+ locale:(document.documentElement.getAttribute('lang')||'')
+ };
+})()`;
+
+/* DETECT 默认扫"当前页 + 全局固定件"。遮罩展开时页面元素会被 scrim 判成 covered,
+ 所以扫抽屉/面板要限定作用域,否则全是假红。 */
+const detectFor = scope => DETECT.replace('/*SCOPE*/',
+ scope ? `if(!el.closest(${JSON.stringify(scope)})) return;` : '');
+
+async function openPage(hash) {
+ await ev(`location.hash=${JSON.stringify(hash)}`);
+ await sleep(2600);
+}
+
+const host = new URL(BASE).hostname;
+if (COOKIE) await send('Network.setCookie', { name: 'auth_token', value: COOKIE, domain: host, path: '/' }, sessionId);
+await send('Page.navigate', { url: BASE + '/' }, sessionId);
+await sleep(3500);
+
+/* The login card is a phone surface too — check it before authenticating. */
+const login = await ev(DETECT);
+if (!COOKIE) {
+ await ev(`(()=>{const u=document.getElementById('login-username'),p=document.getElementById('login-password');if(u&&p){u.value=${JSON.stringify(USER)};p.value=${JSON.stringify(PASS)};document.getElementById('login-form').requestSubmit();}})()`);
+ await sleep(8000);
+} else {
+ console.log('using the session cookie supplied via --cookie; no password was sent');
+}
+const who = await ev(`(()=>{const o=document.getElementById('login-overlay');return o?getComputedStyle(o).display:'none'})()`);
+if (who !== 'none') { console.error('login did not take (overlay still displayed):', who); process.exit(3); }
+
+const report = { login, pages: {}, chromeTaps: [] };
+let grand = {};
+const bump = k => { grand[k] = (grand[k] || 0) + 1; };
+
+const log = (o, k) => { (o.bad || []).forEach(b => bump(b.why)); };
+
+for (const pg of PAGES) {
+ await openPage(pg);
+ const r = await ev(DETECT);
+ if (!r || r.__err) { console.log(pg, 'DETECT ERROR', r && r.__err); continue; }
+ report.pages[pg] = r;
+ log(r);
+ const n = r.bad.length;
+ console.log(`${pg.padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(n).padStart(3)} ${n ? '' : ' OK'}`);
+ r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`));
+ const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId);
+ fs.writeFileSync(`${OUT}/${pg}.png`, Buffer.from(shot.data, 'base64'));
+}
+
+/* ---------------- overlays: 抽屉与面板展开后的按钮 ---------------- */
+const OVERLAYS = [
+ { name: 'sheet', route: 'dashboard', open: `CSAMobile.openSheet()`, scope: '#m-sheet' },
+ { name: 'drawer', route: 'dashboard', open: `CSAMobile.openDrawer()`, scope: '#main-sidebar' },
+ { name: 'chat-drawer', route: 'chat', open: `CSAMobile.openChatDrawer()`, scope: '#conversation-sidebar' },
+];
+for (const o of OVERLAYS) {
+ await openPage(o.route);
+ await ev(`(()=>{try{${o.open}}catch(e){}})()`);
+ await sleep(900); /* 等开合动画结束,动画中量到的是位移中的盒子 */
+ const r = await ev(detectFor(o.scope));
+ if (!r || r.__err) { console.log(o.name, 'DETECT ERROR', r && r.__err); continue; }
+ report.pages[o.name] = r;
+ log(r);
+ console.log(`${('[' + o.name + ']').padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(r.bad.length).padStart(3)} ${r.bad.length ? '' : ' OK'}`);
+ r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`));
+ await ev(`CSAMobile.closeSheet();CSAMobile.closeDrawer();CSAMobile.closeChatDrawer();`);
+}
+
+/* ---------------- chrome finger taps ---------------- */
+/* 关闭要走用户真能点到的控件:抽屉展开后汉堡键被抽屉自己盖住,
+ 再点它点的是侧栏(真实用户用抽屉内的 × 或遮罩)。每步前先复位,
+ 否则一条失败会连锁把后面的断言全带偏。 */
+const RESET = `CSAMobile.closeDrawer();CSAMobile.closeSheet();CSAMobile.closeChatDrawer();`;
+const TAPS = [
+ { name: 'menu-open', sel: '.m-menu-btn', pre: RESET, expect: `document.querySelector('#main-sidebar').classList.contains('m-open')` },
+ { name: 'menu-close', sel: '#main-sidebar .m-close-btn', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` },
+ { name: 'menu-scrim-close', sel: '#m-scrim', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` },
+ { name: 'more-open', sel: '.m-more-btn', pre: RESET, expect: `document.querySelector('#m-sheet').classList.contains('m-show')` },
+ { name: 'more-close', sel: '#m-sheet .m-close-btn', pre: `${RESET}CSAMobile.openSheet();`, expect: `!document.querySelector('#m-sheet').classList.contains('m-show')` },
+ { name: 'tab-hitl', sel: '#m-tabbar [data-tab=hitl]', expect: `document.querySelector('#page-hitl').classList.contains('active')` },
+ { name: 'tab-tasks', sel: '#m-tabbar [data-tab=tasks]', expect: `document.querySelector('#page-tasks').classList.contains('active')` },
+ { name: 'tab-dashboard', sel: '#m-tabbar [data-tab=dashboard]', expect: `document.querySelector('#page-dashboard').classList.contains('active')` },
+ { name: 'tab-chat', sel: '#m-tabbar [data-tab=chat]', expect: `document.querySelector('#page-chat').classList.contains('active')` },
+ { name: 'chat-drawer-open', sel: '#m-chat-bar button:first-child', expect: `document.querySelector('#conversation-sidebar').classList.contains('m-open')` },
+ { name: 'chat-drawer-close', sel: '#conversation-sidebar .m-close-btn', expect: `!document.querySelector('#conversation-sidebar').classList.contains('m-open')` },
+];
+
+if (CHROME) {
+ await openPage('chat');
+ for (const t of TAPS) {
+ if (t.pre) await ev(`(()=>{try{${t.pre}}catch(e){}})()`);
+ await sleep(320);
+ const box = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(t.sel)});if(!e)return null;const r=e.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2,w:r.width,h:r.height,shown:getComputedStyle(e).display!=='none'}})()`);
+ if (!box || !box.shown) { report.chromeTaps.push({ name: t.name, ok: false, why: 'absent' }); console.log(`tap ${t.name.padEnd(20)} ABSENT`); continue; }
+ events.length = 0;
+ await fingerTap(box.x, box.y);
+ const passed = await ev(`(()=>{try{return !!(${t.expect})}catch(e){return false}})()`);
+ const err = events.includes('Runtime.exceptionThrown');
+ report.chromeTaps.push({ name: t.name, ok: !!passed && !err, state: passed, err });
+ console.log(`tap ${t.name.padEnd(20)} ${passed && !err ? 'ok' : 'FAIL'} (state=${passed} jsErr=${err})`);
+ }
+ /* language round-trip: the injected chrome must re-label itself */
+ await ev(`window.changeLanguage && window.changeLanguage('en-US')`);
+ await sleep(2500);
+ const en = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`);
+ report.langEn = en;
+ console.log('en-US chrome labels:', JSON.stringify(en));
+ await ev(`window.changeLanguage && window.changeLanguage('ru-RU')`);
+ await sleep(2500);
+ const ru = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`);
+ report.langRu = ru;
+ console.log('ru-RU chrome labels:', JSON.stringify(ru));
+ await ev(`window.changeLanguage && window.changeLanguage('zh-CN')`);
+ await sleep(2000);
+}
+
+fs.writeFileSync(JSON_OUT, JSON.stringify(report, null, 2));
+
+const chromeFail = report.chromeTaps.filter(t => !t.ok).length;
+const total = Object.values(grand).reduce((a, b) => a + b, 0);
+console.log(`\n=========== MOBILE TAP AUDIT @${W}x${H} ===========`);
+console.log(`pages ${Object.keys(report.pages).length}`);
+console.log(`covered (occluded) ${grand.covered || 0}`);
+console.log(`offscreen ${grand.offscreen || 0}`);
+console.log(`zero-size ${grand.zero || 0}`);
+console.log(`pointer-events:none ${grand['pointer-none'] || 0}`);
+console.log(`tap target < ${MIN_TAP}px ${grand.tiny || 0}`);
+console.log(`chrome taps failed ${chromeFail}`);
+console.log(`report ${JSON_OUT}`);
+
+/* Ratchet: the钉死的 baseline may only go down. */
+let bl = null;
+try { bl = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); } catch { }
+let ratchetFail = false;
+if (bl) {
+ for (const [k, v] of Object.entries(grand)) {
+ if ((bl[k] ?? 0) < v) { console.log(`RATCHET FAIL ${k}: baseline ${bl[k]} -> now ${v}`); ratchetFail = true; }
+ }
+ for (const k of Object.keys(bl)) {
+ if (k.startsWith('page:')) {
+ const name = k.slice(5), now = (report.pages[name] || {}).bad?.length ?? 0;
+ if (bl[k] < now) { console.log(`RATCHET FAIL ${name}: baseline ${bl[k]} -> now ${now}`); ratchetFail = true; }
+ }
+ }
+ if (chromeFail > (bl.chromeFail ?? 0)) { console.log(`RATCHET FAIL chrome: ${bl.chromeFail} -> ${chromeFail}`); ratchetFail = true; }
+} else {
+ const fresh = { ...grand };
+ for (const [p, r] of Object.entries(report.pages)) fresh['page:' + p] = r.bad.length;
+ fresh.chromeFail = chromeFail;
+ fresh.note = 'generated by scripts/mobile-tap-audit.mjs; numbers may only decrease';
+ fs.writeFileSync(BASELINE, JSON.stringify(fresh, null, 2));
+ console.log(`baseline written -> ${BASELINE}`);
+}
+
+/* 退出码口径(写死,别每次靠人判断):
+ - 够不到 / 被遮挡 / 零尺寸 / pointer-events:none —— 任何一处都是硬失败,控件点不到没有"存量债"可言;
+ - 自家注入的抽屉、面板、会话抽屉里的按钮 —— 必须 0,不许留债;
+ - 桌面遗留控件的小命中区 —— 走基线只降不升,超基线即红。 */
+const hard = (grand.covered || 0) + (grand.offscreen || 0) + (grand.zero || 0) + (grand['pointer-none'] || 0);
+const ownChrome = ['sheet', 'drawer', 'chat-drawer'].reduce((n, k) => n + ((report.pages[k] || {}).bad || []).length, 0);
+const fail = hard || ownChrome || chromeFail || ratchetFail;
+console.log(`\n判定: 硬缺陷 ${hard} | 自家外壳 ${ownChrome} | 点击失败 ${chromeFail} | 基线回潮 ${ratchetFail ? '是' : '否'} -> ${fail ? 'FAIL' : 'PASS'}`);
+process.exit(fail ? 1 : 0);
diff --git a/scripts/mobile-tap-baseline.json b/scripts/mobile-tap-baseline.json
new file mode 100644
index 00000000..8b831cd8
--- /dev/null
+++ b/scripts/mobile-tap-baseline.json
@@ -0,0 +1 @@
+{"tiny": 3, "page:dashboard": 0, "page:chat": 0, "page:projects": 0, "page:vulnerabilities": 0, "page:tasks": 0, "page:workflows": 0, "page:asset-overview": 0, "page:asset-library": 1, "page:info-collect": 0, "page:hitl": 0, "page:tool-guard": 2, "page:webshell": 0, "page:c2-listeners": 0, "page:c2-sessions": 0, "page:c2-tasks": 0, "page:c2-payloads": 0, "page:c2-events": 0, "page:c2-profiles": 0, "page:chat-files": 0, "page:mcp-monitor": 0, "page:mcp-management": 0, "page:knowledge-management": 0, "page:knowledge-retrieval-logs": 0, "page:roles-management": 0, "page:platform-rbac": 0, "page:skills-monitor": 0, "page:skills-management": 0, "page:agents-management": 0, "page:settings": 0, "page:audit-log": 0, "page:monitor": 0, "page:sheet": 0, "page:drawer": 0, "page:chat-drawer": 0, "chromeFail": 0, "note": "generated by scripts/mobile-tap-audit.mjs; numbers may only decrease"}
\ No newline at end of file
diff --git a/web/static/css/api-docs-mobile.css b/web/static/css/api-docs-mobile.css
new file mode 100644
index 00000000..dcda8361
--- /dev/null
+++ b/web/static/css/api-docs-mobile.css
@@ -0,0 +1,301 @@
+/*
+ * CyberStrikeAI — API docs page (/api-docs) on phones.
+ * Separate template from index.html, so it needs its own adaptation layer.
+ * Loaded after the template's inline
+
+
diff --git a/web/templates/index.html b/web/templates/index.html
index 254693bc..a7bd1c61 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -2,7 +2,15 @@
-
+
+
+
+
+
+
+
+
+
CyberStrikeAI
@@ -36,6 +44,8 @@
+
+
@@ -1257,7 +1267,7 @@