mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
fix(web): harden, polish and optimize the web UI per the Sept 2026 audit (#568)
* fix(web): harden, polish and optimize the web UI per the September 2026 audit Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20). Implementation integrity (P0) - app.css now defines every utility class the templates and JS use, including .hidden, so the ~145 JS show/hide toggles work. Button reset, and base component rules (.btn, .form-control) wrapped in :where() so utility classes on the same element win. New static-audit test fails when a used utility class has no rule. Accessibility - Focus rings render (the old ring rule referenced undefined variables); one :focus-visible outline everywhere; skip link; labelled nav landmarks. - Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap, Escape, focus return, applied to every modal. - Named icon-only buttons and labelled ~70 form fields. - Toasts announced once; errors persist >= 10s; one showNotification. - Captive WiFi page: live region, timeouts, dark mode, 16px inputs. Performance (Pi Zero 2 W) - SSE streams and tab timers pause when hidden or off-tab; the display stream only runs while a preview is visible. app-shell.js deferred. - Widget scripts served as one versioned bundle (/assets/widgets.js): 52 -> 21 script tags, 66 -> 35 requests on first load. - Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS 1358 KB -> 291 KB on the wire. SSE untouched. Theming and responsive - File managers, form fields and Fonts upload on theme tokens; bare inputs themed in dark mode; no more white surfaces. - No horizontal overflow at 375px on any tab; 44px touch targets on coarse pointers; reduced-motion respected; header title truncates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings on #568 - json-file-manager: focus-trap releases kept in a Map (no dynamic property access or delete; no value-returning forEach callback) - notification / schedule-picker: style and day-label lookups via Map - app.js: move the pending-queue assignment out of the expression - diff_viewer / error_handler: named function declarations instead of arrow consts No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: check the OAuth widget ships in the widget bundle base.html no longer tags widget scripts one by one; they load through /assets/widgets.js. Assert the page requests the bundle and the bundle contains google-oauth.js, which is what the test was protecting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): address review feedback on #568 - widget bundle version fingerprints every file (name, mtime_ns, size) - gzip fallback appends Accept-Encoding to an existing Vary header - dialog helper: releasing a non-top dialog no longer moves focus out of the dialog the user is in - labels: file-upload targets its file input; fallback config fields get label for/id pairs; native color input has a fallback name - utility audit also reads class names inside bound :class expressions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): give the native color-picker input an accessible name CodeRabbit flagged this on PR #568 as an outside-diff finding (never posted inline, so it was missed in the round of fixes that addressed the other 6 review comments). The <input type="color"> only carried a title attribute; screen readers don't reliably announce title, and there's no other label naming the control when showHexInput is false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings in app-shell.js - drop the unused catch binding on the SSE JSON parse - move the pending-notification queue assignment out of the expression No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): contain plugin widgets/ dir and bound style-editor retries From CodeRabbit review on #568 (code that arrived with the main merge): - serve_plugin_widget resolves widgets/ with resolve_under before resolving the manifest script under it, so a symlinked widgets directory can't become the containment base (CWE-22). New test. - style-editor init stops polling after ~10s when the widget never registers and leaves the plain fallback fields in place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
* LEDMatrix Notification Widget
|
||||
*
|
||||
* Global notification/toast system for displaying messages to users.
|
||||
* Consolidates all notification functionality into a single widget.
|
||||
* This is the single implementation: the early fallbacks in app-shell.js,
|
||||
* app.js and partials/fonts.html only queue messages until this widget has
|
||||
* loaded (see window.__pendingNotifications) and then delegate to it.
|
||||
*
|
||||
* Usage:
|
||||
* window.showNotification('Message here', 'success');
|
||||
@@ -11,6 +13,16 @@
|
||||
*
|
||||
* Types: success, error, warning, info (default)
|
||||
*
|
||||
* Accessibility:
|
||||
* - Toasts are announced through two persistent, visually hidden live
|
||||
* regions: errors use an assertive region (role="alert"), everything else
|
||||
* a polite one (role="status"). The visible toast is not itself a live
|
||||
* region, so each message is announced exactly once.
|
||||
* - Errors stay on screen for at least ERROR_MIN_DURATION and always have a
|
||||
* dismiss button.
|
||||
* - Auto-dismiss pauses while the pointer is over a toast or focus is in it.
|
||||
* - Slide animations are skipped under prefers-reduced-motion.
|
||||
*
|
||||
* @module NotificationWidget
|
||||
*/
|
||||
|
||||
@@ -26,35 +38,51 @@
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
containerId: 'notifications',
|
||||
politeRegionId: 'notifications-live-polite',
|
||||
assertiveRegionId: 'notifications-live-assertive',
|
||||
defaultDuration: 4000,
|
||||
warningDuration: 6000,
|
||||
errorMinDuration: 10000,
|
||||
fadeOutDuration: 300,
|
||||
maxNotifications: 5,
|
||||
position: 'top-right' // top-right, top-left, bottom-right, bottom-left
|
||||
};
|
||||
|
||||
// Type-specific styling
|
||||
// Type-specific styling. `bg` class names are kept for anyone styling
|
||||
// against them; `color` is applied inline so white text meets 4.5:1
|
||||
// contrast even where the utility classes are missing or lighter.
|
||||
const TYPE_STYLES = {
|
||||
success: {
|
||||
bg: 'bg-green-500',
|
||||
color: '#047857',
|
||||
icon: 'fa-check-circle',
|
||||
label: 'Success'
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-500',
|
||||
color: '#b91c1c',
|
||||
icon: 'fa-exclamation-circle',
|
||||
label: 'Error'
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-500',
|
||||
color: '#b45309',
|
||||
icon: 'fa-exclamation-triangle',
|
||||
label: 'Warning'
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-500',
|
||||
color: '#1d4ed8',
|
||||
icon: 'fa-info-circle',
|
||||
label: 'Info'
|
||||
}
|
||||
};
|
||||
// Lookups go through a Map so an arbitrary type string can't reach
|
||||
// Object.prototype properties.
|
||||
const STYLE_BY_TYPE = new Map(Object.entries(TYPE_STYLES));
|
||||
|
||||
const VISUALLY_HIDDEN = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;' +
|
||||
'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
|
||||
|
||||
// Track active notifications
|
||||
let activeNotifications = [];
|
||||
@@ -62,8 +90,14 @@
|
||||
// keyed by notification id (cleaned up on dismiss). A Map rather than a
|
||||
// plain object so ids can never collide with prototype properties.
|
||||
const actionCallbacks = new Map();
|
||||
// Auto-dismiss timers, keyed by notification id: { timer, remaining, startedAt }
|
||||
const timers = new Map();
|
||||
let notificationCounter = 0;
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the notifications container
|
||||
* @returns {HTMLElement} Container element
|
||||
@@ -75,14 +109,55 @@
|
||||
container = document.createElement('div');
|
||||
container.id = CONFIG.containerId;
|
||||
container.className = 'fixed top-4 right-4 z-50 space-y-2 pointer-events-none';
|
||||
container.setAttribute('aria-live', 'polite');
|
||||
container.setAttribute('aria-label', 'Notifications');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
if (!container.dataset.ledNotifications) {
|
||||
container.dataset.ledNotifications = 'true';
|
||||
// Announcements go through the dedicated live regions below; a
|
||||
// live container would announce every toast a second time.
|
||||
container.removeAttribute('aria-live');
|
||||
container.setAttribute('role', 'region');
|
||||
container.setAttribute('aria-label', 'Notifications');
|
||||
// Let clicks pass through the empty parts of the stack even when
|
||||
// the utility classes are not defined.
|
||||
container.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
getLiveRegion(CONFIG.politeRegionId, 'status', 'polite');
|
||||
getLiveRegion(CONFIG.assertiveRegionId, 'alert', 'assertive');
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function getLiveRegion(id, role, politeness) {
|
||||
let region = document.getElementById(id);
|
||||
if (!region) {
|
||||
region = document.createElement('div');
|
||||
region.id = id;
|
||||
region.setAttribute('role', role);
|
||||
region.setAttribute('aria-live', politeness);
|
||||
region.setAttribute('aria-atomic', 'true');
|
||||
region.style.cssText = VISUALLY_HIDDEN;
|
||||
document.body.appendChild(region);
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce a message to screen readers once.
|
||||
*/
|
||||
function announce(text, assertive) {
|
||||
const region = getLiveRegion(
|
||||
assertive ? CONFIG.assertiveRegionId : CONFIG.politeRegionId,
|
||||
assertive ? 'alert' : 'status',
|
||||
assertive ? 'assertive' : 'polite'
|
||||
);
|
||||
// Clear first so repeating the same message is announced again.
|
||||
region.textContent = '';
|
||||
setTimeout(() => { region.textContent = text; }, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
@@ -94,16 +169,55 @@
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function clearTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (t && t.timer) clearTimeout(t.timer);
|
||||
timers.delete(notificationId);
|
||||
}
|
||||
|
||||
function startTimer(notificationId, ms) {
|
||||
const t = timers.get(notificationId) || {};
|
||||
if (t.timer) clearTimeout(t.timer);
|
||||
t.remaining = ms;
|
||||
t.startedAt = Date.now();
|
||||
t.timer = setTimeout(() => removeNotification(notificationId), ms);
|
||||
timers.set(notificationId, t);
|
||||
}
|
||||
|
||||
function pauseTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (!t || !t.timer) return;
|
||||
clearTimeout(t.timer);
|
||||
t.timer = null;
|
||||
t.remaining = Math.max(0, t.remaining - (Date.now() - t.startedAt));
|
||||
}
|
||||
|
||||
function resumeTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (!t || t.timer) return;
|
||||
// Give the reader a moment after they move away.
|
||||
startTimer(notificationId, Math.max(t.remaining, 2000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notification by ID
|
||||
* @param {string} notificationId - Notification ID
|
||||
* @param {boolean} immediate - Skip fade animation
|
||||
*/
|
||||
function removeNotification(notificationId, immediate = false) {
|
||||
clearTimer(notificationId);
|
||||
const notification = document.getElementById(notificationId);
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
|
||||
if (!notification) return;
|
||||
|
||||
if (immediate) {
|
||||
// If focus is inside the toast, don't strand it on a removed node.
|
||||
const hadFocus = notification.contains(document.activeElement);
|
||||
|
||||
if (immediate || prefersReducedMotion()) {
|
||||
notification.remove();
|
||||
} else {
|
||||
notification.style.transition = `opacity ${CONFIG.fadeOutDuration}ms, transform ${CONFIG.fadeOutDuration}ms`;
|
||||
@@ -115,9 +229,16 @@
|
||||
}, CONFIG.fadeOutDuration);
|
||||
}
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
if (hadFocus) {
|
||||
const next = activeNotifications.length
|
||||
? document.querySelector(`#${activeNotifications[activeNotifications.length - 1]} button`)
|
||||
: null;
|
||||
if (next) {
|
||||
next.focus();
|
||||
} else if (document.activeElement && typeof document.activeElement.blur === 'function') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,15 +252,26 @@
|
||||
if (typeof options === 'string') {
|
||||
options = { type: options };
|
||||
}
|
||||
options = options || {};
|
||||
|
||||
const type = options.type || 'info';
|
||||
const duration = options.duration !== undefined ? options.duration : CONFIG.defaultDuration;
|
||||
const type = STYLE_BY_TYPE.has(options.type) ? options.type : 'info';
|
||||
const isError = type === 'error';
|
||||
let duration = options.duration !== undefined
|
||||
? Number(options.duration)
|
||||
: (isError ? CONFIG.errorMinDuration
|
||||
: type === 'warning' ? CONFIG.warningDuration : CONFIG.defaultDuration);
|
||||
// Errors must stay long enough to read; 0 still means "until dismissed".
|
||||
if (isError && duration > 0 && duration < CONFIG.errorMinDuration) {
|
||||
duration = CONFIG.errorMinDuration;
|
||||
}
|
||||
const showIcon = options.showIcon !== false;
|
||||
const dismissible = options.dismissible !== false;
|
||||
// Errors always get a dismiss button.
|
||||
const dismissible = isError || duration <= 0 || options.dismissible !== false;
|
||||
|
||||
const style = TYPE_STYLES[type] || TYPE_STYLES.info;
|
||||
const style = STYLE_BY_TYPE.get(type);
|
||||
const container = getContainer();
|
||||
const notificationId = `notification_${++notificationCounter}`;
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
|
||||
// Enforce max notifications limit
|
||||
while (activeNotifications.length >= CONFIG.maxNotifications) {
|
||||
@@ -150,18 +282,25 @@
|
||||
const notification = document.createElement('div');
|
||||
notification.id = notificationId;
|
||||
notification.className = `${style.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 pointer-events-auto transform transition-all duration-300 ease-out`;
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.setAttribute('role', 'alert');
|
||||
notification.style.backgroundColor = style.color;
|
||||
notification.style.color = '#ffffff';
|
||||
notification.style.pointerEvents = 'auto';
|
||||
notification.dataset.type = type;
|
||||
if (reduceMotion) {
|
||||
notification.style.transition = 'none';
|
||||
} else {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
}
|
||||
|
||||
// Build content
|
||||
let html = '';
|
||||
|
||||
if (showIcon) {
|
||||
html += `<i class="fas ${style.icon} flex-shrink-0"></i>`;
|
||||
html += `<i class="fas ${style.icon} flex-shrink-0" aria-hidden="true"></i>`;
|
||||
}
|
||||
|
||||
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
|
||||
html += `<span class="flex-1 text-sm"><span style="${VISUALLY_HIDDEN}">${style.label}: </span>${escapeHtml(message)}</span>`;
|
||||
|
||||
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
|
||||
// The callback is stored by id and invoked via triggerAction, which
|
||||
@@ -171,7 +310,8 @@
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
|
||||
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
|
||||
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150"
|
||||
style="background:rgba(255,255,255,.2);color:inherit;border:0;">
|
||||
${escapeHtml(options.actionLabel)}
|
||||
</button>
|
||||
`;
|
||||
@@ -182,8 +322,9 @@
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.get('notification').dismiss('${notificationId}')"
|
||||
class="flex-shrink-0 ml-2 w-5 h-5 flex items-center justify-center rounded-full opacity-70 hover:opacity-100 hover:bg-white hover:bg-opacity-20 transition-all duration-150"
|
||||
aria-label="Dismiss notification">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
style="width:1.75rem;height:1.75rem;display:inline-flex;align-items:center;justify-content:center;background:transparent;color:inherit;border:0;border-radius:9999px;cursor:pointer;"
|
||||
aria-label="Dismiss ${style.label.toLowerCase()} notification">
|
||||
<svg class="w-3 h-3" width="12" height="12" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -194,17 +335,29 @@
|
||||
container.appendChild(notification);
|
||||
activeNotifications.push(notificationId);
|
||||
|
||||
// Trigger animation (need to wait for DOM update)
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
});
|
||||
announce(`${style.label}: ${String(message)}`, isError);
|
||||
|
||||
// Auto-dismiss (0 = no auto-dismiss)
|
||||
if (!reduceMotion) {
|
||||
// Trigger animation (need to wait for DOM update)
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-dismiss (0 = no auto-dismiss), paused while hovered or focused
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(notificationId);
|
||||
}, duration);
|
||||
startTimer(notificationId, duration);
|
||||
notification.addEventListener('mouseenter', () => pauseTimer(notificationId));
|
||||
notification.addEventListener('mouseleave', () => {
|
||||
if (!notification.contains(document.activeElement)) resumeTimer(notificationId);
|
||||
});
|
||||
notification.addEventListener('focusin', () => pauseTimer(notificationId));
|
||||
notification.addEventListener('focusout', (e) => {
|
||||
if (!notification.contains(e.relatedTarget) && !notification.matches(':hover')) {
|
||||
resumeTimer(notificationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
@@ -224,16 +377,17 @@
|
||||
// Register the widget
|
||||
window.LEDMatrixWidgets.register('notification', {
|
||||
name: 'Notification Widget',
|
||||
version: '1.0.0',
|
||||
version: '1.1.0',
|
||||
|
||||
/**
|
||||
* Show a notification
|
||||
* @param {string} message - Message to display
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.type - Notification type: success, error, warning, info
|
||||
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss)
|
||||
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss).
|
||||
* Errors are kept for at least 10s.
|
||||
* @param {boolean} options.showIcon - Show type icon (default: true)
|
||||
* @param {boolean} options.dismissible - Show dismiss button (default: true)
|
||||
* @param {boolean} options.dismissible - Show dismiss button (default: true; always true for errors)
|
||||
* @returns {string} Notification ID
|
||||
*/
|
||||
show: showNotification,
|
||||
@@ -295,15 +449,28 @@
|
||||
// Global shorthand function (backwards compatible with existing code).
|
||||
// Accepts either the legacy type string or a full options object
|
||||
// ({ type, duration, actionLabel, onAction, ... }).
|
||||
// This always replaces the early fallbacks, whatever order they loaded in.
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
};
|
||||
window.showNotification.__ledNotificationWidget = true;
|
||||
|
||||
// Initialize container on load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', getContainer);
|
||||
} else {
|
||||
function flushPending() {
|
||||
getContainer();
|
||||
const pending = window.__pendingNotifications;
|
||||
window.__pendingNotifications = [];
|
||||
if (Array.isArray(pending)) {
|
||||
pending.forEach(args => {
|
||||
try { window.showNotification(args[0], args[1]); } catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize container (and show anything queued before we loaded)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', flushPending);
|
||||
} else {
|
||||
flushPending();
|
||||
}
|
||||
|
||||
console.log('[NotificationWidget] Notification widget registered');
|
||||
|
||||
Reference in New Issue
Block a user