diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 7e672815..b82491b3 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -265,6 +265,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt
+ BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
diff --git a/public/packets.js b/public/packets.js
index b222e290..5ad0c636 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -2841,15 +2841,18 @@
}
}
- // Location: from ADVERT lat/lon, or from known node via pubkey/sender name
- let locationHtml = 'โ';
+ // Location: from ADVERT lat/lon, or from known node via pubkey/sender name.
+ // Issue #1281: only render the row when we actually have transmitter GPS.
+ // Non-ADVERT packets don't carry GPS in the unencrypted payload, so the row
+ // would otherwise render as "โ" and waste a slot on ~90% of packet types.
+ let locationHtml = '';
let locationNodeKey = null;
if (decoded.lat != null && decoded.lon != null && !(decoded.lat === 0 && decoded.lon === 0)) {
locationNodeKey = decoded.pubKey || decoded.srcPubKey || '';
const nodeName = decoded.name || '';
locationHtml = `${decoded.lat.toFixed(5)}, ${decoded.lon.toFixed(5)}`;
if (nodeName) locationHtml = `${escapeHtml(nodeName)} โ ${locationHtml}`;
- if (locationNodeKey) locationHtml += ` ๐map`;
+ if (locationNodeKey) locationHtml += ` ๐map`;
} else {
// Try to resolve sender node location from nodes list
const senderKey = decoded.pubKey || decoded.srcPubKey;
@@ -2861,7 +2864,7 @@
locationNodeKey = nodeData.node.public_key;
locationHtml = `${nodeData.node.lat.toFixed(5)}, ${nodeData.node.lon.toFixed(5)}`;
if (nodeData.node.name) locationHtml = `${escapeHtml(nodeData.node.name)} โ ${locationHtml}`;
- locationHtml += ` ๐map`;
+ locationHtml += ` ๐map`;
} else if (senderName && !senderKey) {
// Search by name
const searchData = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null);
@@ -2870,7 +2873,7 @@
locationNodeKey = match.public_key;
locationHtml = `${match.lat.toFixed(5)}, ${match.lon.toFixed(5)}`;
locationHtml = `${escapeHtml(match.name)} โ ${locationHtml}`;
- locationHtml += ` ๐map`;
+ locationHtml += ` ๐map`;
}
}
} catch {}
@@ -2896,7 +2899,7 @@
${messageHtml}
- Observer
- ${obsNameOnly(effectivePkt.observer_id)}${obsIataBadge(effectivePkt)}
- - Location
- ${locationHtml}
+ ${locationHtml ? `- Location
- ${locationHtml}
` : ''}
- SNR / RSSI
- ${snr != null ? snr + ' dB' : 'โ'} / ${rssi != null ? rssi + ' dBm' : 'โ'}
- Route Type
- ${routeTypeName(pkt.route_type)}
- Payload Type
- ${typeName}
diff --git a/public/style.css b/public/style.css
index 7b356b23..8bc92257 100644
--- a/public/style.css
+++ b/public/style.css
@@ -917,6 +917,10 @@ body.scroll-locked { overflow: hidden; }
}
.detail-meta dt { color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
.detail-meta dd { font-weight: 500; margin-bottom: 4px; }
+/* #1281: ๐map link inside detail-meta โ UA-default blue is unreadable on
+ * dark backgrounds. Force theme-aware color via --accent. */
+.loc-map-link { color: var(--accent); font-size: 0.85em; text-decoration: none; }
+.loc-map-link:hover { text-decoration: underline; }
.observation-current { background: var(--accent-bg, rgba(0,122,255,0.1)); font-weight: 600; }
.detail-obs-row:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); }
.detail-obs-table th { font-size: 0.8em; text-transform: uppercase; color: var(--text-muted); }
diff --git a/test-issue-1281-location-row-e2e.js b/test-issue-1281-location-row-e2e.js
new file mode 100644
index 00000000..006d13aa
--- /dev/null
+++ b/test-issue-1281-location-row-e2e.js
@@ -0,0 +1,155 @@
+/**
+ * #1281 โ Packet detail Location row + ๐map link contrast.
+ *
+ * Bug:
+ * A) - Location
- โ
renders unconditionally on every packet,
+ * wasting a row on ~90% of packet types (only ADVERT carries unencrypted
+ * transmitter GPS).
+ * B) The trailing `๐map` link has no class/color โ inherits UA-default
+ * blue โ unreadable in dark mode.
+ *
+ * Asserts:
+ * 1. Some non-ADVERT packet detail does NOT contain - Location
.
+ * 2. Some ADVERT packet detail DOES contain - Location
with coords.
+ * 3. The ๐map link uses class="loc-map-link" with color = --accent
+ * (NOT the default UA blue rgb(0,0,238)).
+ *
+ * Usage: BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js
+ */
+'use strict';
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+
+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 normRgb(s) {
+ const m = s && s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
+ if (!m) return null;
+ return `rgb(${m[1]}, ${m[2]}, ${m[3]})`;
+}
+
+async function gotoPackets(page) {
+ await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => {
+ localStorage.removeItem('meshcore-groupbyhash');
+ localStorage.setItem('meshcore-time-window', '525600');
+ });
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 });
+}
+
+// Click rows until detail pane's Payload Type matches `wantType` (e.g. "Advert"
+// or any non-"Advert"). Returns true on hit, false if exhausted.
+async function findPacketDetailByType(page, predicate, maxRows = 40) {
+ await page.waitForTimeout(400);
+ const rows = await page.$$('table tbody tr[data-hash][data-action]');
+ for (let i = 0; i < Math.min(rows.length, maxRows); i++) {
+ await rows[i].click({ timeout: 3000 }).catch(() => null);
+ await page.waitForTimeout(350);
+ const meta = await page.evaluate(() => {
+ const dts = document.querySelectorAll('dl.detail-meta dt');
+ let typeName = null;
+ let hasLocation = false;
+ let locationText = '';
+ for (const dt of dts) {
+ const label = dt.textContent.trim();
+ const dd = dt.nextElementSibling;
+ if (label === 'Payload Type') typeName = dd ? dd.textContent.trim() : null;
+ if (label === 'Location') { hasLocation = true; locationText = dd ? dd.textContent.trim() : ''; }
+ }
+ return { typeName, hasLocation, locationText };
+ });
+ if (predicate(meta)) return meta;
+ }
+ return null;
+}
+
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(15000);
+ page.on('pageerror', (e) => console.error('[pageerror]', e.message));
+
+ console.log(`\n=== #1281 Location row + map link contrast E2E against ${BASE} ===`);
+
+ await step('Non-ADVERT packet detail does NOT render - Location
', async () => {
+ await gotoPackets(page);
+ // Filter to a non-ADVERT type to make the search efficient.
+ const meta = await findPacketDetailByType(
+ page,
+ (m) => m.typeName && m.typeName !== 'Advert',
+ 40
+ );
+ assert(meta, 'No non-ADVERT packet found in first 40 rows');
+ assert(!meta.hasLocation,
+ `Expected NO - Location
for type "${meta.typeName}", but found one with text "${meta.locationText}"`);
+ });
+
+ await step('ADVERT packet detail STILL renders - Location
with GPS coords', async () => {
+ await gotoPackets(page);
+ // Filter UI to ADVERTs to guarantee we find one.
+ const fInput = await page.$('#packetFilterInput');
+ if (fInput) {
+ await fInput.fill('type == ADVERT');
+ await page.keyboard.press('Enter');
+ await page.waitForTimeout(600);
+ }
+ const meta = await findPacketDetailByType(
+ page,
+ (m) => m.typeName === 'Advert' && m.hasLocation,
+ 40
+ );
+ assert(meta, 'No ADVERT packet with Location row found in first 40 ADVERT rows');
+ assert(/-?\d+\.\d+\s*,\s*-?\d+\.\d+/.test(meta.locationText),
+ `ADVERT Location should contain GPS coords, got: "${meta.locationText}"`);
+ });
+
+ await step('๐map link uses class="loc-map-link" with color = var(--accent)', async () => {
+ // Reuse the ADVERT detail pane left open from the previous step.
+ const result = await page.evaluate(() => {
+ const link = document.querySelector('dl.detail-meta a.loc-map-link');
+ if (!link) return { missing: true };
+ const cs = getComputedStyle(link);
+ const accentRaw = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
+ // Resolve --accent value to its computed rgb() via a probe element.
+ const probe = document.createElement('span');
+ probe.style.color = `var(--accent)`;
+ document.body.appendChild(probe);
+ const accentRgb = getComputedStyle(probe).color;
+ probe.remove();
+ return {
+ linkColor: cs.color,
+ accentRgb,
+ accentRaw,
+ href: link.getAttribute('href'),
+ text: link.textContent.trim(),
+ };
+ });
+ assert(!result.missing,
+ ' not found in detail pane โ implementation must apply the class');
+ const link = normRgb(result.linkColor);
+ const accent = normRgb(result.accentRgb);
+ console.log(` link.color=${result.linkColor} --accentโ${result.accentRgb} (raw "${result.accentRaw}")`);
+ assert(link === accent,
+ `๐map link color ${result.linkColor} must equal --accent (${result.accentRgb}); ` +
+ `default UA blue (rgb(0, 0, 238)) is not acceptable`);
+ assert(link !== 'rgb(0, 0, 238)',
+ 'Link color is UA-default blue โ class is missing or CSS rule does not match');
+ });
+
+ await browser.close();
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed === 0 ? 0 : 1);
+})().catch((e) => { console.error(e); process.exit(1); });