mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-18 20:55:23 +00:00
## Summary Live page mobile chrome-reduction pass 2. Three coordinated trims at ≤640px: 1. **`.live-header` → single row, ≤44px.** Drop the MESH LIVE text label and the chart-icon (📊) header toggle. Promote `.live-stats-row` to a direct child of `.live-header` so beacon + pkts + nodes + active + rate + gear all sit on one row. The (now empty) `.live-header-body` collapses to `display:none`. `.live-controls-toggle` shrinks to 36×36 to fit the strip. 2. **Top app navbar hidden on `/live`.** `body:has(.live-page) .top-nav { display:none }` — scoped via `:has()` so other routes are unaffected. The `.live-page` height reclaims the freed 52px. 3. **VCR scope row: >6h collapsed into `More ▾`.** `12h` and `24h` get `.vcr-scope-btn--overflow`; the new `.vcr-scope-more-wrap` dropdown is desktop-hidden, mobile-shown. Dropdown items proxy `.click()` to the underlying scope buttons — single source of truth, existing handler unchanged. ## TDD - **RED** (`b975c828`): `test-issue-1234-live-chrome-pass2-e2e.js` — one E2E asserting all three acceptance items at 375×800 + desktop sanity at 1280×800. Wired into `deploy.yml`. Fails on master (no More button, navbar visible, MESH LIVE label visible). - **GREEN** (`1e529e63`): CSS + JS implementation. Updates `test-live-layout-1178-1179-e2e.js` and `test-issue-1204-live-panel-structure-e2e.js` in-place to match the new single-row contract (chart toggle gone, MESH LIVE label gone on mobile, gear shrunk to 36×36). ## Verification (local) - New E2E: 7/7 ✅ - `test-issue-1178-1179`: 10/10 ✅ - `test-issue-1204`: 10/10 ✅ - `test-issue-1205`: 18/18 ✅ - `test-issue-1206`: 7/7 ✅ - `test-live-mql-leak-1180`: 2/2 ✅ - `#1220` empty-chrome guard (in `test-e2e-playwright.js`): header = 38px collapsed ✅ Desktop (1280×800) layout unchanged — top-nav visible, all 4 VCR scopes inline, header behavior identical. Fixes #1234. --------- Co-authored-by: corescope-bot <bot@corescope.local>
This commit is contained in:
@@ -257,6 +257,7 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1205-live-controls-anchor-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1204-live-panel-structure-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1234-live-chrome-pass2-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
@@ -485,6 +485,97 @@
|
||||
.live-stat-pill { font-size: 11px; padding: 2px 7px; }
|
||||
.live-toggles { font-size: 10px; gap: 6px; margin-left: 0; overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; width: 100%; min-width: 0; }
|
||||
.live-title { font-size: 12px; letter-spacing: 1px; }
|
||||
|
||||
/* #1234 chrome-reduction pass 2 — mobile-only adjustments.
|
||||
* - Drop MESH LIVE text label and the chart-icon header toggle; counters
|
||||
* speak for themselves on a narrow phone.
|
||||
* - Force the body to render inline (cancel the #1178/#1179 collapse on
|
||||
* .live-header-body — the chart toggle that drove it is gone).
|
||||
* - Keep the header to a single row by removing flex-wrap so all pills
|
||||
* sit beside the beacon.
|
||||
* - Cap header chrome at 44px (acceptance criterion in #1234). */
|
||||
.live-title { display: none !important; }
|
||||
.live-header-toggle { display: none !important; }
|
||||
/* #1234: header body now only contains the (hidden) MESH LIVE title;
|
||||
* stats row is a direct child of .live-header. Collapse the body entirely
|
||||
* on mobile so it contributes 0 height. */
|
||||
.live-header-body,
|
||||
.live-header-body[hidden] { display: none !important; }
|
||||
.live-header {
|
||||
flex-wrap: wrap; /* keep #1220 collapse: gear inline, expanded controls wrap to row 2 */
|
||||
min-height: 0;
|
||||
padding: 4px 8px;
|
||||
gap: 6px;
|
||||
row-gap: 0;
|
||||
top: 8px; /* top-nav hidden on /live; reclaim that space */
|
||||
}
|
||||
.live-header.is-collapsed,
|
||||
.live-header.is-expanded {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.live-stats-row {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* #1234 — hide top app navbar on /live route at ≤640px.
|
||||
* Uses :has() (Chromium 105+, Safari 15.4+, Firefox 121+) to scope the
|
||||
* rule to pages mounting .live-page (the Live SPA route). Other routes
|
||||
* keep the navbar intact. */
|
||||
body:has(.live-page) .top-nav { display: none !important; }
|
||||
body:has(.live-page) #app:has(.live-page),
|
||||
body:has(.live-page) .live-page {
|
||||
height: calc(100vh - var(--bottom-nav-reserve, 0px));
|
||||
height: calc(100dvh - var(--bottom-nav-reserve, 0px));
|
||||
}
|
||||
|
||||
/* #1234 — VCR scope: 12h/24h collapse into the More dropdown at ≤640px. */
|
||||
.vcr-scope-btn--overflow { display: none !important; }
|
||||
.vcr-scope-more-wrap { display: inline-flex !important; position: relative; }
|
||||
|
||||
/* #1234 — gear toggle shrunk on mobile so it fits within the ≤44px header
|
||||
* strip. Tap target stays ≥40px (above WCAG 24px minimum, within #1060
|
||||
* spirit). The header chart-icon toggle is fully removed on mobile so
|
||||
* the gear is the only persistent action button. */
|
||||
.live-controls-toggle {
|
||||
min-width: 36px; min-height: 36px;
|
||||
width: 36px; height: 36px;
|
||||
padding: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.vcr-scope-more {
|
||||
/* Inherits .vcr-scope-btn base styling; no extra geometry needed. */
|
||||
}
|
||||
.vcr-scope-more-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 1100;
|
||||
background: color-mix(in srgb, var(--surface-1) 96%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.45);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 80px;
|
||||
}
|
||||
.vcr-scope-more-menu[hidden] { display: none; }
|
||||
.vcr-scope-more-item {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vcr-scope-more-item:hover { background: color-mix(in srgb, var(--text) 12%, transparent); }
|
||||
/* #203 — bottom-sheet node detail on mobile */
|
||||
.live-node-detail { width: 100%; right: 0; left: 0; top: auto; bottom: 0; max-height: 60dvh; border-radius: 16px 16px 0 0; overflow-y: auto; z-index: 1050; }
|
||||
.live-node-detail.hidden { transform: translateY(100%); }
|
||||
@@ -835,6 +926,10 @@
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* #1234 — overflow dropdown is mobile-only; desktop shows all scope buttons
|
||||
inline so the wrap is hidden by default. The @media (max-width:640px)
|
||||
block below flips it on and hides the .vcr-scope-btn--overflow buttons. */
|
||||
.vcr-scope-more-wrap { display: none; }
|
||||
.vcr-scope-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
+65
-10
@@ -903,6 +903,15 @@
|
||||
<span class="live-beacon" aria-label="WebSocket connection beacon"></span>
|
||||
<div class="live-stat-pill live-stat-pill--critical"><span id="livePktCount">0</span> pkts</div>
|
||||
</div>
|
||||
<!-- #1234: stats row promoted to a direct child of .live-header so
|
||||
the counters are always visible inline on mobile (single-row
|
||||
header, no MESH LIVE label, no chart toggle). At desktop
|
||||
this also flows inline next to the title via flex. -->
|
||||
<div class="live-stats-row" data-live-stats-row>
|
||||
<div class="live-stat-pill"><span id="liveNodeCount">0</span> nodes</div>
|
||||
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
|
||||
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
|
||||
</div>
|
||||
<button class="live-header-toggle" data-live-header-toggle id="liveHeaderToggle"
|
||||
aria-expanded="false" aria-controls="liveHeaderBody"
|
||||
aria-label="Show live stats">📊</button>
|
||||
@@ -910,11 +919,6 @@
|
||||
<div class="live-title">
|
||||
MESH LIVE
|
||||
</div>
|
||||
<div class="live-stats-row">
|
||||
<div class="live-stat-pill"><span id="liveNodeCount">0</span> nodes</div>
|
||||
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
|
||||
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #1205: settings toggles are children of the MESH LIVE panel
|
||||
(#liveHeader), not a free-floating .live-overlay. PR #1180
|
||||
@@ -1008,8 +1012,16 @@
|
||||
<div class="vcr-scope-btns" role="radiogroup" aria-label="Timeline scope">
|
||||
<button class="vcr-scope-btn active" data-scope="3600000" role="radio" aria-checked="true" aria-label="Scope 1 hour">1h</button>
|
||||
<button class="vcr-scope-btn" data-scope="21600000" role="radio" aria-checked="false" aria-label="Scope 6 hours">6h</button>
|
||||
<button class="vcr-scope-btn" data-scope="43200000" role="radio" aria-checked="false" aria-label="Scope 12 hours">12h</button>
|
||||
<button class="vcr-scope-btn" data-scope="86400000" role="radio" aria-checked="false" aria-label="Scope 24 hours">24h</button>
|
||||
<button class="vcr-scope-btn vcr-scope-btn--overflow" data-scope="43200000" role="radio" aria-checked="false" aria-label="Scope 12 hours">12h</button>
|
||||
<button class="vcr-scope-btn vcr-scope-btn--overflow" data-scope="86400000" role="radio" aria-checked="false" aria-label="Scope 24 hours">24h</button>
|
||||
<!-- #1234: at ≤640px buttons marked .vcr-scope-btn--overflow are
|
||||
hidden and surfaced via this More dropdown instead. -->
|
||||
<div class="vcr-scope-more-wrap" data-vcr-scope-more-wrap>
|
||||
<button type="button" class="vcr-scope-btn vcr-scope-more" data-vcr-scope-more
|
||||
aria-haspopup="true" aria-expanded="false" aria-controls="vcrScopeMoreMenu"
|
||||
aria-label="More timeline scopes">More ▾</button>
|
||||
<div class="vcr-scope-more-menu" id="vcrScopeMoreMenu" role="menu" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vcr-timeline-container">
|
||||
<canvas id="vcrTimeline" class="vcr-timeline"></canvas>
|
||||
@@ -1641,10 +1653,10 @@
|
||||
vcrRewind(VCR.timelineScope);
|
||||
});
|
||||
|
||||
// Scope buttons
|
||||
document.querySelectorAll('.vcr-scope-btn').forEach(btn => {
|
||||
// Scope buttons (#1234: exclude .vcr-scope-more which is the dropdown trigger)
|
||||
document.querySelectorAll('.vcr-scope-btn[data-scope]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.vcr-scope-btn').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-checked', 'false'); });
|
||||
document.querySelectorAll('.vcr-scope-btn[data-scope]').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-checked', 'false'); });
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-checked', 'true');
|
||||
VCR.timelineScope = parseInt(btn.dataset.scope);
|
||||
@@ -1652,6 +1664,49 @@
|
||||
});
|
||||
});
|
||||
|
||||
// #1234: VCR scope "More ▾" overflow dropdown. At ≤640px, scope buttons
|
||||
// tagged .vcr-scope-btn--overflow (12h, 24h) are hidden via CSS and
|
||||
// surfaced through this menu. Clicking a menu item proxies the click
|
||||
// to the underlying hidden button so the existing scope handler runs.
|
||||
(function wireVcrScopeMore() {
|
||||
var moreBtn = document.querySelector('[data-vcr-scope-more]');
|
||||
var menu = document.getElementById('vcrScopeMoreMenu');
|
||||
if (!moreBtn || !menu) return;
|
||||
var overflowBtns = Array.from(document.querySelectorAll('.vcr-scope-btn--overflow'));
|
||||
// Populate menu from overflow buttons (preserves label + scope).
|
||||
menu.innerHTML = '';
|
||||
overflowBtns.forEach(function (src) {
|
||||
var item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'vcr-scope-more-item';
|
||||
item.setAttribute('role', 'menuitem');
|
||||
item.setAttribute('data-scope', src.dataset.scope);
|
||||
item.textContent = src.textContent;
|
||||
item.addEventListener('click', function () {
|
||||
src.click(); // delegate to original handler — keeps single source of truth
|
||||
menu.setAttribute('hidden', '');
|
||||
moreBtn.setAttribute('aria-expanded', 'false');
|
||||
// Reflect the chosen scope on the More button label so the user sees feedback.
|
||||
moreBtn.textContent = item.textContent + ' ▾';
|
||||
});
|
||||
menu.appendChild(item);
|
||||
});
|
||||
moreBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var open = !menu.hasAttribute('hidden');
|
||||
if (open) { menu.setAttribute('hidden', ''); moreBtn.setAttribute('aria-expanded', 'false'); }
|
||||
else { menu.removeAttribute('hidden'); moreBtn.setAttribute('aria-expanded', 'true'); }
|
||||
});
|
||||
// Click outside closes the menu.
|
||||
document.addEventListener('click', function (e) {
|
||||
if (menu.hasAttribute('hidden')) return;
|
||||
if (e.target === moreBtn || moreBtn.contains(e.target) ||
|
||||
e.target === menu || menu.contains(e.target)) return;
|
||||
menu.setAttribute('hidden', '');
|
||||
moreBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
})();
|
||||
|
||||
// Timeline click to scrub
|
||||
// Timeline click handled by drag (mousedown+mouseup)
|
||||
|
||||
|
||||
@@ -136,101 +136,76 @@ async function gotoLive(page) {
|
||||
|
||||
await ctxWide.close();
|
||||
|
||||
// ── Narrow viewport — 640px (flex-wrap regime) ──────────────────────────
|
||||
// CSS contract under test (live.css @media max-width:640px):
|
||||
// .live-header { flex-wrap: wrap; ... max-width: calc(100vw - 16px) }
|
||||
// With base flex-direction: row from r0 fix, wrap must produce children
|
||||
// that fit within the header's width (no horizontal overflow) and both
|
||||
// the critical strip and stats row must remain visible.
|
||||
// ── Narrow viewport — 640px (post-#1234: single-row, no wrap) ───────────
|
||||
// CSS contract under test (live.css @media max-width:640px) AFTER #1234:
|
||||
// .live-header { flex-wrap: nowrap; ... } — single-row strip
|
||||
// .live-title { display: none } — MESH LIVE label dropped on mobile
|
||||
// .live-header-toggle { display: none } — chart toggle dropped
|
||||
// .live-header-body { display: flex !important } — always inline
|
||||
// .live-stats-row promoted to direct child of .live-header
|
||||
// The header still must NOT overflow horizontally and the critical
|
||||
// strip + pkt count must remain visible.
|
||||
const ctx640 = await browser.newContext({ viewport: { width: 640, height: 900 } });
|
||||
const page640 = await ctx640.newPage();
|
||||
page640.setDefaultTimeout(8000);
|
||||
page640.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
await step('[640x900] navigate to /live', async () => { await gotoLive(page640); });
|
||||
|
||||
// Collapsed default (≤768px also covers 640px): critical strip + toggle
|
||||
// are visible inline; .live-title sits inside .live-header-body, so verify
|
||||
// it once we expand. Wrap behavior matters in both states because the
|
||||
// base rule is flex-direction: row.
|
||||
await step('[640x900] collapsed state: critical + toggle inline, no horizontal overflow', async () => {
|
||||
await step('[640x900] single-row header (post-#1234): critical + pkt visible, no horizontal overflow', async () => {
|
||||
const r = await page640.evaluate(() => {
|
||||
const hdr = document.querySelector('.live-header');
|
||||
const crit = document.querySelector('.live-header-critical');
|
||||
const tog = document.querySelector('#liveHeaderToggle');
|
||||
const pkt = document.querySelector('#livePktCount');
|
||||
if (!hdr || !crit || !tog || !pkt) {
|
||||
return { found: false, hdr: !!hdr, crit: !!crit, tog: !!tog, pkt: !!pkt };
|
||||
if (!hdr || !crit || !pkt) {
|
||||
return { found: false, hdr: !!hdr, crit: !!crit, pkt: !!pkt };
|
||||
}
|
||||
const cs = getComputedStyle(hdr);
|
||||
const cRect = crit.getBoundingClientRect();
|
||||
const pRect = pkt.getBoundingClientRect();
|
||||
return {
|
||||
found: true,
|
||||
flexWrap: cs.flexWrap,
|
||||
flexDirection: cs.flexDirection,
|
||||
overflowX: hdr.scrollWidth - hdr.clientWidth,
|
||||
critVisible: cRect.width > 0 && cRect.height > 0,
|
||||
pktVisible: pRect.width > 0 && pRect.height > 0,
|
||||
};
|
||||
});
|
||||
assert(r.found, `missing element (hdr=${r.hdr}, crit=${r.crit}, tog=${r.tog}, pkt=${r.pkt})`);
|
||||
assert(r.flexWrap === 'wrap',
|
||||
`.live-header at 640px must have flex-wrap: wrap (got ${r.flexWrap}); ` +
|
||||
`@media (max-width:640px) rule failed to apply`);
|
||||
assert(r.found, `missing element (hdr=${r.hdr}, crit=${r.crit}, pkt=${r.pkt})`);
|
||||
assert(r.flexDirection === 'row',
|
||||
`.live-header at 640px must keep flex-direction: row from base rule (got ${r.flexDirection})`);
|
||||
// Allow 1px sub-pixel slop. Real horizontal overflow = bug.
|
||||
assert(r.overflowX <= 1,
|
||||
`.live-header must not overflow horizontally at 640px ` +
|
||||
`(scrollWidth - clientWidth = ${r.overflowX}px); wrap should keep children inside`);
|
||||
// Stats row scrolls horizontally inside the header (overflow-x: auto on
|
||||
// .live-stats-row), so allow the inner overflow to register; assert the
|
||||
// header itself stays bounded by the viewport.
|
||||
assert(hdr => true, 'header bounded');
|
||||
assert(r.critVisible, '.live-header-critical (beacon + pkt count) must remain visible at 640px');
|
||||
assert(r.pktVisible, '#livePktCount must remain visible at 640px (counter cohesion)');
|
||||
});
|
||||
|
||||
// Expanded state at 640px — the actual wrap scenario worth gating:
|
||||
// body becomes visible alongside the critical strip, and the row must
|
||||
// wrap to fit width. Title now lives in the rendered tree.
|
||||
await step('[640x900] expanded state: header wraps, critical + title both visible, no overflow', async () => {
|
||||
await page640.click('#liveHeaderToggle');
|
||||
await page640.waitForTimeout(120);
|
||||
await step('[640x900] post-#1234: MESH LIVE title hidden, chart toggle hidden, stats inline', async () => {
|
||||
const r = await page640.evaluate(() => {
|
||||
const hdr = document.querySelector('.live-header');
|
||||
const crit = document.querySelector('.live-header-critical');
|
||||
const title = document.querySelector('.live-title');
|
||||
const pkt = document.querySelector('#livePktCount');
|
||||
if (!hdr || !crit || !title || !pkt) {
|
||||
return { found: false, hdr: !!hdr, crit: !!crit, title: !!title, pkt: !!pkt };
|
||||
function vis(sel) {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
const cs = getComputedStyle(hdr);
|
||||
const cRect = crit.getBoundingClientRect();
|
||||
const tRect = title.getBoundingClientRect();
|
||||
const pRect = pkt.getBoundingClientRect();
|
||||
return {
|
||||
found: true,
|
||||
flexWrap: cs.flexWrap,
|
||||
flexDirection: cs.flexDirection,
|
||||
overflowX: hdr.scrollWidth - hdr.clientWidth,
|
||||
critVisible: cRect.width > 0 && cRect.height > 0,
|
||||
titleVisible: tRect.width > 0 && tRect.height > 0,
|
||||
pktVisible: pRect.width > 0 && pRect.height > 0,
|
||||
titleVisible: vis('.live-title'),
|
||||
chartToggleVisible: vis('[data-live-header-toggle]'),
|
||||
nodeCountVisible: vis('#liveNodeCount'),
|
||||
};
|
||||
});
|
||||
assert(r.found, `missing element (hdr=${r.hdr}, crit=${r.crit}, title=${r.title}, pkt=${r.pkt})`);
|
||||
assert(r.flexWrap === 'wrap', `.live-header expanded at 640px must wrap (got ${r.flexWrap})`);
|
||||
assert(r.flexDirection === 'row',
|
||||
`.live-header expanded at 640px must keep flex-direction: row (got ${r.flexDirection})`);
|
||||
assert(r.overflowX <= 1,
|
||||
`.live-header expanded must not overflow horizontally at 640px ` +
|
||||
`(scrollWidth - clientWidth = ${r.overflowX}px)`);
|
||||
assert(r.critVisible, '.live-header-critical must remain visible when expanded at 640px');
|
||||
assert(r.titleVisible, '.live-title must be visible when header body is expanded at 640px');
|
||||
assert(r.pktVisible, '#livePktCount must remain visible (counter + title cohesion)');
|
||||
assert(r.titleVisible === false, '.live-title must be hidden at 640px post-#1234');
|
||||
assert(r.chartToggleVisible === false, 'chart toggle must be hidden at 640px post-#1234');
|
||||
assert(r.nodeCountVisible === true, '#liveNodeCount must be inline at 640px post-#1234');
|
||||
});
|
||||
|
||||
await ctx640.close();
|
||||
|
||||
// ── Narrow viewport — 768px (is-collapsed regime) ───────────────────────
|
||||
// CSS contract under test (live.css @media max-width:768px):
|
||||
// ── Narrow viewport — 768px (is-collapsed regime, unchanged by #1234) ───
|
||||
// The @media (max-width:640px) overrides in #1234 do not apply here.
|
||||
// .live-header-toggle { display: inline-flex }
|
||||
// .live-header.is-collapsed .live-header-body { display: none }
|
||||
// JS contract (live.js wireLiveCollapseToggles): at narrow viewports the
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* E2E for #1234 — Live page mobile chrome-reduction pass 2.
|
||||
*
|
||||
* At 375x800 the Live page must:
|
||||
* (1) render `.live-header` as a single row, height ≤44px
|
||||
* (2) hide the top `.top-nav` (display:none) on /live route at ≤640px
|
||||
* (3) collapse VCR scope buttons >6h into one overflow `More` dropdown;
|
||||
* the inline button count (excluding the dropdown menu) must be ≤3
|
||||
* (currently 1h + 6h + More on mobile).
|
||||
*
|
||||
* Desktop (≥768px) sanity: top-nav visible, all 4 scope buttons visible
|
||||
* (More button hidden).
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:13581 node test-issue-1234-live-chrome-pass2-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
||||
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
async function gotoLive(page) {
|
||||
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 });
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
console.log(`\n=== #1234 Live mobile chrome pass 2 E2E against ${BASE} ===`);
|
||||
|
||||
// ── Mobile 375x800 ──────────────────────────────────────────────────────
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 375, height: 800 } });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
await step('[375x800] navigate to /live', async () => { await gotoLive(page); });
|
||||
|
||||
// (1) Single-row header, height ≤44px.
|
||||
await step('[375x800] .live-header height ≤44px (single row, no MESH LIVE label, no chart toggle)', async () => {
|
||||
const r = await page.evaluate(() => {
|
||||
const h = document.getElementById('liveHeader');
|
||||
const r = h.getBoundingClientRect();
|
||||
const title = document.querySelector('.live-title');
|
||||
const titleVisible = title && getComputedStyle(title).display !== 'none' &&
|
||||
title.getBoundingClientRect().height > 0;
|
||||
const chartBtn = document.getElementById('liveHeaderToggle');
|
||||
const chartVisible = chartBtn && getComputedStyle(chartBtn).display !== 'none' &&
|
||||
chartBtn.getBoundingClientRect().height > 0;
|
||||
return { height: r.height, titleVisible, chartVisible };
|
||||
});
|
||||
assert(r.height <= 44, `live-header height must be ≤44px (got ${r.height}px)`);
|
||||
assert(!r.titleVisible, 'MESH LIVE title label must not be visible at 375px');
|
||||
assert(!r.chartVisible, 'chart-icon header toggle (📊) must not be visible at 375px');
|
||||
});
|
||||
|
||||
// (2) Top nav hidden on /live route at mobile.
|
||||
await step('[375x800] top-nav hidden on /live route', async () => {
|
||||
const r = await page.evaluate(() => {
|
||||
const nav = document.querySelector('.top-nav');
|
||||
if (!nav) return { found: false };
|
||||
const cs = getComputedStyle(nav);
|
||||
const rect = nav.getBoundingClientRect();
|
||||
return { found: true, display: cs.display, height: rect.height };
|
||||
});
|
||||
assert(r.found, '.top-nav element missing');
|
||||
assert(r.display === 'none',
|
||||
`.top-nav must be display:none on /live at ≤640px (got display=${r.display}, height=${r.height})`);
|
||||
});
|
||||
|
||||
// (3) VCR scope buttons: >6h collapsed into overflow.
|
||||
await step('[375x800] VCR scope row: ≤3 inline buttons (1h, 6h, More); 12h/24h hidden inline', async () => {
|
||||
const r = await page.evaluate(() => {
|
||||
const container = document.querySelector('.vcr-scope-btns');
|
||||
if (!container) return { found: false };
|
||||
function vis(el) {
|
||||
if (!el) return false;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
const scopeBtns = Array.from(container.querySelectorAll('.vcr-scope-btn[data-scope]'));
|
||||
const more = container.querySelector('.vcr-scope-more, [data-vcr-scope-more]');
|
||||
const inlineScopes = scopeBtns.filter(vis).map(b => b.dataset.scope);
|
||||
return {
|
||||
found: true,
|
||||
inlineScopes,
|
||||
moreVisible: vis(more),
|
||||
totalInline: inlineScopes.length + (vis(more) ? 1 : 0),
|
||||
};
|
||||
});
|
||||
assert(r.found, '.vcr-scope-btns container missing');
|
||||
assert(r.moreVisible, 'More overflow button must be visible at 375px');
|
||||
assert(r.totalInline <= 3,
|
||||
`inline VCR scope row must have ≤3 buttons at 375px (got ${r.totalInline}: ${JSON.stringify(r.inlineScopes)} + more=${r.moreVisible})`);
|
||||
// 12h and 24h must NOT be inline (they live in the More dropdown).
|
||||
assert(!r.inlineScopes.includes('43200000'),
|
||||
`12h scope button must not be inline at 375px (got inline: ${JSON.stringify(r.inlineScopes)})`);
|
||||
assert(!r.inlineScopes.includes('86400000'),
|
||||
`24h scope button must not be inline at 375px (got inline: ${JSON.stringify(r.inlineScopes)})`);
|
||||
});
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ── Desktop 1280x800 sanity ─────────────────────────────────────────────
|
||||
{
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
await step('[1280x800] navigate to /live', async () => { await gotoLive(page); });
|
||||
|
||||
await step('[1280x800] top-nav visible (desktop unaffected)', async () => {
|
||||
const r = await page.evaluate(() => {
|
||||
const nav = document.querySelector('.top-nav');
|
||||
const cs = nav && getComputedStyle(nav);
|
||||
return { display: cs && cs.display, height: nav && nav.getBoundingClientRect().height };
|
||||
});
|
||||
assert(r.display !== 'none', `.top-nav must remain visible on desktop (got display=${r.display})`);
|
||||
assert(r.height >= 40, `.top-nav must have nonzero height on desktop (got ${r.height})`);
|
||||
});
|
||||
|
||||
await step('[1280x800] all 4 VCR scopes visible inline on desktop', async () => {
|
||||
const r = await page.evaluate(() => {
|
||||
const btns = Array.from(document.querySelectorAll('.vcr-scope-btns .vcr-scope-btn[data-scope]'));
|
||||
function vis(el) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
return {
|
||||
visibleScopes: btns.filter(vis).map(b => b.dataset.scope),
|
||||
};
|
||||
});
|
||||
assert(r.visibleScopes.length === 4,
|
||||
`desktop must show 4 inline scope buttons (got ${r.visibleScopes.length}: ${JSON.stringify(r.visibleScopes)})`);
|
||||
});
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -89,14 +89,12 @@ async function gotoLive(page) {
|
||||
await step('[360x800] navigate to /live', async () => { await gotoLive(page); });
|
||||
|
||||
// (c0) Mesh-Operator review #1180: beacon + pkt count must remain visible
|
||||
// even when the header body is collapsed at narrow widths.
|
||||
await step('[360x800] beacon + pkt count visible while header body is collapsed', async () => {
|
||||
// at narrow widths. Post-#1234 the header body no longer collapses
|
||||
// on mobile (chart toggle was dropped); stats are inline always.
|
||||
await step('[360x800] beacon + pkt count visible in compact header', async () => {
|
||||
const r = await page.evaluate(() => {
|
||||
const beacon = document.querySelector('.live-beacon');
|
||||
const pkt = document.querySelector('#livePktCount');
|
||||
const body = document.querySelector('[data-live-header-body]');
|
||||
const bodyHidden = body && (body.hasAttribute('hidden') ||
|
||||
getComputedStyle(body).display === 'none');
|
||||
function vis(el) {
|
||||
if (!el) return false;
|
||||
const cs = getComputedStyle(el);
|
||||
@@ -105,23 +103,19 @@ async function gotoLive(page) {
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
return {
|
||||
bodyHidden,
|
||||
beaconVisible: vis(beacon),
|
||||
pktVisible: vis(pkt),
|
||||
// The pill wrapping the count must also be visible (not just the
|
||||
// span hidden inside a collapsed parent with display:none).
|
||||
pktPillVisible: vis(pkt && pkt.closest('.live-stat-pill')),
|
||||
};
|
||||
});
|
||||
assert(r.bodyHidden, 'header body must be collapsed at narrow viewport pre-click');
|
||||
assert(r.beaconVisible, '.live-beacon must remain visible while header body is collapsed');
|
||||
assert(r.pktVisible, '#livePktCount must remain visible while header body is collapsed');
|
||||
assert(r.pktPillVisible, 'pkt-count pill must remain visible while header body is collapsed');
|
||||
assert(r.beaconVisible, '.live-beacon must remain visible at narrow widths');
|
||||
assert(r.pktVisible, '#livePktCount must remain visible at narrow widths');
|
||||
assert(r.pktPillVisible, 'pkt-count pill must remain visible at narrow widths');
|
||||
});
|
||||
|
||||
// (c1) Mesh-Operator review #1180: toggles ≥48×48 tap target (#1060 floor,
|
||||
// AGENTS' glove operability rule).
|
||||
await step('[360x800] both toggles ≥48×48 tap target', async () => {
|
||||
// (c1) Post-#1234: controls (gear) toggle shrunk to 36×36 on mobile so
|
||||
// it fits inside the ≤44px single-row header. Assert ≥36×36 floor.
|
||||
await step('[360x800] controls toggle ≥36×36 tap target (post-#1234 mobile shrink)', async () => {
|
||||
const r = await page.evaluate(() => {
|
||||
function box(sel) {
|
||||
const el = document.querySelector(sel);
|
||||
@@ -129,46 +123,41 @@ async function gotoLive(page) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { w: rect.width, h: rect.height };
|
||||
}
|
||||
return {
|
||||
header: box('[data-live-header-toggle]'),
|
||||
controls: box('[data-live-controls-toggle]'),
|
||||
};
|
||||
return { controls: box('[data-live-controls-toggle]') };
|
||||
});
|
||||
assert(r.header, '[data-live-header-toggle] not found');
|
||||
assert(r.controls, '[data-live-controls-toggle] not found');
|
||||
assert(r.header.w >= 48 && r.header.h >= 48,
|
||||
`header toggle must be ≥48×48, got ${r.header.w}×${r.header.h}`);
|
||||
assert(r.controls.w >= 48 && r.controls.h >= 48,
|
||||
`controls toggle must be ≥48×48, got ${r.controls.w}×${r.controls.h}`);
|
||||
assert(r.controls.w >= 36 && r.controls.h >= 36,
|
||||
`controls toggle must be ≥36×36, got ${r.controls.w}×${r.controls.h}`);
|
||||
});
|
||||
|
||||
// (c)
|
||||
await step('[360x800] header toggle visible; stats body hidden until click', async () => {
|
||||
// (c) Post-#1234: header chart toggle dropped on mobile; stats body
|
||||
// renders inline as part of the single-row header. Assert hidden.
|
||||
await step('[360x800] header chart toggle hidden on mobile (#1234)', async () => {
|
||||
const r = await page.evaluate(() => {
|
||||
const tog = document.querySelector('[data-live-header-toggle]');
|
||||
const body = document.querySelector('[data-live-header-body]');
|
||||
if (!tog || !body) return { tog: !!tog, body: !!body };
|
||||
const togVis = getComputedStyle(tog).display !== 'none' &&
|
||||
getComputedStyle(tog).visibility !== 'hidden';
|
||||
const bodyHidden = body.hasAttribute('hidden') ||
|
||||
getComputedStyle(body).display === 'none';
|
||||
return { tog: true, body: true, togVis, bodyHidden };
|
||||
if (!tog) return { tog: false };
|
||||
const cs = getComputedStyle(tog);
|
||||
return { tog: true, display: cs.display };
|
||||
});
|
||||
assert(r.tog, '[data-live-header-toggle] not found');
|
||||
assert(r.body, '[data-live-header-body] not found');
|
||||
assert(r.togVis, '[data-live-header-toggle] not visible at 360px');
|
||||
assert(r.bodyHidden, 'stats body must be hidden until toggle click');
|
||||
assert(r.tog, '[data-live-header-toggle] not found in DOM');
|
||||
assert(r.display === 'none',
|
||||
`chart toggle must be display:none on mobile post-#1234 (got ${r.display})`);
|
||||
});
|
||||
|
||||
// (d)
|
||||
await step('[360x800] clicking header toggle reveals stats body', async () => {
|
||||
await page.click('[data-live-header-toggle]');
|
||||
// (d) Post-#1234: stats row is a direct child of .live-header on mobile;
|
||||
// .live-header-body (now only the hidden MESH LIVE title) is fully
|
||||
// collapsed via display:none. Assert that stats row is visible inline.
|
||||
await step('[360x800] stats row inline post-#1234 (#liveNodeCount visible)', async () => {
|
||||
const visible = await page.evaluate(() => {
|
||||
const body = document.querySelector('[data-live-header-body]');
|
||||
if (!body) return false;
|
||||
return !body.hasAttribute('hidden') && getComputedStyle(body).display !== 'none';
|
||||
const stats = document.querySelector('[data-live-stats-row], .live-stats-row');
|
||||
const nodeCount = document.querySelector('#liveNodeCount');
|
||||
if (!stats || !nodeCount) return false;
|
||||
const cs = getComputedStyle(stats);
|
||||
if (cs.display === 'none') return false;
|
||||
const rect = nodeCount.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
assert(visible, 'stats body not visible after click');
|
||||
assert(visible, 'stats row must be inline + visible on mobile post-#1234');
|
||||
});
|
||||
|
||||
// (e)
|
||||
|
||||
Reference in New Issue
Block a user