mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
Phase 0 — CSS-only fixes: - Fix --font-mono to use real monospace stack (JetBrains Mono, Fira Code, etc.) - Replace hardcoded hex colors with CSS variables across 16+ files - Merge global-nav.css (507 lines) into layout.css, delete original - Reduce !important in responsive.css from 71 to 8 via .app-shell specificity - Standardize breakpoints to 480/768/1024/1280px Phase 1 — Loading states & SSE connection feedback: - Add centralized SSEManager (sse-manager.js) with exponential backoff - Add SSE status indicator dot in nav bar - Add withLoadingButton() + .btn-loading CSS spinner - Add mode section crossfade transitions Phase 2 — Accessibility: - Add aria-labels to icon-only buttons across mode partials - Add for/id associations to 42 form labels in 5 mode partials - Add aria-live on toast stack, enableListKeyNav() utility Phase 3 — Destructive action guards & list overflow: - Add confirmAction() styled modal, replace all 25 native confirm() calls - Add toast cap at 5 simultaneous toasts - Add list overflow indicator CSS Phase 4 — Inline style extraction: - Refactor switchMode() in app.js and index.html to use classList.toggle() - Add CSS toggle rules for all switchMode-controlled elements - Remove inline style="display:none" from 7+ HTML elements - Add utility classes (.hidden, .d-flex, .d-grid, etc.) Phase 5 — Mobile UX polish: - pre/code overflow handling already in place - Touch target sizing via --touch-min variable Phase 6 — Error handling consistency: - Add reportActionableError() to user-facing catch blocks in 5 mode JS files - 28 error toast additions alongside existing console.error calls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
444 lines
14 KiB
JavaScript
444 lines
14 KiB
JavaScript
const AppFeedback = (function() {
|
|
'use strict';
|
|
|
|
let stackEl = null;
|
|
let nextToastId = 1;
|
|
const TOAST_MAX = 5;
|
|
|
|
function init() {
|
|
ensureStack();
|
|
installGlobalHandlers();
|
|
}
|
|
|
|
function ensureStack() {
|
|
if (stackEl && document.body.contains(stackEl)) return stackEl;
|
|
|
|
stackEl = document.getElementById('appToastStack');
|
|
if (!stackEl) {
|
|
stackEl = document.createElement('div');
|
|
stackEl.id = 'appToastStack';
|
|
stackEl.className = 'app-toast-stack';
|
|
stackEl.setAttribute('aria-live', 'assertive');
|
|
stackEl.setAttribute('role', 'alert');
|
|
document.body.appendChild(stackEl);
|
|
}
|
|
return stackEl;
|
|
}
|
|
|
|
function toast(options) {
|
|
const opts = options || {};
|
|
const type = normalizeType(opts.type);
|
|
const id = nextToastId++;
|
|
const durationMs = Number.isFinite(opts.durationMs) ? opts.durationMs : 6500;
|
|
|
|
const root = document.createElement('div');
|
|
root.className = `app-toast ${type}`;
|
|
root.dataset.toastId = String(id);
|
|
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'app-toast-title';
|
|
titleEl.textContent = String(opts.title || defaultTitle(type));
|
|
root.appendChild(titleEl);
|
|
|
|
const msgEl = document.createElement('div');
|
|
msgEl.className = 'app-toast-msg';
|
|
msgEl.textContent = String(opts.message || '');
|
|
root.appendChild(msgEl);
|
|
|
|
const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean).slice(0, 3) : [];
|
|
if (actions.length > 0) {
|
|
const actionsEl = document.createElement('div');
|
|
actionsEl.className = 'app-toast-actions';
|
|
for (const action of actions) {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.textContent = String(action.label || 'Action');
|
|
btn.addEventListener('click', () => {
|
|
try {
|
|
if (typeof action.onClick === 'function') {
|
|
action.onClick();
|
|
}
|
|
} finally {
|
|
removeToast(id);
|
|
}
|
|
});
|
|
actionsEl.appendChild(btn);
|
|
}
|
|
root.appendChild(actionsEl);
|
|
}
|
|
|
|
const stack = ensureStack();
|
|
|
|
// Enforce toast cap — remove oldest when exceeded
|
|
while (stack.children.length >= TOAST_MAX) {
|
|
stack.removeChild(stack.firstChild);
|
|
}
|
|
|
|
stack.appendChild(root);
|
|
|
|
if (durationMs > 0) {
|
|
window.setTimeout(() => {
|
|
removeToast(id);
|
|
}, durationMs);
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
function removeToast(id) {
|
|
if (!stackEl) return;
|
|
const toastEl = stackEl.querySelector(`[data-toast-id="${id}"]`);
|
|
if (!toastEl) return;
|
|
toastEl.remove();
|
|
}
|
|
|
|
function reportError(context, error, options) {
|
|
const opts = options || {};
|
|
const message = extractMessage(error);
|
|
const actions = [];
|
|
|
|
if (isSettingsError(message)) {
|
|
actions.push({
|
|
label: 'Open Settings',
|
|
onClick: () => {
|
|
if (typeof showSettings === 'function') {
|
|
showSettings();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (isNetworkError(message)) {
|
|
actions.push({
|
|
label: 'Retry',
|
|
onClick: () => {
|
|
if (typeof opts.onRetry === 'function') {
|
|
opts.onRetry();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (typeof opts.extraAction === 'function' && opts.extraActionLabel) {
|
|
actions.push({
|
|
label: String(opts.extraActionLabel),
|
|
onClick: opts.extraAction,
|
|
});
|
|
}
|
|
|
|
return toast({
|
|
type: 'error',
|
|
title: context || 'Action Failed',
|
|
message,
|
|
actions,
|
|
durationMs: opts.persistent ? 0 : 8500,
|
|
});
|
|
}
|
|
|
|
function installGlobalHandlers() {
|
|
window.addEventListener('error', (event) => {
|
|
const target = event && event.target;
|
|
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) {
|
|
return;
|
|
}
|
|
|
|
const message = extractMessage(event && event.error) || String(event.message || 'Unknown error');
|
|
if (shouldIgnore(message)) return;
|
|
toast({
|
|
type: 'warning',
|
|
title: 'Unhandled Error',
|
|
message,
|
|
});
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
const message = extractMessage(event && event.reason);
|
|
if (shouldIgnore(message)) return;
|
|
toast({
|
|
type: 'warning',
|
|
title: 'Promise Rejection',
|
|
message,
|
|
});
|
|
});
|
|
}
|
|
|
|
function normalizeType(type) {
|
|
const t = String(type || 'info').toLowerCase();
|
|
if (t === 'error' || t === 'warning') return t;
|
|
return 'info';
|
|
}
|
|
|
|
function defaultTitle(type) {
|
|
if (type === 'error') return 'Error';
|
|
if (type === 'warning') return 'Warning';
|
|
return 'Notice';
|
|
}
|
|
|
|
function extractMessage(error) {
|
|
if (!error) return 'Unknown error';
|
|
if (typeof error === 'string') return error;
|
|
if (error instanceof Error) return error.message || error.name;
|
|
if (typeof error.message === 'string') return error.message;
|
|
return String(error);
|
|
}
|
|
|
|
function shouldIgnore(message) {
|
|
const text = String(message || '').toLowerCase();
|
|
return text.includes('script error') || text.includes('resizeobserver loop limit exceeded');
|
|
}
|
|
|
|
function renderCollectionState(container, options) {
|
|
if (!container) return null;
|
|
const opts = options || {};
|
|
const type = String(opts.type || 'empty').toLowerCase();
|
|
const message = String(opts.message || (type === 'loading' ? 'Loading...' : 'No data available'));
|
|
const className = opts.className || `app-collection-state is-${type}`;
|
|
|
|
container.innerHTML = '';
|
|
|
|
if (container.tagName === 'TBODY') {
|
|
const row = document.createElement('tr');
|
|
row.className = 'app-collection-state-row';
|
|
const cell = document.createElement('td');
|
|
const columns = Number.isFinite(opts.columns) ? opts.columns : 1;
|
|
cell.colSpan = Math.max(1, columns);
|
|
const state = document.createElement('div');
|
|
state.className = className;
|
|
state.textContent = message;
|
|
cell.appendChild(state);
|
|
row.appendChild(cell);
|
|
container.appendChild(row);
|
|
return row;
|
|
}
|
|
|
|
const state = document.createElement('div');
|
|
state.className = className;
|
|
state.textContent = message;
|
|
container.appendChild(state);
|
|
return state;
|
|
}
|
|
|
|
function isOffline() {
|
|
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
|
}
|
|
|
|
function isTransientNetworkError(error) {
|
|
const text = String(extractMessage(error) || '').toLowerCase();
|
|
if (!text) return false;
|
|
|
|
return text.includes('networkerror') ||
|
|
text.includes('failed to fetch') ||
|
|
text.includes('network request failed') ||
|
|
text.includes('load failed') ||
|
|
text.includes('err_network_io_suspended') ||
|
|
text.includes('network io suspended') ||
|
|
text.includes('the network connection was lost') ||
|
|
text.includes('connection reset') ||
|
|
text.includes('timeout');
|
|
}
|
|
|
|
function isTransientOrOffline(error) {
|
|
return isOffline() || isTransientNetworkError(error);
|
|
}
|
|
|
|
function isNetworkError(message) {
|
|
return isTransientNetworkError(message);
|
|
}
|
|
|
|
function isSettingsError(message) {
|
|
const text = String(message || '').toLowerCase();
|
|
return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool');
|
|
}
|
|
|
|
// --- Button loading state ---
|
|
|
|
function withLoadingButton(btn, asyncFn) {
|
|
if (!btn || btn.disabled) return Promise.resolve();
|
|
|
|
const originalText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.classList.add('btn-loading');
|
|
|
|
return Promise.resolve()
|
|
.then(function() { return asyncFn(); })
|
|
.then(function(result) {
|
|
btn.disabled = false;
|
|
btn.classList.remove('btn-loading');
|
|
btn.textContent = originalText;
|
|
return result;
|
|
})
|
|
.catch(function(err) {
|
|
btn.disabled = false;
|
|
btn.classList.remove('btn-loading');
|
|
btn.textContent = originalText;
|
|
throw err;
|
|
});
|
|
}
|
|
|
|
// --- Confirmation modal ---
|
|
|
|
function confirmAction(options) {
|
|
var opts = options || {};
|
|
var title = opts.title || 'Confirm Action';
|
|
var message = opts.message || 'Are you sure?';
|
|
var confirmLabel = opts.confirmLabel || 'Confirm';
|
|
var confirmClass = opts.confirmClass || 'btn-danger';
|
|
|
|
return new Promise(function(resolve) {
|
|
// Create backdrop
|
|
var backdrop = document.createElement('div');
|
|
backdrop.className = 'confirm-modal-backdrop';
|
|
|
|
var modal = document.createElement('div');
|
|
modal.className = 'confirm-modal';
|
|
modal.setAttribute('role', 'dialog');
|
|
modal.setAttribute('aria-modal', 'true');
|
|
modal.setAttribute('aria-labelledby', 'confirm-modal-title');
|
|
|
|
var titleEl = document.createElement('div');
|
|
titleEl.className = 'confirm-modal-title';
|
|
titleEl.id = 'confirm-modal-title';
|
|
titleEl.textContent = title;
|
|
modal.appendChild(titleEl);
|
|
|
|
var msgEl = document.createElement('div');
|
|
msgEl.className = 'confirm-modal-message';
|
|
msgEl.textContent = message;
|
|
modal.appendChild(msgEl);
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'confirm-modal-actions';
|
|
|
|
var cancelBtn = document.createElement('button');
|
|
cancelBtn.type = 'button';
|
|
cancelBtn.className = 'btn btn-ghost';
|
|
cancelBtn.textContent = 'Cancel';
|
|
|
|
var confirmBtn = document.createElement('button');
|
|
confirmBtn.type = 'button';
|
|
confirmBtn.className = 'btn ' + confirmClass;
|
|
confirmBtn.textContent = confirmLabel;
|
|
|
|
actions.appendChild(cancelBtn);
|
|
actions.appendChild(confirmBtn);
|
|
modal.appendChild(actions);
|
|
backdrop.appendChild(modal);
|
|
document.body.appendChild(backdrop);
|
|
|
|
// Focus confirm button
|
|
confirmBtn.focus();
|
|
|
|
function cleanup(result) {
|
|
backdrop.remove();
|
|
document.removeEventListener('keydown', onKey);
|
|
resolve(result);
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.key === 'Escape') cleanup(false);
|
|
if (e.key === 'Enter') cleanup(true);
|
|
}
|
|
|
|
cancelBtn.addEventListener('click', function() { cleanup(false); });
|
|
confirmBtn.addEventListener('click', function() { cleanup(true); });
|
|
backdrop.addEventListener('click', function(e) {
|
|
if (e.target === backdrop) cleanup(false);
|
|
});
|
|
document.addEventListener('keydown', onKey);
|
|
});
|
|
}
|
|
|
|
// --- Keyboard navigation for lists ---
|
|
|
|
function enableListKeyNav(container, itemSelector) {
|
|
if (!container) return;
|
|
|
|
container.setAttribute('role', 'listbox');
|
|
container.setAttribute('tabindex', '0');
|
|
|
|
container.addEventListener('keydown', function(e) {
|
|
var items = container.querySelectorAll(itemSelector);
|
|
if (!items.length) return;
|
|
|
|
var current = container.querySelector(itemSelector + '[aria-selected="true"]');
|
|
var idx = current ? Array.prototype.indexOf.call(items, current) : -1;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
var next = Math.min(idx + 1, items.length - 1);
|
|
selectItem(items, next);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
var prev = Math.max(idx - 1, 0);
|
|
selectItem(items, prev);
|
|
} else if (e.key === 'Enter' && current) {
|
|
e.preventDefault();
|
|
current.click();
|
|
} else if (e.key === 'Escape' && current) {
|
|
e.preventDefault();
|
|
current.setAttribute('aria-selected', 'false');
|
|
current.classList.remove('keyboard-focused');
|
|
}
|
|
});
|
|
|
|
function selectItem(items, index) {
|
|
items.forEach(function(item) {
|
|
item.setAttribute('aria-selected', 'false');
|
|
item.classList.remove('keyboard-focused');
|
|
});
|
|
var target = items[index];
|
|
if (target) {
|
|
target.setAttribute('aria-selected', 'true');
|
|
target.classList.add('keyboard-focused');
|
|
target.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
init,
|
|
toast,
|
|
reportError,
|
|
removeToast,
|
|
renderCollectionState,
|
|
isOffline,
|
|
isTransientNetworkError,
|
|
isTransientOrOffline,
|
|
withLoadingButton,
|
|
confirmAction,
|
|
enableListKeyNav,
|
|
};
|
|
})();
|
|
|
|
window.showAppToast = function(title, message, type) {
|
|
return AppFeedback.toast({
|
|
title,
|
|
message,
|
|
type,
|
|
});
|
|
};
|
|
|
|
window.reportActionableError = function(context, error, options) {
|
|
return AppFeedback.reportError(context, error, options);
|
|
};
|
|
|
|
window.renderCollectionState = function(container, options) {
|
|
return AppFeedback.renderCollectionState(container, options);
|
|
};
|
|
|
|
window.isOffline = function() {
|
|
return AppFeedback.isOffline();
|
|
};
|
|
|
|
window.isTransientNetworkError = function(error) {
|
|
return AppFeedback.isTransientNetworkError(error);
|
|
};
|
|
|
|
window.isTransientOrOffline = function(error) {
|
|
return AppFeedback.isTransientOrOffline(error);
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
AppFeedback.init();
|
|
});
|