Files
intercept/static/js/core/ui-feedback.js
Smittix e687862043 feat: UI/UX overhaul — CSS cleanup, accessibility, error handling, inline style extraction
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>
2026-03-12 13:04:36 +00:00

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();
});