From 46bdca3d2080ab1bf3362d1e8bfc041faf45a433 Mon Sep 17 00:00:00 2001 From: openclaw-bot Date: Thu, 28 May 2026 21:43:19 +0000 Subject: [PATCH] feat(mobile): packets UX overhaul + bottom-nav More controls (#1415, #1458, #1461, #1467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/index.html | 1 + public/mobile-page-actions.js | 186 ++++++++++++++++++++++++++++++++++ public/style.css | 115 +++++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 public/mobile-page-actions.js diff --git a/public/index.html b/public/index.html index 0cbb5d98..a1ed0d89 100644 --- a/public/index.html +++ b/public/index.html @@ -170,5 +170,6 @@ + diff --git a/public/mobile-page-actions.js b/public/mobile-page-actions.js new file mode 100644 index 00000000..9911b24b --- /dev/null +++ b/public/mobile-page-actions.js @@ -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(); + } +})(); diff --git a/public/style.css b/public/style.css index d29ae713..559b5cbf 100644 --- a/public/style.css +++ b/public/style.css @@ -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; }