Extension: icon-state feedback + overlay picker instead of inline panels

- Injected buttons now show state via the tablet icon itself — spinner
  while sending, check on success, warning triangle on busy/offline/error —
  with detail in a hover tooltip, rather than a persistent inline text block
- Replaced the in-page dropdown panel (block-inserted for row/banner
  buttons, fixed-position floating for menu items) with a single centred
  modal overlay used by all three injection shapes — simpler than
  maintaining per-variant positioning logic, and looks the same everywhere
- Picker overlay now opens automatically when the default tablet is busy
  (409) or configured but not actually connected (delivered: false), not
  just when no default is set at all — mirrored in popup.js's own fallback

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-10-01 12:24:44 +00:00
parent 4aeca15281
commit d7fbdce04d
2 changed files with 214 additions and 136 deletions

View file

@ -95,7 +95,7 @@ function makeSendButton(bookingId, guestName, variant = 'block') {
btn.id = `tcp-btn-${bookingId}-${variant}`; btn.id = `tcp-btn-${bookingId}-${variant}`;
btn.type = 'button'; btn.type = 'button';
btn.className = variant === 'block' ? 'button most_width' : 'button'; btn.className = variant === 'block' ? 'button most_width' : 'button';
btn.innerHTML = '<i class="far fa-tablet-alt fa-fw" aria-hidden="true"></i> <span>Tablet Push</span>'; btn.innerHTML = '<i class="tcp-icon far fa-tablet-alt fa-fw" aria-hidden="true"></i> <span>Tablet Push</span>';
btn.style.cssText = ` btn.style.cssText = `
${variant === 'block' ? 'margin-top: 6px;' : 'margin-right: 8px;'} ${variant === 'block' ? 'margin-top: 6px;' : 'margin-right: 8px;'}
background: #be123c; border-color: #be123c; color: #fff; background: #be123c; border-color: #be123c; color: #fff;
@ -103,121 +103,193 @@ function makeSendButton(bookingId, guestName, variant = 'block') {
btn.addEventListener('click', e => { btn.addEventListener('click', e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleSendClick(btn, bookingId, guestName, variant); handleSendClick(btn, bookingId, guestName);
}); });
return btn; return btn;
} }
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
// Visual feedback is split two ways: a per-button icon swap for the simple
// pass/fail cases (no page real estate taken, self-explanatory at a glance),
// and the picker overlay below for anything that actually needs a human
// decision (no default configured, default busy, default offline). Detail
// text lives in the title attribute (hover tooltip) rather than a persistent
// inline block — keeps every variant (table-cell row, banner, nav-bar menu
// item) visually identical instead of needing per-container layout math.
const STATE_ICON = {
idle: 'fa-tablet-alt',
loading: 'fa-spinner fa-spin',
success: 'fa-check',
warning: 'fa-exclamation-triangle',
error: 'fa-exclamation-triangle',
};
const STATE_BG = {
idle: '#be123c',
loading: '#be123c',
success: '#166534',
warning: '#b45309',
error: '#dc2626',
};
const STATE_ICON_COLOR = {
success: '#86efac',
warning: '#fde68a',
error: '#fecaca',
};
// <button> variants (row/banner) get their whole background tinted; the
// context-menu variant is an <a> with no background box of its own — tinting
// that would look out of place in NewBook's own nav bar, so only its icon
// recolours.
function setButtonState(anchorBtn, state, title = '') {
const icon = anchorBtn.querySelector('.tcp-icon');
if (icon) {
icon.className = `tcp-icon far ${STATE_ICON[state] || STATE_ICON.idle} fa-fw`;
icon.style.color = STATE_ICON_COLOR[state] || '';
}
if (anchorBtn.tagName === 'BUTTON') {
const bg = STATE_BG[state] || STATE_BG.idle;
anchorBtn.style.background = bg;
anchorBtn.style.borderColor = bg;
}
if (title) anchorBtn.title = title; else anchorBtn.removeAttribute('title');
}
function scheduleRevert(anchorBtn) {
setTimeout(() => {
// Button may already be gone (NewBook rebuilt its container) — touching
// a detached node is harmless, but skip the state reset if so, nothing
// to show it on anymore.
if (document.body.contains(anchorBtn)) setButtonState(anchorBtn, 'idle');
}, 4000);
}
// Injected buttons skip asking which tablet entirely when a default is // Injected buttons skip asking which tablet entirely when a default is
// configured for this machine (see options.js) — push straight to it. Only // configured for this machine (see options.js) — push straight to it. Only
// falls back to the full device-picker panel if that specific push comes // opens the picker overlay if that specific push comes back busy (409), the
// back busy (409), or if no default is configured at all — see // default is configured but not actually connected (delivered: false — see
// project_checkin memory's extension notes, this was an explicit UX request // device-stream.js), or no default is configured at all. The popup is
// ("injected buttons don't need to ask which tablet, just use/send to // deliberately different — it always shows the picker, just pre-selected to
// default unless busy"). The popup is deliberately different — it always // the default (see popup.js) — since opening the popup is already a more
// shows the picker, just pre-selected to the default (see popup.js) — since // deliberate action than a one-click inline button.
// opening the popup is already a more deliberate action than a one-click async function handleSendClick(anchorBtn, bookingId, guestName) {
// inline button. if (anchorBtn.dataset.tcpBusy === '1') return; // already mid-send, ignore a double-click
async function handleSendClick(anchorBtn, bookingId, guestName, variant) {
const { defaultDeviceId } = await chrome.storage.local.get('defaultDeviceId'); const { defaultDeviceId } = await chrome.storage.local.get('defaultDeviceId');
if (!defaultDeviceId) {
showPickerOverlay(anchorBtn, bookingId, guestName);
return;
}
anchorBtn.dataset.tcpBusy = '1';
setButtonState(anchorBtn, 'loading');
if (defaultDeviceId) {
const status = showInlineStatus(anchorBtn, variant, 'Sending…');
let result; let result;
try { try {
result = await chrome.runtime.sendMessage({ action: 'tcpPush', bookingId, deviceId: defaultDeviceId }); result = await chrome.runtime.sendMessage({ action: 'tcpPush', bookingId, deviceId: defaultDeviceId });
} catch { } catch {
status.textContent = 'Extension was reloaded — refresh this NewBook page and try again.'; delete anchorBtn.dataset.tcpBusy;
setButtonState(anchorBtn, 'error', 'Extension was reloaded — refresh this NewBook page and try again.');
scheduleRevert(anchorBtn);
return;
}
delete anchorBtn.dataset.tcpBusy;
if (result?.ok && result.delivered !== false) {
setButtonState(anchorBtn, 'success', 'Sent — the tablet should navigate now.');
scheduleRevert(anchorBtn);
return; return;
} }
if (result?.ok) { if (result?.ok) {
if (result.delivered === false) { // delivered === false — session created server-side but nothing's
status.textContent = 'Sent, but that tablet isn’t connected right now — it’ll pick this up if it reconnects.'; // actually listening on that device right now.
status.style.color = '#b45309'; setButtonState(anchorBtn, 'warning', 'That tablet isn’t connected right now.');
} else { showPickerOverlay(anchorBtn, bookingId, guestName, 'Default tablet isn’t connected — choose another:');
status.textContent = 'Sent — the tablet should navigate now.';
status.style.color = '#166534';
}
return; return;
} }
if (result?.status === 409) { if (result?.status === 409) {
status.remove(); setButtonState(anchorBtn, 'warning', result.error || 'That tablet is busy.');
// fall through to the picker below, with a note explaining why it's showPickerOverlay(anchorBtn, bookingId, guestName, 'Default tablet is busy — choose another:');
// asking despite a default being configured
await togglePanel(anchorBtn, bookingId, guestName, variant, 'Default tablet is busy — choose another:');
return; return;
} }
status.textContent = `Failed: ${result?.error || 'unknown error'}`; setButtonState(anchorBtn, 'error', `Failed: ${result?.error || 'unknown error'}`);
status.style.color = '#dc2626'; scheduleRevert(anchorBtn);
return;
}
await togglePanel(anchorBtn, bookingId, guestName, variant);
} }
// The context-menu buttons (variant 'menu') live inside a horizontal nav // Centred modal overlay — replaces the old in-page dropdown panel entirely
// bar <li> — inserting a block-level status/panel 'afterend' there (like the // (both its block-flow insertion for row/banner buttons and the fixed-
// row/banner variants do) would stretch that menu item's row height instead // position floating variant the nav-bar menu items needed). A real overlay
// of floating over the page. Anchoring to the viewport via the button's own // doesn't care where its anchor lives on the page, so one implementation now
// bounding rect sidesteps needing the menu bar to be a positioned ancestor. // covers all three injection shapes.
function positionFloating(el, anchorBtn) { let overlayBackdrop = null;
const rect = anchorBtn.getBoundingClientRect();
el.style.position = 'fixed'; function closeOverlay() {
el.style.top = `${rect.bottom + 4}px`; if (!overlayBackdrop) return;
el.style.left = `${rect.left}px`; overlayBackdrop.remove();
el.style.zIndex = '99999'; overlayBackdrop = null;
document.removeEventListener('keydown', handleOverlayKeydown);
} }
// Small text-only status line for the default-tablet fast path — no function handleOverlayKeydown(e) {
// dropdown, nothing to pick, just feedback on what happened. if (e.key === 'Escape') closeOverlay();
function showInlineStatus(anchorBtn, variant, text) { }
if (openPanel) { openPanel.remove(); openPanel = null; }
const el = document.createElement('div'); function buildOverlay() {
const width = variant === 'block' ? 'width: 100%;' : ''; closeOverlay();
const floating = variant === 'menu'; const backdrop = document.createElement('div');
el.style.cssText = ` backdrop.style.cssText = `
display: block; ${floating ? '' : 'margin-top: 6px;'} ${width} font-size: 12px; color: #64748b; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45);
font-family: -apple-system, BlinkMacSystemFont, sans-serif; z-index: 999998; display: flex; align-items: center; justify-content: center;
${floating ? 'background: #fff; padding: 6px 10px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);' : ''}
`; `;
el.textContent = text; backdrop.addEventListener('mousedown', e => { if (e.target === backdrop) closeOverlay(); });
if (floating) {
document.body.appendChild(el);
positionFloating(el, anchorBtn);
} else {
anchorBtn.insertAdjacentElement('afterend', el);
}
openPanel = el;
return el;
}
let openPanel = null;
async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', note = '') {
if (openPanel) { openPanel.remove(); openPanel = null; }
const panel = document.createElement('div'); const panel = document.createElement('div');
// Always its own block below the anchor regardless of variant — an inline
// anchor (the banner button) has no full-width block context to size
// against naturally, so give it an explicit min-width instead of relying
// on parent width like the row-cell variant can.
const floating = variant === 'menu';
const width = variant === 'block' ? 'width: 100%;' : 'min-width: 200px; max-width: 280px;';
panel.style.cssText = ` panel.style.cssText = `
display: block; ${floating ? '' : 'margin-top: 6px;'} ${width} padding: 8px; background: #f4f5f7; position: relative; background: #fff; border-radius: 12px; padding: 20px;
border: 1px solid #e4e8ee; border-radius: 8px; min-width: 260px; max-width: 320px; box-shadow: 0 10px 40px rgba(0,0,0,0.3);
${floating ? 'box-shadow: 0 2px 10px rgba(0,0,0,0.2);' : ''} font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13px; color: #1e293b;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 12px; color: #1e293b;
`; `;
panel.textContent = note ? `${note} loading…` : 'Loading tablets…';
if (floating) { const closeBtn = document.createElement('button');
document.body.appendChild(panel); closeBtn.type = 'button';
positionFloating(panel, anchorBtn); closeBtn.textContent = '×';
} else { closeBtn.setAttribute('aria-label', 'Close');
anchorBtn.insertAdjacentElement('afterend', panel); closeBtn.style.cssText = `
position: absolute; top: 6px; right: 10px; border: none; background: none;
font-size: 20px; line-height: 1; cursor: pointer; color: #94a3b8; padding: 4px;
`;
closeBtn.addEventListener('click', closeOverlay);
panel.appendChild(closeBtn);
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
overlayBackdrop = backdrop;
document.addEventListener('keydown', handleOverlayKeydown);
return panel;
}
async function showPickerOverlay(anchorBtn, bookingId, guestName, note = '') {
const panel = buildOverlay();
const title = document.createElement('div');
title.style.cssText = 'font-weight: 700; margin: 0 20px 10px 0; color: #1a1a2e;';
title.textContent = guestName ? `Send ${guestName} to a tablet` : 'Send to a tablet';
panel.appendChild(title);
if (note) {
const noteEl = document.createElement('div');
noteEl.textContent = note;
noteEl.style.cssText = 'margin-bottom: 10px; color: #b45309; font-weight: 600;';
panel.appendChild(noteEl);
} }
openPanel = panel;
const body = document.createElement('div');
body.textContent = 'Loading tablets…';
panel.appendChild(body);
// A stale content script (injected before the extension was last // A stale content script (injected before the extension was last
// reloaded) throws "Extension context invalidated" the moment it touches // reloaded) throws "Extension context invalidated" the moment it touches
@ -230,82 +302,87 @@ async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', n
try { try {
res = await chrome.runtime.sendMessage({ action: 'tcpListDevices' }); res = await chrome.runtime.sendMessage({ action: 'tcpListDevices' });
} catch { } catch {
panel.textContent = 'Extension was reloaded — refresh this NewBook page and try again.'; body.textContent = 'Extension was reloaded — refresh this NewBook page and try again.';
return; return;
} }
if (overlayBackdrop !== panel.parentElement) return; // closed while loading
if (!res?.ok) { if (!res?.ok) {
panel.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`; body.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`;
return; return;
} }
if (!res.devices.length) { if (!res.devices.length) {
panel.textContent = 'No tablets registered yet.'; body.textContent = 'No tablets registered yet.';
return; return;
} }
const select = document.createElement('select'); const select = document.createElement('select');
select.style.cssText = 'width: 100%; margin-bottom: 6px; padding: 4px;'; select.style.cssText = 'width: 100%; margin-bottom: 10px; padding: 6px; border-radius: 6px; border: 1px solid #e4e8ee;';
select.innerHTML = res.devices.map(d => select.innerHTML = res.devices.map(d =>
`<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}</option>` `<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}</option>`
).join(''); ).join('');
const sendBtn = document.createElement('button'); const sendBtn = document.createElement('button');
sendBtn.type = 'button';
sendBtn.textContent = `Send${guestName ? ` — ${guestName}` : ''}`; sendBtn.textContent = `Send${guestName ? ` — ${guestName}` : ''}`;
sendBtn.style.cssText = ` sendBtn.style.cssText = `
width: 100%; padding: 4px 8px; background: #c9a84c; color: #1a1a2e; width: 100%; padding: 8px; background: #c9a84c; color: #1a1a2e;
border: none; border-radius: 6px; font-weight: 600; cursor: pointer; border: none; border-radius: 6px; font-weight: 700; cursor: pointer;
`; `;
const statusEl = document.createElement('div');
statusEl.style.cssText = 'margin-top: 8px; font-size: 12px;';
sendBtn.addEventListener('click', async () => { sendBtn.addEventListener('click', async () => {
sendBtn.disabled = true; sendBtn.disabled = true;
sendBtn.textContent = 'Sending…'; sendBtn.textContent = 'Sending…';
const result = await chrome.runtime.sendMessage({ setButtonState(anchorBtn, 'loading');
action: 'tcpPush', bookingId, deviceId: select.value,
}); const result = await chrome.runtime.sendMessage({ action: 'tcpPush', bookingId, deviceId: select.value });
if (result?.ok) {
panel.textContent = result.delivered === false sendBtn.disabled = false;
? 'Sent, but that tablet isn’t connected right now — it’ll pick this up if it reconnects.' sendBtn.textContent = `Send${guestName ? ` — ${guestName}` : ''}`;
: 'Sent — the tablet should navigate now.';
panel.style.color = result.delivered === false ? '#b45309' : '#1e293b'; if (result?.ok && result.delivered !== false) {
statusEl.textContent = 'Sent — the tablet should navigate now.';
statusEl.style.color = '#166534';
setButtonState(anchorBtn, 'success', 'Sent — the tablet should navigate now.');
scheduleRevert(anchorBtn);
setTimeout(closeOverlay, 1000);
} else if (result?.ok) {
statusEl.textContent = 'Sent, but that tablet isn’t connected right now.';
statusEl.style.color = '#b45309';
setButtonState(anchorBtn, 'warning', 'That tablet isn’t connected right now.');
} else { } else {
panel.textContent = `Failed: ${result?.error || 'unknown error'}`; statusEl.textContent = `Failed: ${result?.error || 'unknown error'}`;
statusEl.style.color = '#dc2626';
setButtonState(anchorBtn, 'error', `Failed: ${result?.error || 'unknown error'}`);
} }
}); });
panel.innerHTML = ''; body.innerHTML = '';
if (note) { body.appendChild(select);
const noteEl = document.createElement('div'); body.appendChild(sendBtn);
noteEl.textContent = note; body.appendChild(statusEl);
noteEl.style.cssText = 'margin-bottom: 6px; color: #b45309; font-weight: 600;';
panel.appendChild(noteEl);
}
panel.appendChild(select);
panel.appendChild(sendBtn);
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
} }
// Context-menu entries use a completely different markup shape than the // Context-menu entries use a completely different markup shape than the
// .button-based row/banner injections above — <li><a><div class="page_icon"> // .button-based row/banner injections above — <li><a><div class="page_icon">
// <i/></div><span/></a></li> — so this builds that shape directly rather // <i/></div><span/></a></li> — so this builds that shape directly rather
// than reusing makeSendButton. Click handling (default-tablet fast path, // than reusing makeSendButton. Click handling (default-tablet fast path,
// picker fallback) is identical, via the 'menu' variant plumbed through // picker overlay fallback) is identical via the shared handleSendClick.
// handleSendClick/showInlineStatus/togglePanel above.
function makeMenuLink(bookingId) { function makeMenuLink(bookingId) {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'context tcp-menu-item'; li.className = 'context tcp-menu-item';
const a = document.createElement('a'); const a = document.createElement('a');
a.href = '#'; a.href = '#';
a.innerHTML = ` a.innerHTML = `
<div class="page_icon"><i class="far fa-tablet-alt fa-fw" alt="" style="margin-right: 0; margin-left: 0px; vertical-align: middle; font-size: 24px;" aria-hidden="true"></i></div> <div class="page_icon"><i class="tcp-icon far fa-tablet-alt fa-fw" alt="" style="margin-right: 0; margin-left: 0px; vertical-align: middle; font-size: 24px;" aria-hidden="true"></i></div>
<span>Tablet Push</span> <span>Tablet Push</span>
`; `;
a.addEventListener('click', e => { a.addEventListener('click', e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleSendClick(a, bookingId, '', 'menu'); handleSendClick(a, bookingId, '');
}); });
li.appendChild(a); li.appendChild(a);
return li; return li;

View file

@ -126,12 +126,13 @@ function renderBooking(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey)
const msgEl = document.getElementById('msg'); const msgEl = document.getElementById('msg');
if (!msgEl) return; // "Send to a different tablet" was clicked before this resolved if (!msgEl) return; // "Send to a different tablet" was clicked before this resolved
if (result.ok) { if (result.ok && result.delivered !== false) {
if (result.delivered === false) {
setMsg(msgEl, 'Sent, but that tablet isn’t connected right now — it’ll pick this up if it reconnects.', 'warn');
} else {
setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok'); setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok');
} } else if (result.ok) {
// Session created server-side but nothing's actually listening on
// that device right now — same "needs a human decision" case as
// busy, so offer the picker rather than a dead-end message.
renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey, 'Default tablet isn’t connected — choose another:');
} else if (result.status === 409) { } else if (result.status === 409) {
// Default went busy in the moment between loading the popup and // Default went busy in the moment between loading the popup and
// sending — fall back to the manual picker rather than leaving the // sending — fall back to the manual picker rather than leaving the