diff --git a/.eslintrc.json b/.eslintrc.json
index 03a4a5099..635ab181e 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -245,6 +245,7 @@
"invalidateApiCache": "readonly",
"isFavorite": "readonly",
"isPathHashSizeWarn": "readonly",
+ "isUnscopedFlood": "readonly",
"isTransportRoute": "readonly",
"makeColumnsResizable": "readonly",
"makeRoleMarkerSVG": "readonly",
@@ -266,6 +267,7 @@
"pullReconnect": "readonly",
"qrcode": "readonly",
"registerPage": "readonly",
+ "renderNoScope": "readonly",
"renderPathHashSize": "readonly",
"renderVersionCard": "readonly",
"renderSkewBadge": "readonly",
diff --git a/public/app.js b/public/app.js
index a5639f119..76796a91b 100644
--- a/public/app.js
+++ b/public/app.js
@@ -102,6 +102,34 @@ function renderPathHashSize(size, text, opts) {
' ' +
label + rec + '';
}
+/**
+ * #480: the ONE decision for "this flood carries no region scope". Decided
+ * from the transmission's route type only: FLOOD (1) is unscoped,
+ * TRANSPORT_FLOOD (0) is scoped whether or not its name is known (an unknown
+ * name has scope_name = '' and is NOT "no scope"). DIRECT (2) and
+ * TRANSPORT_DIRECT (3) are not flood scope, so they get no marker.
+ */
+function isUnscopedFlood(routeType) {
+ return routeType === 1 || routeType === '1';
+}
+/**
+ * #480: the ONE markup for the "no scope" recommendation, shared by the
+ * Channels message meta line and the packet-detail Scope row. Same amber
+ * --path-hash-warn recommendation style as #353, a warning (not error) icon.
+ * The explanation states only the firmware behaviour (companion default flood
+ * scope; repeater flood.max.unscoped / region denyf, firmware
+ * docs/cli_commands.md) and never attributes a low heard-count to it. Returns
+ * '' unless the route type is an unscoped FLOOD.
+ */
+function renderNoScope(routeType) {
+ if (!isUnscopedFlood(routeType)) return '';
+ const tip = 'Sent without a scope. The message floods outside your region, and repeaters that limit ' +
+ 'unscoped traffic (flood.max.unscoped / region denyf) may stop it. ' +
+ 'Recommended: set a default scope (region) in the companion app.';
+ return '' +
+ ' ' +
+ 'no scope — sent without a scope. Recommended: set a default scope (region) in the companion app ';
+}
/**
* scopeName is optional (callers that don't pass it get the original
* unscoped "T" badge). Pass a packet's scope_name to also surface the
diff --git a/public/channels.js b/public/channels.js
index 11d2492b7..39b0d1149 100644
--- a/public/channels.js
+++ b/public/channels.js
@@ -2840,6 +2840,11 @@
const isTransportRoute = msg.routeType === 0 || msg.routeType === 3;
if (msg.scope) meta.push(`scope: ${escapeHtml(msg.scope)}`);
else if (isTransportRoute) meta.push('scope: unknown');
+ else if (typeof renderNoScope === 'function') {
+ // #480: no scope on a plain FLOOD (decided from the route type, not the name).
+ const noScopeHtml = renderNoScope(msg.routeType);
+ if (noScopeHtml) meta.push(noScopeHtml);
+ }
// msg.area (set server-side from the message's own path[0]
// entry-point repeater, not from the scope string) is where the
// SENDER physically was — distinct from the scope-linked area
diff --git a/public/packets.js b/public/packets.js
index af9c8011e..28fd030d4 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -3768,6 +3768,17 @@
return inner ? '
Hash Size ' + inner + ' ' : '';
}
+ // #480: Scope row. A named/unknown scope keeps its existing display; an
+ // unscoped FLOOD GRP_TXT/GRP_DATA gets the shared "no scope" marker.
+ function scopeDetailHtml(pkt) {
+ if (pkt.scope_name != null) {
+ return 'Scope ' + (pkt.scope_name !== '' ? escapeHtml(pkt.scope_name) : 'unknown scope ') + ' ';
+ }
+ if (pkt.payload_type !== 5 && pkt.payload_type !== 6) return '';
+ const marker = renderNoScope(pkt.route_type);
+ return marker ? 'Scope ' + marker + ' ' : '';
+ }
+
async function renderDetail(panel, data, chosenObsId) {
const pkt = data.packet;
const observations = data.observations || [];
@@ -4004,7 +4015,7 @@
${locationHtml ? `Location ${locationHtml} ` : ''}
SNR / RSSI ${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}
Route Type ${routeTypeName(pkt.route_type)}
- ${pkt.scope_name != null ? `Scope ${pkt.scope_name !== '' ? escapeHtml(pkt.scope_name) : 'unknown scope '} ` : ''}
+ ${scopeDetailHtml(pkt)}
${hashSizeDetailHtml(hashSize)}
Propagation ${propagationHtml}
${transportCodesRow}
@@ -4689,6 +4700,7 @@
getDetailPreview,
buildDetailMessageHtml,
hashSizeDetailHtml,
+ scopeDetailHtml,
sortGroupChildren,
getPathHopCount,
renderDecodedPacket,
diff --git a/public/style.css b/public/style.css
index e0a290feb..f4c54856b 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2036,6 +2036,9 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
* public/app.js). The icon + sr-only text carry it without colour. */
.path-hash-warn { color: var(--path-hash-warn); font-weight: 600; }
.path-hash-warn .ph-icon { margin-right: 3px; vertical-align: -0.125em; }
+/* #480 — "no scope" marker (renderNoScope in app.js): reuses the amber
+ * --path-hash-warn recommendation colour; stays compact on most messages. */
+.no-scope { white-space: nowrap; }
.ch-path-hash-badge--warn { border-color: var(--path-hash-warn); align-items: center; }
.node-path-hash-badge { background: var(--surface-2); border: 1px solid var(--path-hash-warn); font-size: 10px; }
.ch-analyze-link { color: var(--link-color); text-decoration: none; margin-left: 8px; }
diff --git a/scripts/e2e-shards.json b/scripts/e2e-shards.json
index 5b79fa183..c2fcdb171 100644
--- a/scripts/e2e-shards.json
+++ b/scripts/e2e-shards.json
@@ -1,6 +1,14 @@
{
"version": 1,
"suites": [
+ {
+ "file": "test-issue-480-no-scope-e2e.js",
+ "shard": 2,
+ "env": { "BASE_URL": "$BASE_URL" },
+ "output": "e2e",
+ "meanSeconds": 0,
+ "measurementNote": "New suite; no CI duration measured yet. Assigned to the lowest current measured shard total."
+ },
{
"file": "test-perf-ws-history-e2e.js",
"shard": 2,
diff --git a/test-all.sh b/test-all.sh
index c4610234c..1934b557f 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -197,6 +197,7 @@ run test-issue-1562-observers-summary.js
run test-issue-1509-nav-active-bg.js
run test-issue-1509-detect-preset.js
run test-issue-353-path-hash-warn.js
+run test-issue-480-no-scope.js
run test-live.js
run test-issue-220-live-filter-readiness.js
run test-coverage-gate.js
diff --git a/test-issue-353-path-hash-warn-e2e.js b/test-issue-353-path-hash-warn-e2e.js
index c6e6b8348..e590d0c43 100644
--- a/test-issue-353-path-hash-warn-e2e.js
+++ b/test-issue-353-path-hash-warn-e2e.js
@@ -245,7 +245,7 @@ async function assertContrast(page, selector, where) {
const size = page.locator('dl.detail-meta .detail-hash-size');
await size.waitFor();
assert((await size.textContent()) === '2 bytes', 'text ' + await size.textContent());
- assert(await page.locator('dl.detail-meta .path-hash-warn').count() === 0, '2-byte packet warns');
+ assert(await page.locator('dl.detail-meta .detail-hash-size.path-hash-warn').count() === 0, '2-byte packet warns');
});
await step(`${v.name}: node detail shows the 1-byte badge, not on a 2-byte node`, async () => {
diff --git a/test-issue-480-no-scope-e2e.js b/test-issue-480-no-scope-e2e.js
new file mode 100644
index 000000000..4119dd553
--- /dev/null
+++ b/test-issue-480-no-scope-e2e.js
@@ -0,0 +1,262 @@
+/**
+ * #480 — browser regression: a channel message sent without a region scope
+ * (route type FLOOD) shows a compact amber "no scope" marker on the Channels
+ * meta line and in the packet-detail Scope row; scoped messages (including a
+ * scoped one whose name is unknown), DIRECT routes and non-channel packets
+ * show none. Light and dark theme, desktop and 360x800, axe color-contrast on
+ * every marker, no horizontal overflow.
+ *
+ * Uses the real fixture (an unscoped #wardriving message, the unscoped
+ * GRP_DATA packet 17910000deadbeef) plus channels.js' own test hooks and a
+ * stubbed /api/packets/ for the scoped variants. Only the local test
+ * server is contacted.
+ *
+ * Usage: BASE_URL=http://localhost:13581 node test-issue-480-no-scope-e2e.js
+ * Optional: SCREENSHOT_DIR= saves one screenshot per page/theme/viewport.
+ */
+'use strict';
+const path = require('path');
+const { chromium } = require('playwright');
+const { AxeBuilder } = require('@axe-core/playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+const SHOTS = process.env.SCREENSHOT_DIR || '';
+const GRP_DATA_HASH = '17910000deadbeef';
+const H_UNSCOPED = 'e2e480aa11223344';
+const H_SCOPED = 'e2e480bb11223344';
+const H_UNKNOWN = 'e2e480cc11223344';
+const H_DIRECT = 'e2e480dd11223344';
+const H_TFNULL = 'e2e480ff11223344';
+const H_ADVERT = 'e2e480ee11223344';
+const DECODED = JSON.stringify({ type: 'CHAN', channel: '#e2e-480', sender: 'ScopeNode', text: 'scope check' });
+const TIP = /^Sent without a scope\. The message floods outside your region, and repeaters that limit unscoped traffic \(flood\.max\.unscoped \/ region denyf\) may stop it\. Recommended: set a default scope \(region\) in the companion app\.$/;
+
+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'); }
+
+function packetBody(hash, o) {
+ const raw = '15' + '00' + 'aabbccddeeff00112233445566778899';
+ const obs = {
+ id: 948000001, raw_hex: raw, timestamp: '2026-10-07T10:00:00Z',
+ observer_id: 'e2e-480-obs', observer_name: 'e2e-480-obs', direction: 'rx',
+ snr: -5, rssi: -101, score: null, hash, route_type: o.route_type, payload_type: o.payload_type,
+ payload_version: 0, path_json: '[]', decoded_json: DECODED, created_at: '2026-10-07T10:00:00Z',
+ };
+ return {
+ packet: {
+ id: 948000, hash, raw_hex: raw, route_type: o.route_type, payload_type: o.payload_type, payload_version: 0,
+ first_seen: obs.timestamp, timestamp: obs.timestamp, observer_id: obs.observer_id,
+ snr: -5, rssi: -101, path_json: '[]', decoded_json: DECODED, scope_name: o.scope_name,
+ },
+ path: [], observation_count: 1, observations: [obs],
+ };
+}
+const BODIES = {
+ [H_UNSCOPED]: packetBody(H_UNSCOPED, { route_type: 1, payload_type: 5, scope_name: null }),
+ [H_SCOPED]: packetBody(H_SCOPED, { route_type: 0, payload_type: 5, scope_name: '#dk' }),
+ [H_UNKNOWN]: packetBody(H_UNKNOWN, { route_type: 0, payload_type: 5, scope_name: '' }),
+ [H_DIRECT]: packetBody(H_DIRECT, { route_type: 2, payload_type: 5, scope_name: null }),
+ [H_TFNULL]: packetBody(H_TFNULL, { route_type: 0, payload_type: 5, scope_name: null }),
+ [H_ADVERT]: packetBody(H_ADVERT, { route_type: 1, payload_type: 4, scope_name: null }),
+};
+async function stubPackets(context) {
+ await context.route((url) => /^\/api\/packets\/e2e480/.test(url.pathname), async (route) => {
+ const hash = new URL(route.request().url()).pathname.split('/').pop();
+ try {
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BODIES[hash]) });
+ } catch (_) { try { await route.abort(); } catch (__) { /* closed */ } }
+ });
+}
+
+async function readMarker(locator) {
+ return locator.evaluate((el) => {
+ const probe = document.createElement('span');
+ probe.style.color = 'var(--path-hash-warn)';
+ document.body.appendChild(probe);
+ const want = getComputedStyle(probe).color;
+ probe.style.color = 'var(--warning)';
+ const warning = getComputedStyle(probe).color;
+ probe.style.color = 'var(--danger)';
+ const danger = getComputedStyle(probe).color;
+ probe.remove();
+ const icon = el.querySelector('svg.ph-icon use[href$="#ph-warning"]');
+ const r = el.getBoundingClientRect();
+ return {
+ cls: el.className, title: el.getAttribute('title') || '', color: getComputedStyle(el).color, want, warning, danger,
+ iconHidden: !!icon && icon.closest('svg').getAttribute('aria-hidden') === 'true',
+ iconVisible: !!icon && icon.closest('svg').getBoundingClientRect().width > 0,
+ visible: Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent).join(''),
+ sr: (el.querySelector('.sr-only') || {}).textContent || '',
+ right: r.right, vw: document.documentElement.clientWidth,
+ overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ };
+ });
+}
+function assertMarker(w, where) {
+ assert(w.cls.split(/\s+/).includes('no-scope') && w.cls.split(/\s+/).includes('path-hash-warn'), where + ': class ' + w.cls);
+ assert(w.iconHidden && w.iconVisible, where + ': warning icon missing');
+ assert(w.visible === 'no scope', where + ': visible text ' + JSON.stringify(w.visible));
+ assert(/^ — sent without a scope\. Recommended: set a default scope/.test(w.sr), where + ': sr text ' + JSON.stringify(w.sr));
+ assert(TIP.test(w.title), where + ': tooltip ' + JSON.stringify(w.title));
+ assert(w.color === w.want && w.want === w.warning, where + ': colour ' + w.color + ' vs ' + w.want);
+ assert(w.want !== w.danger, where + ': amber must not equal --danger');
+ assert(w.right <= w.vw + 0.5, where + ': runs off-screen');
+ assert(w.overflow <= 0, where + ': page scrolls horizontally by ' + w.overflow + 'px');
+}
+async function assertContrast(page, selector, where) {
+ await page.waitForFunction((sel) => {
+ const el = document.querySelector(sel);
+ return !!el && document.getAnimations().every((a) => {
+ const t = a.effect && a.effect.target;
+ return !(t && t.contains(el)) || a.playState !== 'running';
+ });
+ }, selector);
+ const r = await new AxeBuilder({ page }).include(selector).withRules(['color-contrast']).analyze();
+ const bad = r.violations.flatMap((v) => v.nodes.map((n) => n.failureSummary || n.html));
+ assert(bad.length === 0, where + ': axe color-contrast ' + bad.join(' | '));
+ const measured = r.passes.reduce((k, v) => k + v.nodes.length, 0);
+ if (measured < 1) {
+ const ratio = await page.$eval(selector, (el) => {
+ const toRgb = (s) => (s.match(/\d+(\.\d+)?/g) || []).slice(0, 3).map(Number);
+ const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
+ const lum = (x) => 0.2126 * lin(x[0]) + 0.7152 * lin(x[1]) + 0.0722 * lin(x[2]);
+ let bgEl = el, bg = null;
+ while (bgEl) { const c = getComputedStyle(bgEl).backgroundColor; if (c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent') { bg = c; break; } bgEl = bgEl.parentElement; }
+ const lf = lum(toRgb(getComputedStyle(el).color)), lb = lum(toRgb(bg || 'rgb(255,255,255)'));
+ return (Math.max(lf, lb) + 0.05) / (Math.min(lf, lb) + 0.05);
+ });
+ assert(ratio >= 4.5, where + ': contrast ' + ratio.toFixed(2) + ':1 < AA 4.5');
+ }
+}
+
+(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=== #480 channel messages without a region scope (${BASE}) ===`);
+
+ const variants = [
+ { name: 'light desktop', theme: 'light', viewport: { width: 1400, height: 900 } },
+ { name: 'dark desktop', theme: 'dark', viewport: { width: 1400, height: 900 } },
+ { name: 'light 360x800', theme: 'light', viewport: { width: 360, height: 800 } },
+ { name: 'dark 360x800', theme: 'dark', viewport: { width: 360, height: 800 } },
+ ];
+
+ // API shape the UI relies on (AGENTS.md rule 4): routeType, not scope name.
+ const probe = await browser.newContext();
+ const msgs = (await (await probe.request.get(BASE + '/api/channels/%23wardriving/messages?limit=5')).json()).messages || [];
+ const grp = await (await probe.request.get(BASE + '/api/packets/' + GRP_DATA_HASH)).json();
+ await probe.close();
+ await step('API: fixture channel messages carry routeType 1 and no scope; GRP_DATA packet route_type 1', async () => {
+ assert(msgs.length > 0, 'no fixture messages');
+ assert(msgs.every((m) => m.routeType === 1 && (m.scope == null)), JSON.stringify(msgs.map((m) => [m.routeType, m.scope])));
+ assert(grp.packet.route_type === 1 && grp.packet.payload_type === 6 && grp.packet.scope_name == null, 'GRP_DATA shape');
+ });
+
+ for (const v of variants) {
+ const context = await browser.newContext({ viewport: v.viewport, colorScheme: v.theme });
+ await context.addInitScript((theme) => { try { localStorage.setItem('meshcore-theme', theme); } catch (_) {} }, v.theme);
+ await stubPackets(context);
+ const page = await context.newPage();
+ page.setDefaultTimeout(15000);
+ page.on('pageerror', (e) => console.error('[pageerror]', e.message));
+ const shot = async (label) => {
+ if (SHOTS) await page.screenshot({ path: path.join(SHOTS, `480-${label}-${v.name.replace(/\W+/g, '-')}.png`) });
+ };
+
+ await step(`${v.name}: theme applied`, async () => {
+ await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
+ const t = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
+ assert(t === v.theme, 'data-theme is ' + t);
+ });
+
+ await step(`${v.name}: channels — real fixture message from the API shows the marker`, async () => {
+ await page.goto(BASE + '/#/channels/%23wardriving', { waitUntil: 'load' });
+ const marker = page.locator('#chMessages .no-scope').first();
+ await marker.waitFor();
+ await marker.scrollIntoViewIfNeeded();
+ assertMarker(await readMarker(marker), 'channels (fixture)');
+ await assertContrast(page, '#chMessages .no-scope', 'channels (fixture)');
+ assert(await page.locator('#chMessages .ch-msg-meta', { hasText: 'scope:' }).count() === 0, 'unscoped fixture shows "scope:"');
+ await shot('channels-fixture');
+ });
+
+ await step(`${v.name}: channels — marker only on unscoped; scoped/unknown/direct unchanged`, async () => {
+ await page.waitForFunction(() => typeof window._channelsRenderMessagesForTest === 'function');
+ await page.evaluate(() => {
+ const base = { text: 'hello', timestamp: '2026-10-07T08:00:00Z', observers: [], repeats: 1 };
+ window._channelsSetStateForTest({
+ channels: [{ hash: '#e2e-480', name: '#e2e-480', messageCount: 5 }],
+ messages: [
+ Object.assign({}, base, { sender: 'Unscoped', packetHash: 'h1', routeType: 1, scope: null }),
+ Object.assign({}, base, { sender: 'Scoped', packetHash: 'h2', routeType: 0, scope: '#dk' }),
+ Object.assign({}, base, { sender: 'UnknownScope', packetHash: 'h3', routeType: 0, scope: '' }),
+ Object.assign({}, base, { sender: 'TransportNoName', packetHash: 'h5', routeType: 0, scope: null }),
+ Object.assign({}, base, { sender: 'Direct', packetHash: 'h4', routeType: 2, scope: null }),
+ ],
+ selectedHash: '#e2e-480',
+ });
+ document.querySelector('.ch-layout')?.classList.add('ch-detail-open');
+ window._channelsRenderMessagesForTest();
+ });
+ const metas = await page.locator('#chMessages .ch-msg-meta').evaluateAll((els) => els.map((el) => ({
+ marker: !!el.querySelector('.no-scope'), text: el.textContent })));
+ assert(metas.length === 5, 'meta lines ' + metas.length);
+ assert(JSON.stringify(metas.map((m) => m.marker)) === '[true,false,false,false,false]', JSON.stringify(metas));
+ assert(metas[1].text.includes('scope: #dk'), 'scoped meta: ' + metas[1].text);
+ assert(metas[2].text.includes('scope: unknown'), 'unknown-scope meta: ' + metas[2].text);
+ assert(metas[3].text.includes('scope: unknown'), 'transport meta: ' + metas[3].text);
+ assert(!metas[4].text.includes('scope'), 'direct meta: ' + metas[4].text);
+ const marker = page.locator('#chMessages .no-scope');
+ await marker.scrollIntoViewIfNeeded();
+ assertMarker(await readMarker(marker), 'channels (injected)');
+ await assertContrast(page, '#chMessages .no-scope', 'channels (injected)');
+ await shot('channels');
+ });
+
+ await step(`${v.name}: packet detail — unscoped GRP_TXT shows the Scope marker`, async () => {
+ await page.goto(`${BASE}/#/packet/${H_UNSCOPED}`, { waitUntil: 'load' });
+ await page.reload({ waitUntil: 'load' });
+ const marker = page.locator('dl.detail-meta dd .no-scope');
+ await marker.waitFor();
+ await marker.scrollIntoViewIfNeeded();
+ assertMarker(await readMarker(marker), 'packet detail');
+ assert((await page.locator('dl.detail-meta dt', { hasText: /^Scope$/ }).count()) === 1, 'Scope row missing');
+ await assertContrast(page, 'dl.detail-meta dd .no-scope', 'packet detail');
+ await shot('packet');
+ });
+
+ await step(`${v.name}: packet detail — real unscoped GRP_DATA shows the marker`, async () => {
+ await page.goto(`${BASE}/#/packet/${GRP_DATA_HASH}`, { waitUntil: 'load' });
+ await page.reload({ waitUntil: 'load' });
+ const marker = page.locator('dl.detail-meta dd .no-scope');
+ await marker.waitFor();
+ assertMarker(await readMarker(marker), 'packet detail (GRP_DATA)');
+ });
+
+ await step(`${v.name}: packet detail — scoped, unknown-scope, direct and non-channel get no marker`, async () => {
+ const expectRow = { [H_SCOPED]: '#dk', [H_UNKNOWN]: 'unknown scope', [H_DIRECT]: null, [H_TFNULL]: null, [H_ADVERT]: null };
+ for (const [hash, text] of Object.entries(expectRow)) {
+ await page.goto(`${BASE}/#/packet/${hash}`, { waitUntil: 'load' });
+ await page.reload({ waitUntil: 'load' });
+ await page.locator('dl.detail-meta').waitFor();
+ assert(await page.locator('dl.detail-meta .no-scope').count() === 0, hash + ' shows the marker');
+ const scopeDd = page.locator('dl.detail-meta dt', { hasText: /^Scope$/ }).locator('xpath=following-sibling::dd[1]');
+ if (text == null) assert(await page.locator('dl.detail-meta dt', { hasText: /^Scope$/ }).count() === 0, hash + ' has a Scope row');
+ else assert((await scopeDd.textContent()).trim() === text, hash + ' scope text ' + (await scopeDd.textContent()));
+ }
+ });
+
+ await context.close();
+ }
+
+ await browser.close();
+ console.log(`\n=== ${passed} passed, ${failed} failed ===`);
+ process.exit(failed ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });
diff --git a/test-issue-480-no-scope.js b/test-issue-480-no-scope.js
new file mode 100644
index 000000000..e0bff3af8
--- /dev/null
+++ b/test-issue-480-no-scope.js
@@ -0,0 +1,195 @@
+/**
+ * #480 — channel messages sent without a region scope get a compact amber
+ * "no scope" marker (like the #353 1-byte path-hash recommendation).
+ *
+ * Covers the ONE shared decision + markup helper in public/app.js
+ * (isUnscopedFlood / renderNoScope), its use on the Channels message meta
+ * line, and the theming contract. The packet-detail Scope row is covered in
+ * test-packets.js, the browser behaviour in test-issue-480-no-scope-e2e.js.
+ *
+ * Run: node test-issue-480-no-scope.js
+ */
+'use strict';
+const vm = require('vm');
+const fs = require('fs');
+const assert = require('assert');
+
+let passed = 0, failed = 0;
+function test(name, fn) {
+ try { fn(); passed++; console.log(' ✅ ' + name); }
+ catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
+}
+
+// Extract the REAL functions from public/app.js (no hand-rolled copies).
+function loadAppFunctions(names) {
+ const source = fs.readFileSync('public/app.js', 'utf8');
+ let extracted = '';
+ for (const name of names) {
+ const start = source.search(new RegExp('^function ' + name + '\\s*\\(', 'm'));
+ if (start < 0) throw new Error('public/app.js no longer declares function ' + name);
+ const open = source.indexOf('{', start);
+ let depth = 0, end = -1;
+ for (let i = open; i < source.length; i++) {
+ if (source[i] === '{') depth++;
+ else if (source[i] === '}' && --depth === 0) { end = i + 1; break; }
+ }
+ extracted += source.slice(start, end) + '\n';
+ }
+ const ctx = { Number, String };
+ vm.createContext(ctx);
+ vm.runInContext(extracted + 'this.__fns = {' + names.join(',') + '};', ctx);
+ return ctx.__fns;
+}
+const helpers = loadAppFunctions(['escapeHtml', 'isUnscopedFlood', 'renderNoScope']);
+const { isUnscopedFlood, renderNoScope } = helpers;
+
+console.log('\n=== #480 isUnscopedFlood ===');
+
+test('FLOOD (1) is unscoped, including the string form a JSON field can carry', () => {
+ assert.strictEqual(isUnscopedFlood(1), true);
+ assert.strictEqual(isUnscopedFlood('1'), true);
+});
+
+test('TRANSPORT_FLOOD (0) is scoped, whether or not the name is known', () => {
+ // The scope NAME is deliberately not an input: a scoped transmission with an
+ // unknown name has scope_name = '' and is NOT "no scope".
+ assert.strictEqual(isUnscopedFlood(0), false);
+ assert.strictEqual(isUnscopedFlood('0'), false);
+ assert.strictEqual(isUnscopedFlood.length, 1);
+});
+
+test('DIRECT (2) and TRANSPORT_DIRECT (3) are not flood scope: no marker', () => {
+ assert.strictEqual(isUnscopedFlood(2), false);
+ assert.strictEqual(isUnscopedFlood(3), false);
+});
+
+test('missing or invalid route types are not unscoped', () => {
+ for (const v of [null, undefined, '', 'abc', 4, -1, 1.5, true, {}, [1]]) {
+ assert.strictEqual(isUnscopedFlood(v), false, 'for ' + JSON.stringify(v));
+ }
+});
+
+console.log('\n=== #480 renderNoScope ===');
+
+test('unscoped flood renders the amber marker: icon, "no scope", explanation', () => {
+ const html = renderNoScope(1);
+ assert.match(html, /^ {
+ assert.match(renderNoScope(1), / — sent without a scope\. Recommended: set a default scope[^<]*<\/span>/);
+});
+
+test('does not claim the missing scope explains a low heard-count', () => {
+ assert.ok(!/heard|repeats|lower|fewer/i.test(renderNoScope(1)));
+});
+
+test('scoped, direct and invalid route types render nothing', () => {
+ for (const v of [0, 2, 3, null, undefined, 'x', 4]) {
+ assert.strictEqual(renderNoScope(v), '', 'for ' + String(v));
+ }
+});
+
+console.log('\n=== #480 channels meta line ===');
+
+const channelsSrc = fs.readFileSync('public/channels.js', 'utf8');
+function makeChannelsSandbox(withHelper) {
+ const noop = () => {};
+ const fakeEl = () => ({
+ addEventListener: noop, querySelector: () => null, querySelectorAll: () => [],
+ classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
+ appendChild: noop, removeChild: noop, setAttribute: noop, getAttribute: () => null,
+ textContent: '', innerHTML: '', style: {}, dataset: {}, scrollTop: 0, scrollHeight: 0,
+ });
+ const chMessagesEl = fakeEl();
+ const doc = {
+ readyState: 'complete', createElement: fakeEl, head: fakeEl(), body: fakeEl(),
+ documentElement: fakeEl(),
+ getElementById: (id) => (id === 'chMessages' ? chMessagesEl : null),
+ querySelector: () => null, querySelectorAll: () => [], addEventListener: noop,
+ };
+ const ctx = {
+ window: { addEventListener: noop, matchMedia: () => ({ matches: false, addListener: noop, addEventListener: noop }) },
+ document: doc, console, Date, Math, JSON, Set, Map, Array, Object, Promise, Response: function () {}, Error,
+ setTimeout, clearTimeout, setInterval, clearInterval,
+ history: { replaceState: noop, pushState: noop },
+ location: { hash: '', href: '', pathname: '/' },
+ navigator: { userAgent: 'node' },
+ RegionFilter: { getRegionParam: () => '' },
+ api: () => Promise.resolve({ messages: [] }),
+ CLIENT_TTL: {}, ChannelDecrypt: undefined,
+ truncate: (s) => s, formatHashHex: (h) => String(h),
+ channelDisplayName: (c) => c && c.name,
+ escapeHtml: helpers.escapeHtml,
+ getSenderColor: () => '#123456',
+ fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
+ btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
+ isTransportRoute: (rt) => rt === 0 || rt === 3,
+ registerPage: noop,
+ };
+ if (withHelper) ctx.renderNoScope = renderNoScope;
+ vm.createContext(ctx);
+ vm.runInContext(channelsSrc, ctx);
+ return { ctx, chMessagesEl };
+}
+function render(msg, withHelper = true) {
+ const { ctx, chMessagesEl } = makeChannelsSandbox(withHelper);
+ ctx.window._channelsSetStateForTest({ messages: [Object.assign({
+ sender: 'Alice', text: 'hello', timestamp: '2026-01-15T10:00:00Z' }, msg)] });
+ ctx.window._channelsRenderMessagesForTest();
+ return chMessagesEl.innerHTML;
+}
+
+test('an unscoped FLOOD message shows the marker where "scope: #dk" would be', () => {
+ const html = render({ routeType: 1, scope: null });
+ assert.match(html, /class="no-scope path-hash-warn"/);
+ assert.ok(!html.includes('scope: '), 'no "scope: …" text on an unscoped message');
+});
+
+test('a scoped message with a name is unchanged: "scope: #dk", no marker', () => {
+ const html = render({ routeType: 0, scope: '#dk' });
+ assert.ok(html.includes('scope: #dk'));
+ assert.ok(!html.includes('no-scope'));
+});
+
+test('a scoped message with an unknown name stays "scope: unknown", not "no scope"', () => {
+ const html = render({ routeType: 0, scope: '' });
+ assert.ok(html.includes('scope: unknown'));
+ assert.ok(!html.includes('no-scope'));
+});
+
+test('DIRECT and TRANSPORT_DIRECT messages get no marker', () => {
+ assert.ok(!render({ routeType: 2, scope: null }).includes('no-scope'));
+ assert.ok(!render({ routeType: 3, scope: '#dk' }).includes('no-scope'));
+});
+
+test('a message without a route type gets no marker', () => {
+ assert.ok(!render({ scope: null }).includes('no-scope'));
+});
+
+test('the marker is guarded when app.js has not provided the helper', () => {
+ assert.ok(!render({ routeType: 1 }, false).includes('no-scope'));
+});
+
+test('the meta line uses the shared helper, not a private copy', () => {
+ assert.ok(channelsSrc.includes('renderNoScope('), 'channels.js must call renderNoScope');
+ assert.ok(!/no scope/.test(channelsSrc.replace(/\/\/.*$/gm, '')), 'no inline "no scope" markup in channels.js');
+});
+
+console.log('\n=== #480 theming ===');
+
+const css = fs.readFileSync('public/style.css', 'utf8');
+test('the marker reuses --path-hash-warn and hardcodes no colour', () => {
+ const rule = css.match(/\.no-scope\s*\{[^}]*\}/);
+ assert.ok(rule, '.no-scope rule exists');
+ assert.ok(!/#[0-9a-fA-F]{3,8}\b|rgb\(|hsl\(/.test(rule[0]), 'no hardcoded colour: ' + rule[0]);
+ assert.ok(/\.path-hash-warn\s*\{[^}]*color:\s*var\(--path-hash-warn\)/.test(css));
+});
+
+console.log('\n' + passed + ' passed, ' + failed + ' failed');
+process.exit(failed ? 1 : 0);
diff --git a/test-packets.js b/test-packets.js
index 4ac735519..a39444afd 100644
--- a/test-packets.js
+++ b/test-packets.js
@@ -1888,6 +1888,50 @@ console.log('\n=== packets.js: detail Hash Size reads the selected observation =
});
}
+// ===== packets.js: detail Scope row marks unscoped channel packets (#480) =====
+console.log('\n=== packets.js: detail Scope row marks unscoped channel packets (#480) ===');
+{
+ const api = loadPacketsSandbox()._packetsTestAPI;
+ const src = fs.readFileSync('public/packets.js', 'utf8');
+ const pkt = (o) => Object.assign({ payload_type: 5, route_type: 1, scope_name: null }, o);
+
+ test('unscoped GRP_TXT (FLOOD) gets a Scope row with the marker and explanation', () => {
+ const html = api.scopeDetailHtml(pkt({}));
+ assert.ok(html.startsWith('Scope '), html);
+ assert.match(html, /class="no-scope path-hash-warn"/);
+ assert.match(html, /#ph-warning/);
+ assert.match(html, /title="Sent without a scope\./);
+ });
+
+ test('unscoped GRP_DATA (FLOOD) gets the same row', () => {
+ assert.match(api.scopeDetailHtml(pkt({ payload_type: 6 })), /no-scope/);
+ });
+
+ test('scoped packets keep the existing display, with no marker', () => {
+ assert.strictEqual(api.scopeDetailHtml(pkt({ route_type: 0, scope_name: '#dk' })), ' Scope #dk ');
+ const unknown = api.scopeDetailHtml(pkt({ route_type: 0, scope_name: '' }));
+ assert.match(unknown, /unknown scope/);
+ assert.ok(!unknown.includes('no-scope'));
+ });
+
+ test('a scoped TRANSPORT_FLOOD with an empty name is NOT marked "no scope"', () => {
+ assert.ok(!api.scopeDetailHtml(pkt({ route_type: 0, scope_name: '' })).includes('no-scope'));
+ });
+
+ test('DIRECT / TRANSPORT_DIRECT channel packets get no marker', () => {
+ assert.strictEqual(api.scopeDetailHtml(pkt({ route_type: 2 })), '');
+ assert.ok(!api.scopeDetailHtml(pkt({ route_type: 3, scope_name: '#dk' })).includes('no-scope'));
+ });
+
+ test('non-channel unscoped packets (e.g. adverts) get no Scope row', () => {
+ for (const t of [0, 2, 4, 8, 9]) assert.strictEqual(api.scopeDetailHtml(pkt({ payload_type: t })), '', 'type ' + t);
+ });
+
+ test('renderDetail emits the Scope row through scopeDetailHtml', () => {
+ assert.ok(src.includes('${scopeDetailHtml(pkt)}'), 'detail-meta list must use the shared row helper');
+ });
+}
+
// ===== packets.js: detail Hash Size warns on 1-byte (#353) =====
console.log('\n=== packets.js: detail Hash Size row warns on 1-byte (#353) ===');
{