Files
LEDMatrix/web_interface/static/v3/js/htmx-config.js
T
ChuckBuildsandClaude Sonnet 5 8044084280 fix(web): address CodeRabbit review — validation, a11y, perf, and privacy fixes
Verified each finding against current code. Fixed:

- api_v3: plugin_rotation_order is now strictly validated (JSON list of
  strings, 400 with a descriptive message otherwise) and popped from the
  payload before any further handling.
- display_controller: _apply_plugin_rotation_order defensively ignores a
  non-list value (keeps the existing rotation, logs a warning) and drops
  non-string entries; new logs carry the [DisplayController] prefix.
  Unit-tested both defensive paths.
- app.py: snapshot-read handler narrowed to OSError with debug logging;
  flask-compress ImportError now emits one structured warning with the
  install remedy.
- htmx-config: the response-error logger prints form FIELD NAMES only -
  values (API keys, passwords) never reach the console.
- plugin-order-list: saved order/exclusions normalized with Array.isArray
  (a saved "null" previously crashed .forEach); each row gained
  keyboard/touch-accessible move-up/move-down buttons (HTML5 drag events
  don't fire on most mobile browsers) that reorder and syncInputs()
  immediately alongside native drag.
- app-shell: window.installedPlugins setter always takes the new list
  (same-ID metadata/enabled updates were silently dropped); tab rebuild
  stays gated on ID changes. LED dot renderer reads the frame with ONE
  getImageData call instead of one per pixel (~9,200/frame at 192x48).
- plugins_manager: togglePlugin returns its request promise resolving the
  API outcome; the install flow now shows the "installed and enabled"
  toast (with Restart Now) only after enablement succeeds, and a warning
  without a restart offer when it fails.
- a11y: hamburger aria-label flips Open/Close with drawer state; both
  Advanced-section toggle buttons declare aria-controls/aria-expanded and
  the shared toggleSection() keeps aria-expanded in sync; move buttons
  have per-plugin aria-labels.
- Rotation/Vegas order-list bootstraps cap their retries (~5s) and show a
  reload hint instead of spinning forever; Alpine app-state lookups prefer
  [x-data="app()"] with a generic fallback.

Skipped, with reasons:
- executePluginAction arg order: caller (plugin_config.html) already
  passes (actionId, index, pluginId) matching the signature exactly.
- generateFieldHtml XSS, entity-unescape blocks, dotToNested pollution,
  and "app.loadInstalledPlugins" in app-shell: all inside the legacy
  client-side config cluster whose entry points are shadowed by
  plugins_manager.js / replaced by server-rendered forms (zero live
  callers, verified) - queued for wholesale deletion in the follow-up
  rather than patching dead code.
- custom-feeds-helpers.js findings (3): file was deleted in a prior commit.
- console.error/warn override removal and afterSwap script re-execution
  removal: deliberate pre-existing workarounds every partial's inline
  init currently depends on; reworking them safely needs isolated testing
  (follow-up), and the error suppression is already double-gated
  (insertBefore AND htmx match).
- "move durations bootstrap into a bundle": inline partial-scoped init is
  the established pattern for HTMX partials in this codebase.

Validation: all 40 web tests pass; py_compile on all touched Python; all
touched templates parse; rotation-order defensive paths unit-tested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 14:17:44 -04:00

257 lines
15 KiB
JavaScript

/* global debugLog */
// HTMX swap/script-execution configuration and section toggle helpers
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
// Configure HTMX to evaluate scripts in swapped content and fix insertBefore errors
(function() {
function setupScriptExecution() {
if (document.body) {
// Fix HTMX insertBefore errors by validating targets before swap
document.body.addEventListener('htmx:beforeSwap', function(event) {
try {
const target = event.detail.target;
if (!target) {
console.warn('[HTMX] Target is null, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Check if target is a valid DOM element
if (!(target instanceof Element)) {
console.warn('[HTMX] Target is not a valid Element, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Check if target has a parent node (required for insertBefore)
if (!target.parentNode) {
console.warn('[HTMX] Target has no parent node, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Ensure target is in the DOM
if (!document.body.contains(target) && !document.head.contains(target)) {
console.warn('[HTMX] Target is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Additional check: ensure parent is also in DOM
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
console.warn('[HTMX] Target parent is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// All checks passed, allow swap
return true;
} catch (e) {
// If validation fails, cancel swap
console.warn('[HTMX] Error validating target:', e);
event.detail.shouldSwap = false;
return false;
}
});
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
const originalError = console.error;
const originalWarn = console.warn;
console.error = function(...args) {
const errorStr = args.join(' ');
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
// Suppress HTMX insertBefore errors (comprehensive check)
// These occur when HTMX tries to swap content but the target element is null
// Usually happens due to timing/race conditions and is harmless
if (errorStr.includes("insertBefore") ||
errorStr.includes("Cannot read properties of null") ||
errorStr.includes("reading 'insertBefore'")) {
// Check if it's from HTMX by looking at stack trace or error string
// Also check the call stack if available
const isHtmxError = errorStr.includes('htmx.org') ||
errorStr.includes('htmx') ||
errorStack.includes('htmx') ||
args.some(arg => {
if (typeof arg === 'string') {
return arg.includes('htmx.org') || arg.includes('htmx');
}
// Check error objects for stack traces
if (arg && typeof arg === 'object' && arg.stack) {
return arg.stack.includes('htmx');
}
return false;
});
if (isHtmxError) {
return; // Suppress - this is a harmless HTMX timing/race condition issue
}
}
// Suppress script execution errors from malformed HTML
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
if (errorStr.includes('Unexpected token')) {
return; // Suppress malformed HTML errors
}
}
originalError.apply(console, args);
};
console.warn = function(...args) {
const warnStr = args.join(' ');
// Suppress Permissions-Policy warnings (harmless browser warnings)
if (warnStr.includes('Permissions-Policy header') ||
warnStr.includes('Unrecognized feature') ||
warnStr.includes('Origin trial controlled feature') ||
warnStr.includes('browsing-topics') ||
warnStr.includes('run-ad-auction') ||
warnStr.includes('join-ad-interest-group') ||
warnStr.includes('private-state-token') ||
warnStr.includes('private-aggregation') ||
warnStr.includes('attribution-reporting')) {
return; // Suppress - these are harmless browser feature warnings
}
originalWarn.apply(console, args);
};
// Handle HTMX errors gracefully with detailed logging
document.body.addEventListener('htmx:responseError', function(event) {
const detail = event.detail;
const xhr = detail.xhr;
const target = detail.target;
// Enhanced error logging
console.error('HTMX response error:', {
status: xhr?.status,
statusText: xhr?.statusText,
url: xhr?.responseURL,
target: target?.id || target?.tagName,
responseText: xhr?.responseText
});
// For form submissions, log field names only — values
// may contain API keys, passwords, or other secrets
// that must never reach the console.
if (target && target.tagName === 'FORM') {
const formData = new FormData(target);
const fieldNames = [];
for (const [key] of formData.entries()) {
fieldNames.push(key);
}
console.error('Form fields (values redacted):', fieldNames);
// Try to parse error response for validation details
if (xhr?.responseText) {
try {
const errorData = JSON.parse(xhr.responseText);
console.error('Error details:', {
message: errorData.message,
details: errorData.details,
validation_errors: errorData.validation_errors,
context: errorData.context
});
} catch {
console.error('Error response (non-JSON):', xhr.responseText.substring(0, 500));
}
}
}
});
document.body.addEventListener('htmx:swapError', function(event) {
// Log but don't break the app
console.warn('HTMX swap error:', event.detail);
});
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail && event.detail.target) {
try {
const scripts = event.detail.target.querySelectorAll('script');
scripts.forEach(function(oldScript) {
try {
if (oldScript.textContent.trim() || oldScript.src) {
const newScript = document.createElement('script');
if (oldScript.src) newScript.src = oldScript.src;
if (oldScript.type) newScript.type = oldScript.type;
if (oldScript.textContent) newScript.textContent = oldScript.textContent;
if (oldScript.parentNode) {
oldScript.parentNode.insertBefore(newScript, oldScript);
oldScript.parentNode.removeChild(oldScript);
} else {
// If no parent, append to head or body
(document.head || document.body).appendChild(newScript);
}
}
} catch (e) {
// Silently ignore script execution errors
}
});
} catch (e) {
// Silently ignore errors in script processing
}
}
});
// Mark tab containers as loaded once their content settles, so switching
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
// containers only) so modals and plugin config panels can still reload.
document.body.addEventListener('htmx:afterSettle', function(event) {
if (event.detail && event.detail.target) {
const target = event.detail.target;
const trigger = target.getAttribute('hx-trigger') || '';
if (trigger.includes('loadtab')) {
target.setAttribute('data-loaded', 'true');
}
}
});
} else {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupScriptExecution);
} else {
setTimeout(setupScriptExecution, 100);
}
}
}
setupScriptExecution();
// Section toggle function - define early so it's available for HTMX-loaded content
window.toggleSection = function(sectionId) {
const section = document.getElementById(sectionId);
const icon = document.getElementById(sectionId + '-icon');
if (!section) {
console.warn('toggleSection: Could not find section for', sectionId);
return;
}
if (!icon) {
console.warn('toggleSection: Could not find icon for', sectionId);
return;
}
// Check if currently hidden by checking both class and computed display
const hasHiddenClass = section.classList.contains('hidden');
const computedDisplay = window.getComputedStyle(section).display;
const isHidden = hasHiddenClass || computedDisplay === 'none';
if (isHidden) {
// Show the section - remove hidden class and explicitly set display to block
section.classList.remove('hidden');
section.style.display = 'block';
icon.classList.remove('fa-chevron-right');
icon.classList.add('fa-chevron-down');
} else {
// Hide the section - add hidden class and set display to none
section.classList.add('hidden');
section.style.display = 'none';
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-right');
}
// Keep assistive tech in sync: any toggle button that declares
// aria-controls for this section mirrors the expanded state.
const controlBtn = document.querySelector(`[aria-controls="${sectionId}"]`);
if (controlBtn) {
controlBtn.setAttribute('aria-expanded', String(isHidden));
}
};
})();