diff --git a/docs/screenshots/settings-commands.png b/docs/screenshots/settings-commands.png new file mode 100644 index 0000000..5e851cf Binary files /dev/null and b/docs/screenshots/settings-commands.png differ diff --git a/docs/screenshots/settings-display.png b/docs/screenshots/settings-display.png new file mode 100644 index 0000000..57697ea Binary files /dev/null and b/docs/screenshots/settings-display.png differ diff --git a/docs/screenshots/settings-multidevice.png b/docs/screenshots/settings-multidevice.png new file mode 100644 index 0000000..69f7dbb Binary files /dev/null and b/docs/screenshots/settings-multidevice.png differ diff --git a/docs/screenshots/settings-sessions.png b/docs/screenshots/settings-sessions.png new file mode 100644 index 0000000..9813304 Binary files /dev/null and b/docs/screenshots/settings-sessions.png differ diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index f071b68..556a146 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -146,6 +146,10 @@ const DISPLAY_DEFAULTS = { bellSound: false, notificationPermission: 'default', viewMode: 'auto', + showDeviceBadges: true, // show device name labels on tiles/sidebar + showActivityGlow: true, // show amber border/glow on tiles/sidebar with bells + showHoverPreview: true, // show hover preview popover on tile hover + showActivityDot: true, // show activity dot on tiles/sidebar }; var VIEW_MODES = ['auto', 'fit']; @@ -455,38 +459,42 @@ function buildTileHTML(session, index, mobile) { const isBell = priority === 'bell'; const unseen = session.bell && session.bell.unseen_count; + var ds = loadDisplaySettings(); + let classes = 'session-tile'; - if (isBell) classes += ' session-tile--bell'; + if (isBell && ds.showActivityGlow !== false) classes += ' session-tile--bell'; if (mobile) classes += ` session-tile--tier-${priority}`; const name = session.name || ''; const escapedName = escapeHtml(name); const timeStr = formatTimestamp(session.last_activity_at || null); - // Bell indicator - let bellHtml = ''; - if (unseen && unseen > 0) { - const countStr = unseen > 9 ? '9+' : String(unseen); - bellHtml = `${countStr}`; + // Activity dot — absolute positioned in upper-right corner of tile (no count text) + let bellDotHtml = ''; + if (unseen && unseen > 0 && ds.showActivityDot !== false) { + bellDotHtml = ''; } - // Device badge (shown when multiple sources configured and session has a device name) - const badgeHtml = _sources.length > 1 && session.deviceName - ? `${escapeHtml(session.deviceName)}` - : ''; + // Device badge — right-aligned in header, separate from name span + // Shown when multiple sources configured AND session has a device name + let badgeHtml = ''; + if (_sources.length > 1 && session.deviceName && ds.showDeviceBadges !== false) { + badgeHtml = `${escapeHtml(session.deviceName)}`; + } // Last N lines of snapshot — show more in fit mode so tall tiles fill const snapshot = session.snapshot || ''; - var _tileDs = loadDisplaySettings(); - var _lineCount = (_tileDs.viewMode === 'fit') ? -80 : -20; + var _lineCount = (ds.viewMode === 'fit') ? -80 : -20; const lastLines = snapshot.split('\n').slice(_lineCount).join('\n'); const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : ''; return ( `
` + + bellDotHtml + `
` + - `${escapeHtml(name)}${badgeHtml}` + - `${bellHtml}${escapeHtml(timeStr)}` + + `${escapeHtml(name)}` + + badgeHtml + + `${escapeHtml(timeStr)}` + `
` + `
${ansiToHtml(lastLines)}
` + `` + @@ -505,16 +513,25 @@ function buildSidebarHTML(session, currentSession) { const escapedName = escapeHtml(name); const isActive = name === currentSession; - let classes = 'sidebar-item'; - if (isActive) classes += ' sidebar-item--active'; + var ds = loadDisplaySettings(); const unseen = session.bell && session.bell.unseen_count; + const isBell = unseen && unseen > 0; - // Bell indicator - let bellHtml = ''; - if (unseen && unseen > 0) { - const countStr = unseen > 9 ? '9+' : String(unseen); - bellHtml = `${countStr}`; + let classes = 'sidebar-item'; + if (isActive) classes += ' sidebar-item--active'; + if (isBell && ds.showActivityGlow !== false) classes += ' sidebar-item--bell'; + + // Activity dot — inline in sidebar header (not absolute, since items are smaller) + let bellDotHtml = ''; + if (isBell && ds.showActivityDot !== false) { + bellDotHtml = ''; + } + + // Device badge — between name and bell/delete, pushed right via flex + let badgeHtml = ''; + if (_sources.length > 1 && session.deviceName && ds.showDeviceBadges !== false) { + badgeHtml = `${escapeHtml(session.deviceName)}`; } // Last 20 lines of snapshot @@ -524,8 +541,9 @@ function buildSidebarHTML(session, currentSession) { return ( `
` + `` + `` + @@ -955,6 +973,8 @@ function _previewClickHandler(e) { function showPreview(name) { if (!name || !_currentSessions) return; + var _previewDs = loadDisplaySettings(); + if (_previewDs.showHoverPreview === false) return; var session = _currentSessions.find(function (s) { return s.name === name; }); if (!session || !session.snapshot) return; @@ -1657,6 +1677,18 @@ function onDisplaySettingChange() { ds.gridColumns = raw === 'auto' ? 'auto' : parseInt(raw, 10); } + var showDeviceBadgesEl = document.getElementById('setting-show-device-badges'); + if (showDeviceBadgesEl) ds.showDeviceBadges = showDeviceBadgesEl.checked; + + var showActivityGlowEl = document.getElementById('setting-show-activity-glow'); + if (showActivityGlowEl) ds.showActivityGlow = showActivityGlowEl.checked; + + var showHoverPreviewEl = document.getElementById('setting-show-hover-preview'); + if (showHoverPreviewEl) ds.showHoverPreview = showHoverPreviewEl.checked; + + var showActivityDotEl = document.getElementById('setting-show-activity-dot'); + if (showActivityDotEl) ds.showActivityDot = showActivityDotEl.checked; + saveDisplaySettings(ds); applyDisplaySettings(ds); } @@ -1705,7 +1737,17 @@ function openSettings() { const viewModeEl = $('setting-view-mode'); if (viewModeEl) viewModeEl.value = loadGridViewMode(); - // Populate Notifications tab from display settings + // Populate new display toggle checkboxes + const showDeviceBadgesEl = $('setting-show-device-badges'); + if (showDeviceBadgesEl) showDeviceBadgesEl.checked = settings.showDeviceBadges !== false; + const showActivityGlowEl = $('setting-show-activity-glow'); + if (showActivityGlowEl) showActivityGlowEl.checked = settings.showActivityGlow !== false; + const showHoverPreviewEl = $('setting-show-hover-preview'); + if (showHoverPreviewEl) showHoverPreviewEl.checked = settings.showHoverPreview !== false; + const showActivityDotEl = $('setting-show-activity-dot'); + if (showActivityDotEl) showActivityDotEl.checked = settings.showActivityDot !== false; + + // Populate Sessions tab / bell sound from display settings const bellSoundEl = $('setting-bell-sound'); if (bellSoundEl) bellSoundEl.checked = !!settings.bellSound; @@ -2227,6 +2269,10 @@ function bindStaticEventListeners() { on($('setting-font-size'), 'change', onDisplaySettingChange); on($('setting-hover-delay'), 'change', onDisplaySettingChange); on($('setting-grid-columns'), 'change', onDisplaySettingChange); + on($('setting-show-device-badges'), 'change', onDisplaySettingChange); + on($('setting-show-activity-glow'), 'change', onDisplaySettingChange); + on($('setting-show-hover-preview'), 'change', onDisplaySettingChange); + on($('setting-show-activity-dot'), 'change', onDisplaySettingChange); on($('setting-view-mode'), 'change', function() { var el = $('setting-view-mode'); if (el) { diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index f8a1ade..2664fd2 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -16,7 +16,7 @@ - +

muxplex

@@ -32,7 +32,7 @@
- + - + - + - + - + - +
@@ -88,7 +88,6 @@ @@ -124,6 +123,26 @@
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- + diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index f8bafe0..375b9b4 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -499,6 +499,12 @@ body { border-left: 3px solid var(--accent); } +/* Bell / activity glow — matches .session-tile--bell */ +.sidebar-item--bell { + border-color: var(--bell-border); + box-shadow: 0 0 0 1px var(--bell-border), inset 0 0 12px var(--bell-glow); +} + .sidebar-item-header { display: flex; flex-direction: row; @@ -605,15 +611,29 @@ body { } /* ============================================================ - Bell count badge + Activity dot — absolute positioned in upper-right corner of tile ============================================================ */ -.tile-bell-count { - font-size: 10px; - font-weight: 600; - color: var(--bell); - min-width: 16px; - text-align: right; +.tile-bell-dot { + position: absolute; + top: 6px; + right: 6px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--bell); + animation: bell-pulse 1.4s ease-in-out infinite; + z-index: 1; + pointer-events: none; +} + +/* Sidebar variant: inline (not absolute), fits within header row */ +.sidebar-bell-dot { + position: static; + display: inline-block; + flex-shrink: 0; + width: 7px; + height: 7px; } /* ============================================================ diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 412c15b..18026df 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -451,24 +451,26 @@ test('buildTileHTML sets data-session attribute with escaped session name', () = assert.ok(html.includes('data-session="my<session>"'), 'data-session should be escaped'); }); -test('buildTileHTML shows 9+ when unseen_count exceeds 9', () => { +test('buildTileHTML shows tile-bell-dot (not count) when unseen_count exceeds 9', () => { const session = { name: 's', bell: { unseen_count: 10, seen_at: null, last_fired_at: 100 }, snapshot: '', }; const html = app.buildTileHTML(session, 0, false); - assert.ok(html.includes('9+'), 'should show 9+ for count > 9'); + assert.ok(html.includes('tile-bell-dot'), 'should show tile-bell-dot for count > 9'); + assert.ok(!html.includes('9+'), 'should NOT show 9+ count text (dot only)'); }); -test('buildTileHTML shows exact count when unseen_count is <= 9', () => { +test('buildTileHTML shows tile-bell-dot (not count) when unseen_count is <= 9', () => { const session = { name: 's', bell: { unseen_count: 5, seen_at: null, last_fired_at: 100 }, snapshot: '', }; const html = app.buildTileHTML(session, 0, false); - assert.ok(html.includes('>5<'), 'should show exact count 5'); + assert.ok(html.includes('tile-bell-dot'), 'should show tile-bell-dot for count > 0'); + assert.ok(!html.includes('>5<'), 'should NOT show exact count 5 (dot only)'); }); test('buildTileHTML includes only last 20 lines of snapshot', () => { @@ -1544,17 +1546,17 @@ test('buildSidebarHTML does not add sidebar-item--active class for inactive sess assert.ok(!html.includes('sidebar-item--active'), 'inactive session should not have sidebar-item--active class'); }); -test('buildSidebarHTML renders bell badge with tile-bell class and count when unseen_count > 0', () => { +test('buildSidebarHTML renders bell dot indicator when unseen_count > 0', () => { const session = { name: 's', snapshot: '', bell: { unseen_count: 3 } }; const html = app.buildSidebarHTML(session, ''); - assert.ok(html.includes('tile-bell'), 'should contain tile-bell class when unseen_count > 0'); - assert.ok(html.includes('>3<'), 'should contain unseen count text'); + assert.ok(html.includes('tile-bell-dot'), 'should contain tile-bell-dot class when unseen_count > 0'); + assert.ok(!html.includes('>3<'), 'should NOT contain unseen count text (dot only)'); }); -test('buildSidebarHTML omits bell badge when unseen_count is 0', () => { +test('buildSidebarHTML omits bell dot when unseen_count is 0', () => { const session = { name: 's', snapshot: '', bell: { unseen_count: 0 } }; const html = app.buildSidebarHTML(session, ''); - assert.ok(!html.includes('tile-bell'), 'should not contain tile-bell class when unseen_count is 0'); + assert.ok(!html.includes('tile-bell-dot'), 'should not contain tile-bell-dot when unseen_count is 0'); }); test('buildSidebarHTML HTML-escapes session name to prevent XSS', () => { @@ -3827,3 +3829,124 @@ test('bindStaticEventListeners does NOT bind change event to setting-view-scope' ); }); +// ─── Activity dot / sidebar glow / config toggles (UI/UX improvements) ─────── + +test('buildTileHTML bell dot is positioned before tile-header (absolute overlay)', () => { + const session = { + name: 's', + bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 }, + snapshot: '', + }; + const html = app.buildTileHTML(session, 0, false); + const dotIdx = html.indexOf('tile-bell-dot'); + const headerIdx = html.indexOf('tile-header'); + assert.ok(dotIdx !== -1, 'tile-bell-dot must be present'); + assert.ok(dotIdx < headerIdx, 'tile-bell-dot must appear before tile-header in HTML'); +}); + +test('buildTileHTML omits tile-bell-dot when unseen_count is 0', () => { + const session = { name: 's', bell: { unseen_count: 0 }, snapshot: '' }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(!html.includes('tile-bell-dot'), 'no tile-bell-dot when unseen_count is 0'); +}); + +test('buildSidebarHTML adds sidebar-item--bell class for bell sessions', () => { + const session = { name: 's', snapshot: '', bell: { unseen_count: 3, seen_at: null, last_fired_at: 100 } }; + const html = app.buildSidebarHTML(session, ''); + assert.ok(html.includes('sidebar-item--bell'), 'sidebar item should have sidebar-item--bell class when bell is active'); +}); + +test('buildSidebarHTML does not add sidebar-item--bell when no unseen', () => { + const session = { name: 's', snapshot: '', bell: { unseen_count: 0 } }; + const html = app.buildSidebarHTML(session, ''); + assert.ok(!html.includes('sidebar-item--bell'), 'should NOT have sidebar-item--bell when unseen_count is 0'); +}); + +test('DISPLAY_DEFAULTS includes showDeviceBadges key', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const defaultsStart = source.indexOf('DISPLAY_DEFAULTS'); + const defaultsEnd = source.indexOf('};', defaultsStart); + const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2); + assert.ok(defaultsBody.includes('showDeviceBadges'), 'DISPLAY_DEFAULTS must include showDeviceBadges'); +}); + +test('DISPLAY_DEFAULTS includes showActivityGlow key', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const defaultsStart = source.indexOf('DISPLAY_DEFAULTS'); + const defaultsEnd = source.indexOf('};', defaultsStart); + const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2); + assert.ok(defaultsBody.includes('showActivityGlow'), 'DISPLAY_DEFAULTS must include showActivityGlow'); +}); + +test('DISPLAY_DEFAULTS includes showHoverPreview key', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const defaultsStart = source.indexOf('DISPLAY_DEFAULTS'); + const defaultsEnd = source.indexOf('};', defaultsStart); + const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2); + assert.ok(defaultsBody.includes('showHoverPreview'), 'DISPLAY_DEFAULTS must include showHoverPreview'); +}); + +test('DISPLAY_DEFAULTS includes showActivityDot key', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const defaultsStart = source.indexOf('DISPLAY_DEFAULTS'); + const defaultsEnd = source.indexOf('};', defaultsStart); + const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2); + assert.ok(defaultsBody.includes('showActivityDot'), 'DISPLAY_DEFAULTS must include showActivityDot'); +}); + +test('HTML index.html has setting-show-device-badges checkbox', () => { + const source = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + assert.ok(source.includes('setting-show-device-badges'), 'Display panel must have setting-show-device-badges checkbox'); +}); + +test('HTML index.html has setting-show-activity-glow checkbox', () => { + const source = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + assert.ok(source.includes('setting-show-activity-glow'), 'Display panel must have setting-show-activity-glow checkbox'); +}); + +test('HTML index.html has setting-show-hover-preview checkbox', () => { + const source = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + assert.ok(source.includes('setting-show-hover-preview'), 'Display panel must have setting-show-hover-preview checkbox'); +}); + +test('HTML index.html has setting-show-activity-dot checkbox', () => { + const source = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + assert.ok(source.includes('setting-show-activity-dot'), 'Display panel must have setting-show-activity-dot checkbox'); +}); + +test('CSS style.css has .tile-bell-dot rule', () => { + const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + assert.ok(source.includes('.tile-bell-dot'), 'style.css must have .tile-bell-dot rule'); +}); + +test('CSS style.css has .sidebar-item--bell rule', () => { + const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + assert.ok(source.includes('.sidebar-item--bell'), 'style.css must have .sidebar-item--bell rule'); +}); + +test('CSS style.css has .sidebar-bell-dot rule', () => { + const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + assert.ok(source.includes('.sidebar-bell-dot'), 'style.css must have .sidebar-bell-dot rule'); +}); + +test('showPreview checks showHoverPreview setting before showing popover', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function showPreview('); + assert.ok(fnStart !== -1, 'showPreview must exist'); + const fnEnd = source.indexOf('\n}', fnStart); + const fnBody = source.substring(fnStart, fnEnd + 2); + assert.ok(fnBody.includes('showHoverPreview'), 'showPreview must check showHoverPreview setting before showing popover'); +}); + +test('bindStaticEventListeners binds change events for new display toggle checkboxes', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function bindStaticEventListeners('); + assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist'); + const fnEnd = source.indexOf('\nfunction ', fnStart + 1); + const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 10000); + assert.ok(fnBody.includes('setting-show-device-badges'), 'must bind setting-show-device-badges'); + assert.ok(fnBody.includes('setting-show-activity-glow'), 'must bind setting-show-activity-glow'); + assert.ok(fnBody.includes('setting-show-hover-preview'), 'must bind setting-show-hover-preview'); + assert.ok(fnBody.includes('setting-show-activity-dot'), 'must bind setting-show-activity-dot'); +}); +