diff --git a/public/live.css b/public/live.css
index 5cc11af7..46cd2a01 100644
--- a/public/live.css
+++ b/public/live.css
@@ -49,6 +49,21 @@
gap: 1px;
}
+/* #1207: empty-state placeholder fills the feed body so the panel never
+ renders as orphan chrome (✕ + ◫ buttons with no content). The placeholder
+ is always in the DOM; CSS hides it when the feed has live-feed-item
+ siblings. This way the panel body is never empty, even between packets,
+ on first paint, or after a feed clear. */
+.live-feed-empty {
+ color: var(--text-muted);
+ font-size: 12px;
+ padding: 12px 8px;
+ text-align: center;
+}
+.live-feed .panel-content:has(.live-feed-item) .live-feed-empty {
+ display: none;
+}
+
.live-legend .panel-content {
display: flex;
flex-direction: column;
diff --git a/public/live.js b/public/live.js
index b3b04b93..ca01ec81 100644
--- a/public/live.js
+++ b/public/live.js
@@ -921,7 +921,9 @@
-
+
+
Waiting for packets…
+
@@ -2029,6 +2031,15 @@
if (!feedContent) return;
feedContent.querySelectorAll('.live-feed-item').forEach(el => el.remove());
feedDedup.clear();
+ // #1207: ensure empty-state placeholder is present (re-add if a prior
+ // rebuild wiped everything). CSS hides it when items exist.
+ if (!feedContent.querySelector('.live-feed-empty')) {
+ var _ph = document.createElement('div');
+ _ph.className = 'live-feed-empty';
+ _ph.setAttribute('aria-hidden', 'true');
+ _ph.textContent = 'Waiting for packets…';
+ feedContent.appendChild(_ph);
+ }
// Aggregate VCR buffer by hash, then create one feed item per unique hash
const byHash = new Map();
@@ -2241,6 +2252,12 @@
window._liveVcrPause = vcrPause;
window._liveVcrResumeLive = vcrResumeLive;
window._liveVcrSetMode = vcrSetMode;
+ // #1207 test seams: expose production feed mutators so E2E can exercise
+ // the real eviction guard / placeholder re-add path (not a test-local copy).
+ window._liveAddFeedItem = function(icon, typeName, payload, hops, color, pkt) {
+ return addFeedItem(icon, typeName, payload, hops, color, pkt);
+ };
+ window._liveRebuildFeedList = function() { return rebuildFeedList(); };
async function replayRecent() {
try {
@@ -3250,7 +3267,11 @@
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.prepend(item);
requestAnimationFrame(() => requestAnimationFrame(() => item.classList.remove('live-feed-enter')));
- while (feed.children.length > 25) feed.removeChild(feed.lastChild);
+ // #1207: trim to 25 items, but never evict the empty-state placeholder.
+ while (feed.querySelectorAll('.live-feed-item').length > 25) {
+ var _items = feed.querySelectorAll('.live-feed-item');
+ feed.removeChild(_items[_items.length - 1]);
+ }
// Register
if (hash) {
diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js
index 6bbd87e5..df179ca3 100644
--- a/test-e2e-playwright.js
+++ b/test-e2e-playwright.js
@@ -951,6 +951,215 @@ async function run() {
});
+ // Issue #1207: Live Feed panel (bottom-left) must NOT render as orphan
+ // chrome (only header buttons ✕ + ◫) when there are no feed items yet.
+ // Either content (live-feed-item) renders, or an explicit empty-state
+ // placeholder with the "Waiting for packets…" copy fills the panel-content
+ // body. The bug is the panel showing its header chrome with an empty body
+ // on first paint / cold load.
+ await test('#1207 Live Feed panel never renders as empty chrome', async () => {
+ await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#liveFeed', { timeout: 10000 });
+ // Force the empty state: clear any feed items that may have arrived,
+ // then verify the placeholder copy is rendered in the panel body.
+ const state = await page.evaluate(() => {
+ const feed = document.getElementById('liveFeed');
+ if (!feed) return { found: false };
+ const content = feed.querySelector('.panel-content');
+ if (!content) return { found: true, hasContent: false };
+ // Wipe item children to simulate first-paint / no-traffic state.
+ content.querySelectorAll('.live-feed-item').forEach((el) => el.remove());
+ const visibleText = (content.innerText || '').trim();
+ return {
+ found: true,
+ hasContent: true,
+ feedHidden: feed.classList.contains('hidden'),
+ visibleText,
+ };
+ });
+ assert(state.found, '#liveFeed should be present on Live page');
+ assert(state.hasContent, '#liveFeed must have a .panel-content body');
+ if (state.feedHidden) return; // user hid the feed → header chrome gone too
+ // Pin to the actual placeholder copy — a non-empty div with no recognisable
+ // text would still be orphan chrome.
+ assert(
+ /Waiting for packets/i.test(state.visibleText),
+ `#1207: Live Feed body must contain placeholder copy "Waiting for packets…". ` +
+ `Got: ${JSON.stringify(state.visibleText)}`,
+ );
+ });
+
+ // Issue #1207 (kent #2): the eviction loop in addFeedItem must use
+ // querySelectorAll('.live-feed-item').length, NOT feed.children.length —
+ // otherwise the placeholder div counts as a "child" and gets trimmed
+ // (or items are under-evicted) once the feed is full. Fill the feed with
+ // synthetic items, run the eviction guard, then strip items back to zero
+ // and assert the placeholder survives.
+ await test('#1207 eviction loop never trims the empty-state placeholder', async () => {
+ await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#liveFeed .panel-content', { timeout: 10000 });
+ // Drive the REAL production addFeedItem (exposed as window._liveAddFeedItem)
+ // 30 times so the production eviction guard inside addFeedItem fires past
+ // the cap of 25. If production reverts to feed.children.length, the
+ // placeholder counts as a child and either under-evicts (26 items) or
+ // gets trimmed itself — both observable failures below.
+ const result = await page.evaluate(() => {
+ if (typeof window._liveAddFeedItem !== 'function') {
+ return { ok: false, reason: 'window._liveAddFeedItem not exposed (test seam missing)' };
+ }
+ const feed = document.querySelector('#liveFeed .panel-content');
+ if (!feed) return { ok: false, reason: 'no .panel-content' };
+ // Clean slate: remove any pre-existing items the page may have rendered.
+ feed.querySelectorAll('.live-feed-item').forEach((el) => el.remove());
+ // Re-add placeholder if a prior packet wiped it.
+ if (!feed.querySelector('.live-feed-empty')) {
+ const ph = document.createElement('div');
+ ph.className = 'live-feed-empty';
+ ph.setAttribute('aria-hidden', 'true');
+ ph.textContent = 'Waiting for packets…';
+ feed.appendChild(ph);
+ }
+ const placeholderBefore = !!feed.querySelector('.live-feed-empty');
+
+ // Fire 30 distinct packets through PRODUCTION addFeedItem.
+ // Unique hashes prevent dedup; minimal pkt shape mirrors what
+ // renderPacketTree passes through.
+ for (let i = 0; i < 30; i++) {
+ const hash = 'evict-test-' + i.toString(16).padStart(8, '0');
+ const pkt = {
+ hash,
+ _ts: Date.now() + i,
+ route_type: 0,
+ observation_count: 1,
+ decoded: { header: { payloadTypeName: 'TXT_MSG' }, payload: { text: 'evict-' + i }, path: { hops: [] } },
+ };
+ window._liveAddFeedItem('💬', 'TXT_MSG', { text: 'evict-' + i }, [], '#888', pkt);
+ }
+ const itemsAfterEvict = feed.querySelectorAll('.live-feed-item').length;
+ const placeholderAfterEvict = !!feed.querySelector('.live-feed-empty');
+ const childrenAfterEvict = feed.children.length;
+ return {
+ ok: true,
+ placeholderBefore,
+ itemsAfterEvict,
+ placeholderAfterEvict,
+ childrenAfterEvict,
+ };
+ });
+ assert(result.ok, `setup failed: ${result.reason || ''}`);
+ assert(result.placeholderBefore, 'placeholder must be present before firing packets');
+ // The production guard `while (feed.querySelectorAll('.live-feed-item').length > 25)`
+ // MUST cap items at exactly 25. A buggy `feed.children.length` form caps at 25
+ // total children (24 items + 1 placeholder) → itemsAfterEvict === 24 → fails here.
+ assert(
+ result.itemsAfterEvict === 25,
+ `eviction guard must cap at 25 .live-feed-item (got ${result.itemsAfterEvict}). ` +
+ `If 24: production is using feed.children.length (placeholder counted as child).`,
+ );
+ // And the placeholder must SURVIVE the eviction loop. A buggy form that
+ // does removeChild on the last child would yank the placeholder once it's
+ // appended last.
+ assert(
+ result.placeholderAfterEvict,
+ 'eviction must NOT remove .live-feed-empty placeholder',
+ );
+ // Total children == 25 items + 1 placeholder.
+ assert(
+ result.childrenAfterEvict === 26,
+ `feed.children.length should be 26 (25 items + 1 placeholder), got ${result.childrenAfterEvict}`,
+ );
+ });
+
+ // Issue #1207 (kent #3): CSS rule
+ // .live-feed .panel-content:has(.live-feed-item) .live-feed-empty { display: none }
+ // must hide the placeholder when at least one item is present. Inject an
+ // item and assert getComputedStyle(empty).display === 'none'.
+ await test('#1207 :has() rule hides placeholder when items present', async () => {
+ await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#liveFeed .panel-content', { timeout: 10000 });
+ const result = await page.evaluate(() => {
+ const feed = document.querySelector('#liveFeed .panel-content');
+ if (!feed) return { ok: false, reason: 'no .panel-content' };
+ // Strip any pre-existing items to isolate the test.
+ feed.querySelectorAll('.live-feed-item').forEach((el) => el.remove());
+ // Ensure placeholder exists.
+ if (!feed.querySelector('.live-feed-empty')) {
+ const ph = document.createElement('div');
+ ph.className = 'live-feed-empty';
+ ph.textContent = 'Waiting for packets…';
+ feed.appendChild(ph);
+ }
+ const ph = feed.querySelector('.live-feed-empty');
+ const displayBefore = getComputedStyle(ph).display;
+ // Inject a real .live-feed-item child of .panel-content (sibling of ph).
+ const item = document.createElement('div');
+ item.className = 'live-feed-item';
+ item.textContent = 'injected';
+ feed.prepend(item);
+ const displayAfter = getComputedStyle(ph).display;
+ return { ok: true, displayBefore, displayAfter };
+ });
+ assert(result.ok, `setup failed: ${result.reason || ''}`);
+ assert(
+ result.displayBefore !== 'none',
+ `placeholder must be visible with no items (got display=${result.displayBefore})`,
+ );
+ assert(
+ result.displayAfter === 'none',
+ `:has(.live-feed-item) rule must hide placeholder (got display=${result.displayAfter})`,
+ );
+ });
+
+ // Issue #1207 (kent #4): rebuildFeedList() must re-add the empty-state
+ // placeholder when it was previously removed. Without this, a sequence of
+ // (live packets render → user navigates / state rebuilds → placeholder gone)
+ // leaves the panel as orphan chrome. Drive the REAL production
+ // rebuildFeedList (via window._liveRebuildFeedList) AFTER explicitly
+ // removing the placeholder; assert it comes back.
+ await test('#1207 rebuildFeedList re-adds placeholder when missing', async () => {
+ await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#liveFeed .panel-content', { timeout: 10000 });
+ const result = await page.evaluate(() => {
+ if (typeof window._liveRebuildFeedList !== 'function') {
+ return { ok: false, reason: 'window._liveRebuildFeedList not exposed (test seam missing)' };
+ }
+ const feed = document.querySelector('#liveFeed .panel-content');
+ if (!feed) return { ok: false, reason: 'no .panel-content' };
+ // Simulate: ingest wiped the placeholder out (e.g. innerHTML replace,
+ // or a future code path that forgot to preserve it).
+ feed.querySelectorAll('.live-feed-empty').forEach((el) => el.remove());
+ feed.querySelectorAll('.live-feed-item').forEach((el) => el.remove());
+ const placeholderBefore = !!feed.querySelector('.live-feed-empty');
+ // Call production rebuild.
+ window._liveRebuildFeedList();
+ const placeholderAfter = feed.querySelector('.live-feed-empty');
+ return {
+ ok: true,
+ placeholderBefore,
+ placeholderPresent: !!placeholderAfter,
+ placeholderText: placeholderAfter ? (placeholderAfter.textContent || '').trim() : '',
+ ariaHidden: placeholderAfter ? placeholderAfter.getAttribute('aria-hidden') : null,
+ };
+ });
+ assert(result.ok, `setup failed: ${result.reason || ''}`);
+ assert(
+ result.placeholderBefore === false,
+ 'precondition: placeholder must be removed before rebuildFeedList()',
+ );
+ assert(
+ result.placeholderPresent,
+ 'rebuildFeedList MUST re-add .live-feed-empty placeholder when missing',
+ );
+ assert(
+ /Waiting for packets/i.test(result.placeholderText),
+ `re-added placeholder copy must match. Got: ${JSON.stringify(result.placeholderText)}`,
+ );
+ assert(
+ result.ariaHidden === 'true',
+ `re-added placeholder must have aria-hidden="true" (got ${JSON.stringify(result.ariaHidden)})`,
+ );
+ });
+
// Test 11: Live page heat checkbox disabled by matrix/ghosts mode
await test('Live heat disabled when ghosts mode active', async () => {
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });