diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 9eb9669..e1e5535 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -337,16 +337,7 @@ async function restoreState() { */ function setConnectionStatus(level) { const el = $('connection-status'); - if (!el) return; - const map = { - ok: { text: _icons.statusOk, cls: 'connection-status--ok' }, - warn: { text: _icons.statusWarn + ' slow', cls: 'connection-status--warn' }, - err: { text: _icons.statusErr + ' offline', cls: 'connection-status--err' }, - }; - const s = map[level]; - if (!s) return; - el.innerHTML = s.text; - el.className = s.cls; + if (el) el.level = level; } // ─── Session polling ───────────────────────────────────────────────────────────────────────────── @@ -1704,12 +1695,70 @@ function renderGrid(sessions) { ordered = mobile ? sortByPriority(visible) : visible; } - var html; - if (_gridViewMode === 'grouped') { - html = renderGroupedGrid(ordered, mobile); - } else { - html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join(''); + // Diff tiles: reuse existing, add new, remove stale + var existingTiles = new Map(); + if (grid) { + grid.querySelectorAll('session-tile').forEach(function(t) { + existingTiles.set(t.getAttribute('data-session-key') || (t.session && t.session.sessionKey), t); + }); } + var newKeys = new Set(); + var fragment = document.createDocumentFragment(); + var ds = getDisplaySettings(); + + function _setupTile(s) { + var key = s.sessionKey || s.name; + newKeys.add(key); + var tile = existingTiles.get(key); + if (!tile) { + tile = document.createElement('session-tile'); + tile.addEventListener('tile-click', function(e) { + openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined }); + }); + tile.addEventListener('tile-options', function(e) { + openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect); + }); + } + tile.session = s; + tile.mobile = mobile; + tile.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled); + tile.showDeviceBadges = ds.showDeviceBadges !== false; + tile.activityIndicator = ds.activityIndicator || 'both'; + tile.viewMode = ds.viewMode || 'auto'; + return tile; + } + + if (_gridViewMode === 'grouped') { + // Group by deviceName + var groups = {}; + var groupOrder = []; + for (var gi = 0; gi < ordered.length; gi++) { + var dn = ordered[gi].deviceName || 'Unknown'; + if (!groups[dn]) { groups[dn] = []; groupOrder.push(dn); } + groups[dn].push(ordered[gi]); + } + for (var g = 0; g < groupOrder.length; g++) { + var gname = groupOrder[g]; + var groupSessions = groups[gname]; + if (groupSessions.length === 0) continue; + var header = document.createElement('h3'); + header.className = 'device-group-header'; + header.textContent = gname; + fragment.appendChild(header); + for (var j = 0; j < groupSessions.length; j++) { + fragment.appendChild(_setupTile(groupSessions[j])); + } + } + } else { + for (var i = 0; i < ordered.length; i++) { + fragment.appendChild(_setupTile(ordered[i])); + } + } + + // Remove stale tiles + existingTiles.forEach(function(tile, key) { + if (!newKeys.has(key)) tile.remove(); + }); // Append status tiles for auth_failed and unreachable sessions. status:empty sentinels are // intentionally ignored in all view modes — a remote with zero tmux sessions produces no @@ -1719,29 +1768,14 @@ function renderGrid(sessions) { if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth'); else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); }); - if (grid) grid.innerHTML = html + statusTilesHtml; + if (grid) { + grid.innerHTML = statusTilesHtml; + grid.prepend(fragment); + } // Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped) if (filterBar) filterBar.innerHTML = ''; - // Bind interaction handlers on each tile - document.querySelectorAll('.session-tile').forEach(function(tile) { - on(tile, 'click', (e) => { - // Don't navigate when clicking the options button inside the tile - if (e.target.closest && e.target.closest('.tile-options-btn')) return; - // Don't open error/status tiles (unreachable, auth_failed) - if (tile.classList.contains('source-tile--error') || !tile.dataset.session) return; - openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' }); - }); - on(tile, 'keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - // Don't open error/status tiles (unreachable, auth_failed) - if (tile.classList.contains('source-tile--error') || !tile.dataset.session) return; - openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' }); - } - }); - }); - if (_viewMode === 'fullscreen') { updatePillBell(); } @@ -1833,15 +1867,24 @@ function hidePreview() { * bottom action sheet instead. * @param {HTMLElement} triggerEl - The .tile-options-btn element that was clicked */ -function openFlyoutMenu(triggerEl) { +function openFlyoutMenu(nameOrEl, sessionKey, remoteId, rectArg) { closeFlyoutMenu(); - // Read session info from the tile - var tile = triggerEl.closest('[data-session-key]'); - if (!tile) return; - _flyoutSessionKey = tile.dataset.sessionKey || ''; - _flyoutSessionName = tile.dataset.session || ''; - _flyoutRemoteId = tile.dataset.remoteId || ''; + // Support both legacy (trigger element) and new (explicit params) call styles + var rect; + if (nameOrEl instanceof HTMLElement) { + var tile = nameOrEl.closest('[data-session-key]'); + if (!tile) return; + _flyoutSessionKey = tile.dataset.sessionKey || ''; + _flyoutSessionName = tile.dataset.session || ''; + _flyoutRemoteId = tile.dataset.remoteId || ''; + rect = nameOrEl.getBoundingClientRect(); + } else { + _flyoutSessionName = nameOrEl || ''; + _flyoutSessionKey = sessionKey || ''; + _flyoutRemoteId = remoteId || ''; + rect = rectArg; + } if (isMobile()) { _openFlyoutSheet(); @@ -1860,7 +1903,6 @@ function openFlyoutMenu(triggerEl) { _flyoutMenuEl = menu; // Position relative to trigger - var rect = triggerEl.getBoundingClientRect(); var menuWidth = menu.offsetWidth; var menuHeight = menu.offsetHeight; @@ -2860,10 +2902,7 @@ function _resetHeartbeatTimer() { */ function showToast(msg) { const el = $('toast'); - if (!el) return; - el.textContent = msg; - el.classList.remove('hidden'); - setTimeout(() => el.classList.add('hidden'), 3000); + if (el && el.show) el.show(msg); } // ─── Session pill bell ─────────────────────────────────────────────────────── @@ -2873,13 +2912,13 @@ function showToast(msg) { * Shows #session-pill-bell if any session other than _viewingSession has unseen bells. */ function updatePillBell() { - const el = $('session-pill-bell'); - if (!el) return; + const pill = $('session-pill'); + if (!pill) return; const viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession; const hasBell = _currentSessions.some( (s) => (s.sessionKey || s.name) !== viewingKey && s.bell && s.bell.unseen_count > 0, ); - if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden'); + pill.hasBell = hasBell; } // --------------------------------------------------------------------------- @@ -3049,9 +3088,8 @@ async function openSession(name, opts = {}) { if (isMobile()) { const pill = $('session-pill'); if (pill) { - pill.classList.remove('hidden'); // pill starts with hidden class - const pillLabel = $('session-pill-label'); - if (pillLabel) pillLabel.textContent = name; + pill.visible = true; + pill.label = name; } updatePillBell(); updateSessionPill(_currentSessions); @@ -3128,7 +3166,7 @@ function closeSession() { } const pill = $('session-pill'); - if (pill) pill.classList.add('hidden'); + if (pill) pill.visible = false; // Restore FAB when returning to overview const fab = $('new-session-fab'); @@ -3767,20 +3805,23 @@ function renderSheetList() { * @param {object[]} sessions - full sessions array */ function updateSessionPill(sessions) { - if (_viewMode !== 'fullscreen') return; - var pillBell = $('session-pill-bell'); - if (!pillBell) return; + const pill = $('session-pill'); + if (!pill) return; + if (_viewMode !== 'fullscreen') { + pill.visible = false; + return; + } + var pillLabel = _viewingSession || ''; + pill.label = pillLabel; + pill.visible = true; + var viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession; var othersWithBell = sessions.filter(function(s) { return (s.sessionKey || s.name) !== viewingKey && s.bell && s.bell.unseen_count > 0 && (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at); }); - if (othersWithBell.length > 0) { - pillBell.classList.remove('hidden'); - } else { - pillBell.classList.add('hidden'); - } + pill.hasBell = othersWithBell.length > 0; } // ─── Header + button with inline name input ──────────────────────────────────── @@ -4197,7 +4238,8 @@ function bindStaticEventListeners() { on($('sidebar-collapse-btn'), 'click', toggleSidebar); bindSidebarClickAway(); document.addEventListener('keydown', handleGlobalKeydown); - on($('session-pill'), 'click', openBottomSheet); + var sessionPillEl = $('session-pill'); + if (sessionPillEl) sessionPillEl.addEventListener('pill-click', openBottomSheet); on($('sheet-backdrop'), 'click', closeBottomSheet); // Settings dialog bindings diff --git a/muxplex/frontend/components/connection-status.js b/muxplex/frontend/components/connection-status.js new file mode 100644 index 0000000..6072903 --- /dev/null +++ b/muxplex/frontend/components/connection-status.js @@ -0,0 +1,51 @@ +import { LitElement, html, css, svg } from '/vendor/lit/lit.min.js'; + +/** + * - Server connectivity indicator. + * + * Properties: + * level: 'ok' | 'warn' | 'err' + */ +export class ConnectionStatus extends LitElement { + static properties = { + level: { type: String, reflect: true }, + }; + + static styles = css` + :host { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; } + .indicator { display: inline-flex; align-items: center; gap: 4px; } + :host([level="ok"]) .indicator { color: var(--status-ok, #3fb950); } + :host([level="warn"]) .indicator { color: var(--status-warn, #d29922); } + :host([level="err"]) .indicator { color: var(--status-err, #f85149); } + `; + + constructor() { + super(); + this.level = 'ok'; + } + + render() { + const content = { + ok: html` + + + + `, + warn: html` + + + slow + + `, + err: html` + + + offline + + `, + }; + return content[this.level] || content.ok; + } +} + +customElements.define('connection-status', ConnectionStatus); \ No newline at end of file diff --git a/muxplex/frontend/components/icons.js b/muxplex/frontend/components/icons.js new file mode 100644 index 0000000..d367244 --- /dev/null +++ b/muxplex/frontend/components/icons.js @@ -0,0 +1,16 @@ +// Inline SVG icon strings shared across components. +// Matches the _icons object in app.js. + +export const icons = { + statusOk: '', + statusWarn: '', + statusErr: '', + kebab: '', + chevronLeft: '', + chevronRight: '', + chevronUp: '', + chevronDown: '', + check: '', + bell: '', + close: '', +}; \ No newline at end of file diff --git a/muxplex/frontend/components/session-pill.js b/muxplex/frontend/components/session-pill.js new file mode 100644 index 0000000..4b32cef --- /dev/null +++ b/muxplex/frontend/components/session-pill.js @@ -0,0 +1,77 @@ +import { LitElement, html, css } from '/vendor/lit/lit.min.js'; + +/** + * - Floating session-switcher button with bell badge. + * + * Properties: + * label: String - session name to display + * hasBell: Boolean - whether bell badge is visible + * visible: Boolean - whether the pill itself is shown + * + * Events: + * pill-click - Fired when user taps/clicks the pill + */ +export class SessionPill extends LitElement { + static properties = { + label: { type: String }, + hasBell: { type: Boolean, attribute: 'has-bell' }, + visible: { type: Boolean, reflect: true }, + }; + + static styles = css` + :host { display: none; } + :host([visible]) { display: block; } + + .pill { + position: fixed; + bottom: 18px; + right: 18px; + z-index: 200; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 999px; + background: var(--pill-bg, #21262d); + color: var(--pill-fg, #c9d1d9); + font-size: 13px; + opacity: 0.75; + cursor: pointer; + transition: opacity 0.2s; + } + .pill:hover { opacity: 1; } + + .bell { + display: none; + color: var(--bell-color, #d29922); + } + .bell.active { display: inline-flex; } + `; + + constructor() { + super(); + this.label = ''; + this.hasBell = false; + this.visible = false; + } + + _onClick() { + this.dispatchEvent(new CustomEvent('pill-click', { bubbles: true, composed: true })); + } + + render() { + return html` + + `; + } +} + +customElements.define('session-pill', SessionPill); \ No newline at end of file diff --git a/muxplex/frontend/components/session-tile.js b/muxplex/frontend/components/session-tile.js new file mode 100644 index 0000000..5f7c873 --- /dev/null +++ b/muxplex/frontend/components/session-tile.js @@ -0,0 +1,146 @@ +import { LitElement, html, css, nothing } from '/vendor/lit/lit.min.js'; +import { unsafeHTML } from '/vendor/lit/lit.min.js'; +import { escapeHtml, formatTimestamp, sessionPriority, ansiToHtml } from './utils.js'; +import { icons } from './icons.js'; + +/** + * - Dashboard tile representing a tmux session. + * + * Properties: + * session: Object - session data { name, snapshot, last_activity_at, bell, ... } + * mobile: Boolean - mobile layout mode + * multiDevice: Boolean - multi-device support enabled + * showDeviceBadges: Boolean - whether to show device name badges + * activityIndicator: String - 'none' | 'glow' | 'dot' | 'both' + * viewMode: String - 'auto' | 'fit' (controls snapshot line count) + * + * Events: + * tile-click { name, remoteId } - Session selected + * tile-options { name, remoteId, sessionKey, rect } - Kebab menu opened + */ +export class SessionTile extends LitElement { + static properties = { + session: { type: Object }, + mobile: { type: Boolean }, + multiDevice: { type: Boolean, attribute: 'multi-device' }, + showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' }, + activityIndicator: { type: String, attribute: 'activity-indicator' }, + viewMode: { type: String, attribute: 'view-mode' }, + }; + + // Render into light DOM so existing CSS applies without migration. + createRenderRoot() { return this; } + + constructor() { + super(); + this.session = {}; + this.mobile = false; + this.multiDevice = false; + this.showDeviceBadges = true; + this.activityIndicator = 'both'; + this.viewMode = 'auto'; + } + + get _priority() { + return sessionPriority(this.session); + } + + get _isBell() { + return this._priority === 'bell'; + } + + get _name() { + return this.session.name || ''; + } + + get _sessionKey() { + return this.session.sessionKey || this._name; + } + + get _remoteId() { + return this.session.remoteId ?? null; + } + + get _classes() { + let cls = 'session-tile'; + const ind = this.activityIndicator; + if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' session-tile--bell'; + if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' session-tile--edge-bell'; + if (this.mobile) cls += ` session-tile--tier-${this._priority}`; + return cls; + } + + get _snapshotHtml() { + const snapshot = this.session.snapshot || ''; + const lineCount = this.viewMode === 'fit' ? -80 : -20; + // Trim trailing blank lines first (see buildTileHTML comment in app.js) + const allLines = snapshot.split('\n'); + while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') { + allLines.pop(); + } + const lastLines = allLines.slice(lineCount).join('\n'); + return ansiToHtml(lastLines); + } + + _onTileClick(e) { + // Don't trigger tile-click when clicking the options button + if (e.target.closest('.tile-options-btn')) return; + this.dispatchEvent(new CustomEvent('tile-click', { + bubbles: true, composed: true, + detail: { name: this._name, remoteId: this._remoteId }, + })); + } + + _onOptionsClick(e) { + e.stopPropagation(); + const btn = e.currentTarget; + this.dispatchEvent(new CustomEvent('tile-options', { + bubbles: true, composed: true, + detail: { + name: this._name, + remoteId: this._remoteId, + sessionKey: this._sessionKey, + rect: btn.getBoundingClientRect(), + }, + })); + } + + render() { + const name = this._name; + const timeStr = formatTimestamp(this.session.last_activity_at || null); + + const showBadge = this.multiDevice && this.session.deviceName && this.showDeviceBadges; + + return html` +
+
+ ${name} + ${showBadge + ? html`${this.session.deviceName}` + : nothing} + ${timeStr} + +
+
+
${unsafeHTML(this._snapshotHtml)}
+
+
+ `; + } +} + +customElements.define('session-tile', SessionTile); \ No newline at end of file diff --git a/muxplex/frontend/components/toast-notification.js b/muxplex/frontend/components/toast-notification.js new file mode 100644 index 0000000..e3934c1 --- /dev/null +++ b/muxplex/frontend/components/toast-notification.js @@ -0,0 +1,55 @@ +import { LitElement, html, css } from '/vendor/lit/lit.min.js'; + +/** + * - Brief popup message that auto-hides. + * + * Usage: + * const toast = document.querySelector('toast-notification'); + * toast.show('Session created'); + */ +export class ToastNotification extends LitElement { + static properties = { + _message: { state: true }, + _visible: { state: true }, + }; + + static styles = css` + :host { display: block; } + .toast { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: var(--toast-bg, #e6edf3); + color: var(--toast-fg, #0d1117); + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + z-index: 9999; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s; + } + .toast.visible { opacity: 1; } + `; + + constructor() { + super(); + this._message = ''; + this._visible = false; + this._timer = null; + } + + show(msg, duration = 3000) { + this._message = msg; + this._visible = true; + clearTimeout(this._timer); + this._timer = setTimeout(() => { this._visible = false; }, duration); + } + + render() { + return html`
${this._message}
`; + } +} + +customElements.define('toast-notification', ToastNotification); \ No newline at end of file diff --git a/muxplex/frontend/components/utils.js b/muxplex/frontend/components/utils.js new file mode 100644 index 0000000..0a50376 --- /dev/null +++ b/muxplex/frontend/components/utils.js @@ -0,0 +1,204 @@ +// Pure utility functions extracted from app.js +// These have zero DOM coupling and zero global state. + +/** + * Escape HTML special characters to safe entities. + * @param {string} str + * @returns {string} + */ +export function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** + * Format a Unix timestamp (seconds) into a relative time string. + * @param {number|null|undefined} ts + * @returns {string} + */ +export function formatTimestamp(ts) { + if (ts == null) return ''; + const diff = Math.floor(Date.now() / 1000 - ts); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + return `${Math.floor(diff / 3600)}h ago`; +} + +/** + * Return the priority label for a session object. + * @param {object} session + * @returns {'bell'|'idle'} + */ +export function sessionPriority(session) { + const bell = session && session.bell; + if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) { + return 'bell'; + } + return 'idle'; +} + +/** Priority rank map. Lower = higher priority. */ +export const PRIORITY_RANK = { bell: 0, active: 1, idle: 2 }; + +/** + * Sort sessions by priority (ascending rank). Returns new array. + * @param {object[]} sessions + * @returns {object[]} + */ +export function sortByPriority(sessions) { + return sessions.slice().sort((a, b) => { + const rankA = PRIORITY_RANK[sessionPriority(a)] ?? 2; + const rankB = PRIORITY_RANK[sessionPriority(b)] ?? 2; + return rankA - rankB; + }); +} + +/** + * Filter sessions by search query (case-insensitive substring on name). + * @param {object[]} sessions + * @param {string|null} query + * @returns {object[]} + */ +export function filterByQuery(sessions, query) { + if (!query) return sessions; + const q = query.toLowerCase(); + return sessions.filter((s) => (s.name || '').toLowerCase().includes(q)); +} + +/** + * Detect sessions that transitioned to new/increased bell state. + * @param {object[]} prev + * @param {object[]} next + * @returns {string[]} + */ +export function detectBellTransitions(prev, next) { + const prevMap = new Map( + (prev || []).map((s) => [s.sessionKey || s.name, (s.bell && s.bell.unseen_count) || 0]), + ); + return (next || []) + .filter((s) => { + const unseen = s.bell && s.bell.unseen_count; + if (!unseen || unseen <= 0) return false; + const key = s.sessionKey || s.name; + const prevCount = prevMap.has(key) ? prevMap.get(key) : 0; + return unseen > prevCount; + }) + .map((s) => s.name); +} + +/** + * Generate a pseudo-random device ID string. + * @returns {string} + */ +export function generateDeviceId() { + return 'd-' + Math.random().toString(36).padEnd(10, '0').slice(2, 10); +} + +/** + * Build a heartbeat payload object. + * @param {string} device_id + * @param {string|null} viewing_session + * @param {string} view_mode + * @param {number} last_interaction_at + * @returns {object} + */ +export function buildHeartbeatPayload(device_id, viewing_session, view_mode, last_interaction_at) { + const label = + typeof navigator !== 'undefined' && navigator.userAgent + ? navigator.userAgent.slice(0, 50) + : 'unknown'; + return { device_id, label, viewing_session, view_mode, last_interaction_at }; +} + +// --------------------------------------------------------------------------- +// ANSI escape -> HTML span converter (SGR codes only) +// --------------------------------------------------------------------------- + +const ANSI_COLORS = [ + '#2e3436','#cc0000','#4e9a06','#c4a000','#3465a4','#75507b','#06989a','#d3d7cf', + '#555753','#ef2929','#8ae234','#fce94f','#729fcf','#ad7fa8','#34e2e2','#eeeeec' +]; + +function ansi256Color(n) { + if (n < 16) return ANSI_COLORS[n]; + if (n >= 232) { const g = 8 + (n - 232) * 10; return `rgb(${g},${g},${g})`; } + n -= 16; + const r = Math.floor(n / 36) * 51; + const g2 = Math.floor((n % 36) / 6) * 51; + const b = (n % 6) * 51; + return `rgb(${r},${g2},${b})`; +} + +function ansiParamsToStyle(params) { + const styles = []; + let k = 0; + while (k < params.length) { + const p = parseInt(params[k], 10) || 0; + if (p === 0) return 'reset'; + if (p === 1) styles.push('font-weight:bold'); + else if (p === 2) styles.push('opacity:0.7'); + else if (p === 3) styles.push('font-style:italic'); + else if (p === 4) styles.push('text-decoration:underline'); + else if (p === 7) styles.push('filter:invert(1)'); + else if (p === 9) styles.push('text-decoration:line-through'); + else if (p >= 30 && p <= 37) styles.push('color:' + ANSI_COLORS[p - 30]); + else if (p === 38 && params[k + 1] === '5') { + styles.push('color:' + ansi256Color(parseInt(params[k + 2], 10) || 0)); + k += 2; + } + else if (p === 39) styles.push('color:inherit'); + else if (p >= 40 && p <= 47) styles.push('background:' + ANSI_COLORS[p - 40]); + else if (p === 48 && params[k + 1] === '5') { + styles.push('background:' + ansi256Color(parseInt(params[k + 2], 10) || 0)); + k += 2; + } + else if (p === 49) styles.push('background:inherit'); + else if (p >= 90 && p <= 97) styles.push('color:' + ANSI_COLORS[p - 90 + 8]); + else if (p >= 100 && p <= 107) styles.push('background:' + ANSI_COLORS[p - 100 + 8]); + k++; + } + return styles.length ? styles.join(';') : ''; +} + +/** + * Convert ANSI SGR escape sequences to HTML spans with inline styles. + * @param {string} raw + * @returns {string} + */ +export function ansiToHtml(raw) { + if (!raw) return ''; + let out = ''; + let spans = 0; + let i = 0; + const len = raw.length; + + while (i < len) { + if (raw[i] === '\x1b' && raw[i + 1] === '[') { + let j = i + 2; + while (j < len && raw[j] !== 'm' && j - i < 20) j++; + if (j < len && raw[j] === 'm') { + const params = raw.substring(i + 2, j).split(';'); + const style = ansiParamsToStyle(params); + if (style === 'reset') { + while (spans > 0) { out += ''; spans--; } + } else if (style) { + out += ''; + spans++; + } + i = j + 1; + continue; + } + } + const ch = raw[i]; + if (ch === '<') out += '<'; + else if (ch === '>') out += '>'; + else if (ch === '&') out += '&'; + else if (ch === '"') out += '"'; + else out += ch; + i++; + } + while (spans > 0) { out += ''; spans--; } + return out; +} \ No newline at end of file diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index d88e5a0..2db1d4b 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -30,7 +30,7 @@ - +
@@ -121,16 +121,13 @@ - + - + @@ -294,5 +291,11 @@ + diff --git a/muxplex/frontend/vendor/lit/lit.min.js b/muxplex/frontend/vendor/lit/lit.min.js new file mode 100644 index 0000000..d1fc527 --- /dev/null +++ b/muxplex/frontend/vendor/lit/lit.min.js @@ -0,0 +1,55 @@ +var I=globalThis,j=I.ShadowRoot&&(I.ShadyCSS===void 0||I.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,et=Symbol(),ut=new WeakMap,H=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==et)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o,e=this.t;if(j&&t===void 0){let s=e!==void 0&&e.length===1;s&&(t=ut.get(e)),t===void 0&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),s&&ut.set(e,t))}return t}toString(){return this.cssText}},$t=i=>new H(typeof i=="string"?i:i+"",void 0,et),_t=(i,...t)=>{let e=i.length===1?i[0]:t.reduce((s,r,o)=>s+(n=>{if(n._$cssResult$===!0)return n.cssText;if(typeof n=="number")return n;throw Error("Value passed to 'css' function must be a 'css' function result: "+n+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(r)+i[o+1],i[0]);return new H(e,i,et)},ft=(i,t)=>{if(j)i.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of t){let s=document.createElement("style"),r=I.litNonce;r!==void 0&&s.setAttribute("nonce",r),s.textContent=e.cssText,i.appendChild(s)}},st=j?i=>i:i=>i instanceof CSSStyleSheet?(t=>{let e="";for(let s of t.cssRules)e+=s.cssText;return $t(e)})(i):i;var{is:zt,defineProperty:Wt,getOwnPropertyDescriptor:Kt,getOwnPropertyNames:qt,getOwnPropertySymbols:Gt,getPrototypeOf:Yt}=Object,V=globalThis,mt=V.trustedTypes,Zt=mt?mt.emptyScript:"",Xt=V.reactiveElementPolyfillSupport,N=(i,t)=>i,it={toAttribute(i,t){switch(t){case Boolean:i=i?Zt:null;break;case Object:case Array:i=i==null?i:JSON.stringify(i)}return i},fromAttribute(i,t){let e=i;switch(t){case Boolean:e=i!==null;break;case Number:e=i===null?null:Number(i);break;case Object:case Array:try{e=JSON.parse(i)}catch{e=null}}return e}},vt=(i,t)=>!zt(i,t),At={attribute:!0,type:String,converter:it,reflect:!1,useDefault:!1,hasChanged:vt};Symbol.metadata??=Symbol("metadata"),V.litPropertyMetadata??=new WeakMap;var v=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=At){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){let s=Symbol(),r=this.getPropertyDescriptor(t,s,e);r!==void 0&&Wt(this.prototype,t,r)}}static getPropertyDescriptor(t,e,s){let{get:r,set:o}=Kt(this.prototype,t)??{get(){return this[e]},set(n){this[e]=n}};return{get:r,set(n){let l=r?.call(this);o?.call(this,n),this.requestUpdate(t,l,s)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??At}static _$Ei(){if(this.hasOwnProperty(N("elementProperties")))return;let t=Yt(this);t.finalize(),t.l!==void 0&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(N("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(N("properties"))){let e=this.properties,s=[...qt(e),...Gt(e)];for(let r of s)this.createProperty(r,e[r])}let t=this[Symbol.metadata];if(t!==null){let e=litPropertyMetadata.get(t);if(e!==void 0)for(let[s,r]of e)this.elementProperties.set(s,r)}this._$Eh=new Map;for(let[e,s]of this.elementProperties){let r=this._$Eu(e,s);r!==void 0&&this._$Eh.set(r,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){let e=[];if(Array.isArray(t)){let s=new Set(t.flat(1/0).reverse());for(let r of s)e.unshift(st(r))}else t!==void 0&&e.push(st(t));return e}static _$Eu(t,e){let s=e.attribute;return s===!1?void 0:typeof s=="string"?s:typeof t=="string"?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),this.renderRoot!==void 0&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){let t=new Map,e=this.constructor.elementProperties;for(let s of e.keys())this.hasOwnProperty(s)&&(t.set(s,this[s]),delete this[s]);t.size>0&&(this._$Ep=t)}createRenderRoot(){let t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return ft(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,s){this._$AK(t,s)}_$ET(t,e){let s=this.constructor.elementProperties.get(t),r=this.constructor._$Eu(t,s);if(r!==void 0&&s.reflect===!0){let o=(s.converter?.toAttribute!==void 0?s.converter:it).toAttribute(e,s.type);this._$Em=t,o==null?this.removeAttribute(r):this.setAttribute(r,o),this._$Em=null}}_$AK(t,e){let s=this.constructor,r=s._$Eh.get(t);if(r!==void 0&&this._$Em!==r){let o=s.getPropertyOptions(r),n=typeof o.converter=="function"?{fromAttribute:o.converter}:o.converter?.fromAttribute!==void 0?o.converter:it;this._$Em=r;let l=n.fromAttribute(e,o.type);this[r]=l??this._$Ej?.get(r)??l,this._$Em=null}}requestUpdate(t,e,s,r=!1,o){if(t!==void 0){let n=this.constructor;if(r===!1&&(o=this[t]),s??=n.getPropertyOptions(t),!((s.hasChanged??vt)(o,e)||s.useDefault&&s.reflect&&o===this._$Ej?.get(t)&&!this.hasAttribute(n._$Eu(t,s))))return;this.C(t,e,s)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(t,e,{useDefault:s,reflect:r,wrapped:o},n){s&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,n??e??this[t]),o!==!0||n!==void 0)||(this._$AL.has(t)||(this.hasUpdated||s||(e=void 0),this._$AL.set(t,e)),r===!0&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let t=this.scheduleUpdate();return t!=null&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[r,o]of this._$Ep)this[r]=o;this._$Ep=void 0}let s=this.constructor.elementProperties;if(s.size>0)for(let[r,o]of s){let{wrapped:n}=o,l=this[r];n!==!0||this._$AL.has(r)||l===void 0||this.C(r,void 0,o,l)}}let t=!1,e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(s=>s.hostUpdate?.()),this.update(e)):this._$EM()}catch(s){throw t=!1,this._$EM(),s}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(t){}firstUpdated(t){}};v.elementStyles=[],v.shadowRootOptions={mode:"open"},v[N("elementProperties")]=new Map,v[N("finalized")]=new Map,Xt?.({ReactiveElement:v}),(V.reactiveElementVersions??=[]).push("2.1.2");var ot=globalThis,gt=i=>i,z=ot.trustedTypes,yt=z?z.createPolicy("lit-html",{createHTML:i=>i}):void 0,nt="$lit$",g=`lit$${Math.random().toFixed(9).slice(2)}$`,ht="?"+g,Ft=`<${ht}>`,x=document,O=()=>x.createComment(""),D=i=>i===null||typeof i!="object"&&typeof i!="function",at=Array.isArray,wt=i=>at(i)||typeof i?.[Symbol.iterator]=="function",rt=`[ +\f\r]`,R=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Ct=/-->/g,Et=/>/g,S=RegExp(`>|${rt}(?:([^\\s"'>=/]+)(${rt}*=${rt}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`,"g"),St=/'/g,bt=/"/g,Pt=/^(?:script|style|textarea|title)$/i,lt=i=>(t,...e)=>({_$litType$:i,strings:t,values:e}),Jt=lt(1),Qt=lt(2),fe=lt(3),f=Symbol.for("lit-noChange"),$=Symbol.for("lit-nothing"),xt=new WeakMap,b=x.createTreeWalker(x,129);function Tt(i,t){if(!at(i)||!i.hasOwnProperty("raw"))throw Error("invalid template strings array");return yt!==void 0?yt.createHTML(t):t}var Mt=(i,t)=>{let e=i.length-1,s=[],r,o=t===2?"":t===3?"":"",n=R;for(let l=0;l"?(n=r??R,a=-1):p[1]===void 0?a=-2:(a=n.lastIndex-p[2].length,d=p[1],n=p[3]===void 0?S:p[3]==='"'?bt:St):n===bt||n===St?n=S:n===Ct||n===Et?n=R:(n=S,r=void 0);let c=n===S&&i[l+1].startsWith("/>")?" ":"";o+=n===R?h+Ft:a>=0?(s.push(d),h.slice(0,a)+nt+h.slice(a)+g+c):h+g+(a===-2?l:c)}return[Tt(i,o+(i[e]||"")+(t===2?"":t===3?"":"")),s]},L=class i{constructor({strings:t,_$litType$:e},s){let r;this.parts=[];let o=0,n=0,l=t.length-1,h=this.parts,[d,p]=Mt(t,e);if(this.el=i.createElement(d,s),b.currentNode=this.el.content,e===2||e===3){let a=this.el.content.firstChild;a.replaceWith(...a.childNodes)}for(;(r=b.nextNode())!==null&&h.length0){r.textContent=z?z.emptyScript:"";for(let c=0;c2||s[0]!==""||s[1]!==""?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=$}_$AI(t,e=this,s,r){let o=this.strings,n=!1;if(o===void 0)t=w(this,t,e,0),n=!D(t)||t!==this._$AH&&t!==f,n&&(this._$AH=t);else{let l=t,h,d;for(t=o[0],h=0;h{let s=e?.renderBefore??t,r=s._$litPart$;if(r===void 0){let o=e?.renderBefore??null;s._$litPart$=r=new M(t.insertBefore(O(),o),o,void 0,e??{})}return r._$AI(i),r};var ct=globalThis,T=class extends v{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=Ht(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return f}};T._$litElement$=!0,T.finalized=!0,ct.litElementHydrateSupport?.({LitElement:T});var ee=ct.litElementPolyfillSupport;ee?.({LitElement:T});(ct.litElementVersions??=[]).push("4.2.2");var C={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},y=i=>(...t)=>({_$litDirective$:i,values:t}),m=class{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,s){this._$Ct=t,this._$AM=e,this._$Ci=s}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}};var{I:se}=Ut,Nt=i=>i,Ot=i=>i===null||typeof i!="object"&&typeof i!="function";var Dt=i=>i.strings===void 0,Rt=()=>document.createComment(""),U=(i,t,e)=>{let s=i._$AA.parentNode,r=t===void 0?i._$AB:t._$AA;if(e===void 0){let o=s.insertBefore(Rt(),r),n=s.insertBefore(Rt(),r);e=new se(o,n,i,i.options)}else{let o=e._$AB.nextSibling,n=e._$AM,l=n!==i;if(l){let h;e._$AQ?.(i),e._$AM=i,e._$AP!==void 0&&(h=i._$AU)!==n._$AU&&e._$AP(h)}if(o!==r||l){let h=e._$AA;for(;h!==o;){let d=Nt(h).nextSibling;Nt(s).insertBefore(h,r),h=d}}}return e},E=(i,t,e=i)=>(i._$AI(t,e),i),ie={},Lt=(i,t=ie)=>i._$AH=t,kt=i=>i._$AH,Z=i=>{i._$AR(),i._$AA.remove()};var Bt=(i,t,e)=>{let s=new Map;for(let r=t;r<=e;r++)s.set(i[r],r);return s},re=y(class extends m{constructor(i){if(super(i),i.type!==C.CHILD)throw Error("repeat() can only be used in text expressions")}dt(i,t,e){let s;e===void 0?e=t:t!==void 0&&(s=t);let r=[],o=[],n=0;for(let l of i)r[n]=s?s(l,n):n,o[n]=e(l,n),n++;return{values:o,keys:r}}render(i,t,e){return this.dt(i,t,e).values}update(i,[t,e,s]){let r=kt(i),{values:o,keys:n}=this.dt(t,e,s);if(!Array.isArray(r))return this.ut=n,o;let l=this.ut??=[],h=[],d,p,a=0,u=r.length-1,c=0,_=o.length-1;for(;a<=u&&c<=_;)if(r[a]===null)a++;else if(r[u]===null)u--;else if(l[a]===n[c])h[c]=E(r[a],o[c]),a++,c++;else if(l[u]===n[_])h[_]=E(r[u],o[_]),u--,_--;else if(l[a]===n[_])h[_]=E(r[a],o[_]),U(i,h[_+1],r[a]),a++,_--;else if(l[u]===n[c])h[c]=E(r[u],o[c]),U(i,r[a],r[u]),u--,c++;else if(d===void 0&&(d=Bt(n,c,_),p=Bt(l,a,u)),d.has(l[a]))if(d.has(l[u])){let A=p.get(n[c]),tt=A!==void 0?r[A]:null;if(tt===null){let pt=U(i,r[a]);E(pt,o[c]),h[c]=pt}else h[c]=E(tt,o[c]),U(i,r[a],tt),r[A]=null;c++}else Z(r[u]),u--;else Z(r[a]),a++;for(;c<=_;){let A=U(i,h[_+1]);E(A,o[c]),h[c++]=A}for(;a<=u;){let A=r[a++];A!==null&&Z(A)}return this.ut=n,Lt(i,h),f}});var oe=y(class extends m{constructor(i){if(super(i),i.type!==C.ATTRIBUTE||i.name!=="class"||i.strings?.length>2)throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.")}render(i){return" "+Object.keys(i).filter(t=>i[t]).join(" ")+" "}update(i,[t]){if(this.st===void 0){this.st=new Set,i.strings!==void 0&&(this.nt=new Set(i.strings.join(" ").split(/\s/).filter(s=>s!=="")));for(let s in t)t[s]&&!this.nt?.has(s)&&this.st.add(s);return this.render(t)}let e=i.element.classList;for(let s of this.st)s in t||(e.remove(s),this.st.delete(s));for(let s in t){let r=!!t[s];r===this.st.has(s)||this.nt?.has(s)||(r?(e.add(s),this.st.add(s)):(e.remove(s),this.st.delete(s)))}return f}});var k=class extends m{constructor(t){if(super(t),this.it=$,t.type!==C.CHILD)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(t){if(t===$||t==null)return this._t=void 0,this.it=t;if(t===f)return t;if(typeof t!="string")throw Error(this.constructor.directiveName+"() called with a non-string value");if(t===this.it)return this._t;this.it=t;let e=[t];return e.raw=e,this._t={_$litType$:this.constructor.resultType,strings:e,values:[]}}};k.directiveName="unsafeHTML",k.resultType=1;var ne=y(k);var he=i=>i??$;var B=(i,t)=>{let e=i._$AN;if(e===void 0)return!1;for(let s of e)s._$AO?.(t,!1),B(s,t);return!0},X=i=>{let t,e;do{if((t=i._$AM)===void 0)break;e=t._$AN,e.delete(i),i=t}while(e?.size===0)},It=i=>{for(let t;t=i._$AM;i=t){let e=t._$AN;if(e===void 0)t._$AN=e=new Set;else if(e.has(i))break;e.add(i),ce(t)}};function ae(i){this._$AN!==void 0?(X(this),this._$AM=i,It(this)):this._$AM=i}function le(i,t=!1,e=0){let s=this._$AH,r=this._$AN;if(r!==void 0&&r.size!==0)if(t)if(Array.isArray(s))for(let o=e;o{i.type==C.CHILD&&(i._$AP??=le,i._$AQ??=ae)},F=class extends m{constructor(){super(...arguments),this._$AN=void 0}_$AT(t,e,s){super._$AT(t,e,s),It(this),this.isConnected=t._$AU}_$AO(t,e=!0){t!==this.isConnected&&(this.isConnected=t,t?this.reconnected?.():this.disconnected?.()),e&&(B(this,t),X(this))}setValue(t){if(Dt(this._$Ct))this._$Ct._$AI(t,this);else{let e=[...this._$Ct._$AH];e[this._$Ci]=t,this._$Ct._$AI(e,this,0)}}disconnected(){}reconnected(){}};var J=class{constructor(t){this.G=t}disconnect(){this.G=void 0}reconnect(t){this.G=t}deref(){return this.G}},Q=class{constructor(){this.Y=void 0,this.Z=void 0}get(){return this.Y}pause(){this.Y??=new Promise(t=>this.Z=t)}resume(){this.Z?.(),this.Y=this.Z=void 0}};var jt=i=>!Ot(i)&&typeof i.then=="function",Vt=1073741823,dt=class extends F{constructor(){super(...arguments),this._$Cwt=Vt,this._$Cbt=[],this._$CK=new J(this),this._$CX=new Q}render(...t){return t.find(e=>!jt(e))??f}update(t,e){let s=this._$Cbt,r=s.length;this._$Cbt=e;let o=this._$CK,n=this._$CX;this.isConnected||this.disconnected();for(let l=0;lthis._$Cwt);l++){let h=e[l];if(!jt(h))return this._$Cwt=l,h;l{for(;n.get();)await n.get();let p=o.deref();if(p!==void 0){let a=p._$Cbt.indexOf(h);a>-1&&a None: def test_html_bottom_sheet() -> None: - """id=bottom-sheet, sheet-list, sheet-backdrop, session-pill, session-pill-label, session-pill-bell.""" + """id=bottom-sheet, sheet-list, sheet-backdrop, session-pill.""" soup = _SOUP for id_ in ( "bottom-sheet", "sheet-list", "sheet-backdrop", "session-pill", - "session-pill-label", - "session-pill-bell", ): assert soup.find(id=id_), f"Missing element with id='{id_}'" def test_html_toast() -> None: - """id=toast, aria-live=polite.""" + """id=toast (toast-notification component).""" soup = _SOUP toast = soup.find(id="toast") assert toast, "Missing element with id='toast'" - assert toast.get("aria-live") == "polite", ( # type: ignore[union-attr] - f"toast missing aria-live=polite, got: {toast.get('aria-live')!r}" # type: ignore[union-attr] - ) def test_html_scripts() -> None: @@ -228,16 +223,12 @@ def test_html_element_classes() -> None: "reconnect-overlay", "needs position:absolute to overlay terminal", ), - ("session-pill", "session-pill", "needs position:fixed to float"), - ("toast", "toast", "needs position:fixed and animation"), ("back-btn", "back-btn", "needs border and hover styles"), ( "expanded-session-name", "expanded-session-name", "needs text-overflow:ellipsis", ), - ("session-pill-label", "session-pill__label", "needs max-width truncation"), - ("session-pill-bell", "session-pill__bell", "needs amber var(--bell) color"), ( "session-sidebar", "session-sidebar", diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index abf0109..e34adf4 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -4005,24 +4005,20 @@ def test_render_grid_closes_stale_flyout() -> None: def test_tile_click_handler_guards_options_btn() -> None: - """Tile click handler early return must guard .tile-options-btn (not .tile-delete). + """Tile click events are handled by session-tile component via tile-click/tile-options events. - BUG: Previously the guard checked .tile-delete which was removed in Phase 3. - Clicking ⋮ triggered BOTH flyout opening AND openSession() navigation. - Fix: guard must check .tile-options-btn to stop event from reaching openSession(). + With session-tile Lit components, the options-button guard is handled inside the component + itself. renderGrid listens for 'tile-click' and 'tile-options' custom events instead of + binding raw click handlers with .tile-options-btn guards. """ - # The tile click handler is inside renderGrid — find it render_grid_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0] - assert "tile-options-btn" in render_grid_body, ( - "Tile click handler must guard against .tile-options-btn clicks — " - "clicking ⋮ must NOT trigger openSession()" + # session-tile components fire custom events; renderGrid must listen for them + assert "tile-click" in render_grid_body, ( + "renderGrid must listen for 'tile-click' events from session-tile components" + ) + assert "tile-options" in render_grid_body, ( + "renderGrid must listen for 'tile-options' events from session-tile components" ) - # Confirm the old broken guard is gone - assert ( - "'tile-delete'" not in render_grid_body - and '"tile-delete"' not in render_grid_body - or ("tile-options-btn" in render_grid_body) - ), "Guard must use .tile-options-btn, not the old .tile-delete which was removed" def test_flyout_delegation_handler_no_stop_propagation() -> None: