fix(#1207): empty-state placeholder for Live Feed panel (no more orphan chrome) (#1210)

Red commit: `6c28227884a1e79e277653465028365dc0863171` — CI:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1207

Fixes #1207

## Diagnosis

The Live Map page renders `#liveFeed` (bottom-left panel) with two
header buttons — `◫` (panel-corner-btn) and `✕` (feed-hide-btn) — but
its `.panel-content` body has zero children on first paint, before any
packets have been ingested via WebSocket. The user-reported "X + book
icons, no content" is exactly these two header buttons sitting on an
empty body.

**Verdict:** intended panel, missing content due to a data race — the
chrome mounts in HTML before the WS pushes its first packet. Not
orphaned, not a leftover from #1186.

## Fix

- Always render a persistent `.live-feed-empty` placeholder ("Waiting
for packets…") inside `#liveFeed .panel-content`.
- CSS hides it via `.live-feed .panel-content:has(.live-feed-item)
.live-feed-empty { display: none; }` when real feed items exist.
- `rebuildFeedList` re-adds the placeholder defensively after a wipe;
eviction loop counts `.live-feed-item` only so the placeholder is never
trimmed out.

All colors via CSS variables (`var(--text-muted)`).

## Test (RED → GREEN)

- **RED** `6c28227884a1e79e277653465028365dc0863171` —
`test-e2e-playwright.js` adds a new test ("#1207 Live Feed panel never
renders as empty chrome") that wipes `.live-feed-item` children to
simulate the empty state and asserts the panel body has visible text or
children. Fails on master.
- **GREEN** `a5af80960ac42759ec83fd5ca5a72e81856228d4` — adds the
placeholder; test now passes.

## Acceptance criteria

- [x] No empty panel chrome visible on Live Map page
- [x] Panel renders "Waiting for packets…" while feed is empty
- [x] CSS auto-hides placeholder when packets arrive
- [x] E2E assertion in `test-e2e-playwright.js` enforces non-empty
`.panel-content` on `#liveFeed`

## Files

- `public/live.js` — HTML markup + `rebuildFeedList` re-add +
eviction-loop guard
- `public/live.css` — `.live-feed-empty` style + `:has()` hide rule
- `test-e2e-playwright.js` — regression test

---------

Co-authored-by: clawbot <clawbot@kpabap.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-05-15 22:34:17 -07:00
committed by GitHub
co-authored by clawbot openclaw-bot
parent dbb013a6bf
commit 4925770aa4
3 changed files with 247 additions and 2 deletions
+15
View File
@@ -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;
+23 -2
View File
@@ -921,7 +921,9 @@
<button class="panel-corner-btn" data-panel="liveFeed" title="Move panel to next corner" aria-label="Move panel to next corner">◫</button>
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
</div>
<div class="panel-content" aria-live="polite" aria-relevant="additions" role="log"></div>
<div class="panel-content" aria-live="polite" aria-relevant="additions" role="log">
<div class="live-feed-empty" aria-hidden="true">Waiting for packets…</div>
</div>
</div>
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed">📋</button>
<div id="nodeDetailBackdrop" class="node-detail-backdrop"></div>
@@ -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) {
+209
View File
@@ -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' });