feat(mobile): packets UX overhaul + bottom-nav More controls (#1415, #1458, #1461, #1467)

Mobile-only changes (`@media (max-width: 900px)` or `isMobile()` gates);
desktop unchanged. All iterations were CDP-verified in the local browser
against staging at 390x844 and 1206x928 viewports.

### #1415 / #1458 / #1461 — packets-list density
- Kill empty chevron rail on mobile
- Slim sticky THEAD (24px, retains sort affordance per operator preference)
- Hide page-header on mobile entirely
- Convert group-header `toggle-select` to `select-hash` on mobile so the
  row stops being a dead-end (no chevron, nothing to expand)

### #1458 / #1461 — detail panel cleanup
- Drop redundant src->dst line (identity already in sticky header)
- Hide the boxed "decoded message" duplication card
- Hide PAYLOAD TYPE row (already in the header badge)
- 2-col label/value grid (~40%% panel-height reduction)
- Sticky in-sheet header for packet identity
- Kill iOS-style drag handle (conflicts with browser pull-to-refresh)
- Make X close visible + always reachable
- Outer sheet `overflow:hidden`, inner content `overflow-y:auto`
  (scrollable region distinct, scrollbar visible)
- Bottom-nav clearance (`padding-bottom: 60px`)
- Close detail sheet on route change away from /packets
- Tap-to-toast popovers for score tooltips (title= does not fire on touch)

### #1467 — mobile nav surface
- Mirror pause + Filters pill into navbar via new mobile-page-actions.js
- Mirror Favorites / Search / Customize into the bottom-nav More sheet
- Brand stays in top nav; per-page controls inject into `.nav-left`

Closes #1415, #1458, #1461, #1467.
This commit is contained in:
openclaw-bot
2026-05-28 21:43:19 +00:00
parent d107cfb942
commit 46bdca3d20
3 changed files with 302 additions and 0 deletions
+1
View File
@@ -170,5 +170,6 @@
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="mobile-page-actions.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+186
View File
@@ -0,0 +1,186 @@
/* #1461 mobile page-actions: mirror per-page header buttons (pause, filter
* toggle) into the top nav's .nav-right area on mobile so the page-header
* row can be hidden entirely. On desktop this script is a no-op. */
(function () {
'use strict';
const MOBILE_BP = 900;
const SLOT_ID = 'navPageActions';
function isMobile() { return window.innerWidth <= MOBILE_BP; }
function ensureSlot() {
let slot = document.getElementById(SLOT_ID);
if (slot) return slot;
// On mobile, .nav-right is display:none — use .nav-left so the slot is
// visible. Append after the brand link.
const navLeft = document.querySelector('.nav-left');
if (!navLeft) return null;
slot = document.createElement('div');
slot.id = SLOT_ID;
slot.style.cssText = 'display:inline-flex;gap:4px;align-items:center;margin-left:8px;';
navLeft.appendChild(slot);
return slot;
}
function clearSlot() {
const slot = document.getElementById(SLOT_ID);
if (slot) slot.innerHTML = '';
}
function makeBtn(label, title, onClick) {
const b = document.createElement('button');
b.className = 'nav-btn';
b.type = 'button';
b.title = title;
b.textContent = label;
b.addEventListener('click', onClick);
return b;
}
function syncForRoute() {
// #1461 #6: close mobile detail sheet on route change away from packets
try {
const sheet = document.getElementById('mobileDetailSheet');
if (sheet && !/^#\/packets/.test(location.hash || '')) {
sheet.classList.remove('open');
}
} catch (_e) {}
if (!isMobile()) { clearSlot(); return; }
const hash = location.hash || '';
const slot = ensureSlot();
if (!slot) return;
slot.innerHTML = '';
if (/^#\/packets(\/|$|\?)/.test(hash)) {
// Mirror pause button (icon only — small)
const pause = makeBtn('⏸', 'Pause live updates', function () {
const real = document.getElementById('pktPauseBtn');
if (real) real.click();
});
pause.style.cssText = 'padding:4px 6px;font-size:14px;background:transparent;border:1px solid var(--border,#444);border-radius:6px;color:var(--nav-text,#fff);cursor:pointer;';
slot.appendChild(pause);
// Mirror filter toggle as a labeled "Filters ▾" pill (matches inline style)
const filt = makeBtn('Filters ▾', 'Toggle filters', function () {
const real = document.querySelector('.filter-bar .filter-toggle-btn, #filterToggleBtn');
if (real) real.click();
});
filt.className = 'nav-btn filter-toggle-btn-mirror';
filt.style.cssText = 'padding:4px 10px;font-size:12px;background:transparent;border:1px solid var(--border,#444);border-radius:999px;color:var(--nav-text,#fff);cursor:pointer;font-weight:500;';
slot.appendChild(filt);
}
}
window.addEventListener('hashchange', syncForRoute);
window.addEventListener('resize', syncForRoute);
/* #1461 #7: on mobile, packets-list group-header expand is a UX dead-end
* (we hid the chevron so there's no way to collapse). Intercept those
* clicks and force them to the single-select code path instead — the
* detail pane has all the obs info anyway. */
document.addEventListener('click', function (e) {
if (!isMobile()) return;
const row = e.target.closest && e.target.closest('#pktTable tr[data-action="toggle-select"]');
if (!row) return;
// Convert to a select-hash event by re-dispatching synthetically — simpler
// to mutate the attribute briefly so the existing delegated handler
// routes it correctly.
row.setAttribute('data-action', 'select-hash');
}, true);
/* #1461 #8: traffic_share_score / bridge_score tooltips use title= which
* doesn't fire on touch. Show a click-to-toast popover on mobile when
* operator taps a TD whose title mentions traffic/bridge/centrality. */
document.addEventListener('click', function (e) {
if (!isMobile()) return;
const el = e.target.closest('[title]');
if (!el) return;
const text = el.getAttribute('title');
if (!text) return;
// Limit to score / metric explanations to avoid spamming on every titled element
if (!/traffic share|bridge|centrality|score|usefulness/i.test(text + ' ' + el.textContent)) return;
e.preventDefault();
e.stopPropagation();
showToast(text);
}, true);
function showToast(msg) {
let t = document.getElementById('mcMobileToast');
if (!t) {
t = document.createElement('div');
t.id = 'mcMobileToast';
t.style.cssText = 'position:fixed;left:50%;bottom:90px;transform:translateX(-50%);background:var(--surface-2,#222);color:var(--text,#fff);padding:10px 16px;border:1px solid var(--border,#444);border-radius:8px;font-size:13px;max-width:80vw;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.4);transition:opacity .3s;';
document.body.appendChild(t);
}
t.textContent = msg;
t.style.opacity = '1';
clearTimeout(t._timer);
t._timer = setTimeout(() => { t.style.opacity = '0'; }, 4000);
}
/* #1467: mirror missing top-nav controls (Favorites, Search, Customize)
* into the bottom-nav More sheet. bottom-nav.js only wired Dark mode;
* the others have no mobile surface today. Insert above the existing
* dark-mode separator so the new items group with the other route items. */
function addMissingMoreSheetItems() {
const sheet = document.querySelector('[data-bottom-nav-sheet]');
if (!sheet) { setTimeout(addMissingMoreSheetItems, 500); return; }
if (sheet.querySelector('[data-mpa-mirror]')) return; // already injected
const mirrors = [
{ id: 'favToggle', icon: '⭐', label: 'Favorites' },
{ id: 'searchToggle', icon: '🔍', label: 'Search' },
{ id: 'customizeToggle', icon: '🎨', label: 'Customize' },
];
const sep = sheet.querySelector('.bottom-nav-sheet-sep');
mirrors.forEach((m) => {
const real = document.getElementById(m.id);
if (!real) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bottom-nav-sheet-item';
btn.setAttribute('role', 'menuitem');
btn.setAttribute('data-mpa-mirror', m.id);
const ic = document.createElement('span');
ic.className = 'bottom-nav-sheet-icon';
ic.setAttribute('aria-hidden', 'true');
ic.textContent = m.icon;
const lb = document.createElement('span');
lb.className = 'bottom-nav-sheet-label';
lb.textContent = m.label;
btn.appendChild(ic);
btn.appendChild(lb);
btn.addEventListener('click', function () {
real.click();
// close the sheet after delegating
try { sheet.classList.remove('open'); } catch (_e) {}
});
if (sep) sheet.insertBefore(btn, sep);
else sheet.appendChild(btn);
});
}
// Also re-run when sheet is opened (bottom-nav rebuilds it on open)
document.addEventListener('click', function (e) {
const target = e.target.closest && e.target.closest('[data-bottom-nav-more]');
if (target) setTimeout(addMissingMoreSheetItems, 50);
}, true);
// Run after page-header is rendered (packets.js builds it async); retry briefly
let tries = 0;
function init() {
syncForRoute();
addMissingMoreSheetItems();
if (tries++ < 20 && /^#\/packets/.test(location.hash) && !document.getElementById('pktPauseBtn')) {
setTimeout(init, 250);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+115
View File
@@ -879,6 +879,121 @@ img.brand-logo {
width: 32px; min-width: 32px; max-width: 32px;
padding: 3px 4px; text-align: center;
}
/* #1461 Tufte v2 mobile prescriptions (iterating; not yet committed). */
@media (max-width: 900px) {
/* #1: kill empty chevron rail */
#pktTable th.col-expand,
#pktTable td.col-expand { display: none !important; }
/* #1b: slim THEAD ~24px sticky */
#pktTable thead th {
padding: 3px 6px !important;
font-size: 9px !important;
line-height: 1.2 !important;
letter-spacing: 0.04em !important;
text-transform: uppercase;
height: 24px !important;
}
#pktTable thead { position: sticky; top: 0; z-index: 5; background: var(--surface-1, #1a1a1a); }
/* #2: hide page-header row + filter-expr + collapsed filter-bar.
* pause + Filters pill are mirrored into .nav-left by mobile-page-actions.js */
#pktLeft .page-header { display: none !important; }
#pktLeft .pkt-filter-expr { display: none !important; }
.filter-bar { display: none !important; }
.filter-bar.filters-expanded {
display: flex !important;
padding: 6px 8px !important;
margin-bottom: 4px !important;
background: var(--surface-2, rgba(0,0,0,0.05)) !important;
border: 1px solid var(--border, #333) !important;
border-radius: 6px !important;
height: auto;
}
/* #3: detail panel cleanup */
/* Strip redundant src→dst line (identity in sticky header) */
.mobile-detail-sheet .detail-srcdst { display: none !important; }
/* Hide boxed "decoded message" card on mobile — content already in
* the sticky header's semantic summary */
.mobile-detail-sheet .detail-message { display: none !important; }
/* Hide PAYLOAD TYPE row (first dt+dd pair — already in header badge) */
.mobile-detail-sheet dl.detail-meta > dt:first-child,
.mobile-detail-sheet dl.detail-meta > dt:first-child + dd { display: none !important; }
/* 2-col grid for label/value rows */
.mobile-detail-sheet dl.detail-meta {
display: grid;
grid-template-columns: minmax(90px, 30%) 1fr;
gap: 4px 10px;
margin: 8px 0 !important;
}
.mobile-detail-sheet dl.detail-meta dt {
margin: 0 !important;
padding: 0 !important;
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: center;
}
.mobile-detail-sheet dl.detail-meta dd {
margin: 0 !important;
padding: 0 !important;
font-size: 13px;
}
/* #4: sheet structure — kill handle, sticky header inside sheet, clear
* bottom-nav (58px tall) so last row doesn't get cut off, taller max */
/* Restructure: outer sheet has fixed height + overflow:hidden; the
* .mobile-sheet-content is the ONLY scroll region. ✕ close stays sticky
* pinned at top of the sheet. Title also stays sticky within the
* scroll region. */
.mobile-detail-sheet {
max-height: calc(100vh - 110px) !important; /* leave room for bottom nav */
padding: 0 !important;
overflow: hidden !important;
display: none;
flex-direction: column;
}
.mobile-detail-sheet.open { display: flex !important; }
.mobile-sheet-handle { display: none !important; } /* conflicts with browser pull-to-refresh */
.mobile-sheet-close {
position: absolute !important;
top: 6px !important;
right: 10px !important;
background: var(--surface-2, rgba(0,0,0,0.6)) !important;
border-radius: 999px !important;
width: 32px !important;
height: 32px !important;
display: flex !important;
align-items: center;
justify-content: center;
font-size: 16px !important;
z-index: 20 !important;
border: 1px solid var(--border, #444) !important;
}
.mobile-sheet-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 60px !important;
/* visible scrollbar so operator sees there's more */
scrollbar-width: thin;
}
.mobile-sheet-content::-webkit-scrollbar { width: 6px; }
.mobile-sheet-content::-webkit-scrollbar-thumb { background: var(--border, #555); border-radius: 3px; }
/* Sticky in-sheet header for packet identity */
.mobile-detail-sheet .detail-title {
position: sticky;
top: 0;
background: var(--detail-bg);
z-index: 5;
padding: 6px 36px 8px 0;
margin: 0 0 8px 0;
border-bottom: 1px solid var(--border);
}
}
.data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; }
.data-table .col-time { min-width: clamp(72px, 8vw, 108px); white-space: nowrap; }