Files
LEDMatrix/web_interface/static/v3/js/widgets/password-input.js
Chuck 7524747e44 Feature/vegas scroll mode (#215)
* feat(display): add Vegas-style continuous scroll mode

Implement an opt-in Vegas ticker mode that composes all enabled plugin
content into a single continuous horizontal scroll. Includes a modular
package (src/vegas_mode/) with double-buffered streaming, 125 FPS
render pipeline using the existing ScrollHelper, live priority
interruption support, and a web UI for configuration with drag-drop
plugin ordering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(vegas): add three-mode display system (SCROLL, FIXED_SEGMENT, STATIC)

Adds a flexible display mode system for Vegas scroll mode that allows
plugins to control how their content appears in the continuous scroll:

- SCROLL: Content scrolls continuously (multi-item plugins like sports)
- FIXED_SEGMENT: Fixed block that scrolls by (clock, weather)
- STATIC: Scroll pauses, plugin displays, then resumes (alerts)

Changes:
- Add VegasDisplayMode enum to base_plugin.py with backward-compatible
  mapping from legacy get_vegas_content_type()
- Add static pause handling to coordinator with scroll position save/restore
- Add mode-aware content composition to stream_manager
- Add vegas_mode info to /api/v3/plugins/installed endpoint
- Add mode indicators to Vegas settings UI
- Add comprehensive plugin developer documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas,widgets): address validation, thread safety, and XSS issues

Vegas mode fixes:
- config.py: align validation limits with UI (scroll_speed max 200, separator_width max 128)
- coordinator.py: fix race condition by properly initializing _pending_config
- plugin_adapter.py: remove unused import
- render_pipeline.py: preserve deque type in reset() method
- stream_manager.py: fix lock handling and swap_buffers to truly swap

API fixes:
- api_v3.py: normalize boolean checkbox values, validate numeric fields, ensure JSON arrays

Widget fixes:
- day-selector.js: remove escapeHtml from JSON.stringify to prevent corruption
- password-input.js: use deterministic color class mapping for Tailwind JIT
- radio-group.js: replace inline onchange with addEventListener to prevent XSS
- select-dropdown.js: guard global registry access
- slider.js: add escapeAttr for attributes, fix null dereference in setValue

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): improve exception handling and static pause state management

coordinator.py:
- _check_live_priority: use logger.exception for full traceback
- _end_static_pause: guard scroll resume on interruption (stop/live priority)
- _update_static_mode_plugins: log errors instead of silently swallowing

render_pipeline.py:
- compose_scroll_content: use specific exceptions and logger.exception
- render_frame: use specific exceptions and logger.exception
- hot_swap_content: use specific exceptions and logger.exception

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): add interrupt mechanism and improve config/exception handling

- Add interrupt checker callback to Vegas coordinator for responsive
  handling of on-demand requests and wifi status during Vegas mode
- Fix config.py update() to include dynamic duration fields
- Fix is_plugin_included() consistency with get_ordered_plugins()
- Update _apply_pending_config to propagate config to StreamManager
- Change _fetch_plugin_content to use logger.exception for traceback
- Replace bare except in _refresh_plugin_list with specific exceptions
- Add aria-label accessibility to Vegas toggle checkbox
- Fix XSS vulnerability in plugin metadata rendering with escapeHtml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): improve logging, validation, lock handling, and config updates

- display_controller.py: use logger.exception for Vegas errors with traceback
- base_plugin.py: validate vegas_panel_count as positive integer with warning
- coordinator.py: fix _apply_pending_config to avoid losing concurrent updates
  by clearing _pending_config while holding lock
- plugin_adapter.py: remove broad catch-all, use narrower exception types
  (AttributeError, TypeError, ValueError, OSError, RuntimeError) and
  logger.exception for traceback preservation
- api_v3.py: only update vegas_config['enabled'] when key is present in data
  to prevent incorrect disabling when checkbox is omitted

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): improve cycle advancement, logging, and accessibility

- Add advance_cycle() method to StreamManager for clearing buffer between cycles
- Call advance_cycle() in RenderPipeline.start_new_cycle() for fresh content
- Use logger.exception() for interrupt check and static pause errors (full tracebacks)
- Add id="vegas_scroll_label" to h3 for aria-labelledby reference
- Call updatePluginConfig() after rendering plugin list for proper initialization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): add thread-safety, preserve updates, and improve logging

- display_controller.py: Use logger.exception() for Vegas import errors
- plugin_adapter.py: Add thread-safe cache lock, remove unused exception binding
- stream_manager.py: In-place merge in process_updates() preserves non-updated plugins
- api_v3.py: Change vegas_scroll_enabled default from False to True

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): add debug logging and narrow exception types

- stream_manager.py: Log when get_vegas_display_mode() is unavailable
- stream_manager.py: Narrow exception type from Exception to (AttributeError, TypeError)
- api_v3.py: Log exceptions when reading Vegas display metadata with plugin context

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): fix method call and improve exception logging

- Fix _check_vegas_interrupt() calling nonexistent _check_wifi_status(),
  now correctly calls _check_wifi_status_message()
- Update _refresh_plugin_list() exception handler to use logger.exception()
  with plugin_id and class name for remote debugging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(web): replace complex toggle with standard checkbox for Vegas mode

The Tailwind pseudo-element toggle (after:content-[''], etc.) wasn't
rendering because these classes weren't in the CSS bundle. Replaced
with a simple checkbox that matches other form controls in the template.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* debug(vegas): add detailed logging to _refresh_plugin_list

Track why plugins aren't being found for Vegas scroll:
- Log count of loaded plugins
- Log enabled status for each plugin
- Log content_type and display_mode checks
- Log when plugin_manager lacks loaded_plugins

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): use correct attribute name for plugin manager

StreamManager and VegasModeCoordinator were checking for
plugin_manager.loaded_plugins but PluginManager stores active
plugins in plugin_manager.plugins. This caused Vegas scroll
to find zero plugins despite plugins being available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): convert scroll_speed from px/sec to px/frame correctly

The config scroll_speed is in pixels per second, but ScrollHelper
in frame_based_scrolling mode interprets it as pixels per frame.
Previously this caused the speed to be clamped to max 5.0 regardless
of the configured value.

Now properly converts: pixels_per_frame = scroll_speed * scroll_delay

With defaults (50 px/s, 0.02s delay), this gives 1 px/frame = 50 px/s.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(vegas): add FPS logging every 5 seconds

Logs actual FPS vs target FPS to help diagnose performance issues.
Shows frame count in each 5-second interval.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): improve plugin content capture reliability

- Call update_data() before capture to ensure fresh plugin data
- Try display() without force_clear first, fallback if TypeError
- Retry capture with force_clear=True if first attempt is blank
- Use histogram-based blank detection instead of point sampling
  (more reliable for content positioned anywhere in frame)

This should help capture content from plugins that don't implement
get_vegas_content() natively.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): handle callable width/height on display_manager

DisplayManager.width and .height may be methods or properties depending
on the implementation. Use callable() check to call them if needed,
ensuring display_width and display_height are always integers.

Fixes potential TypeError when width/height are methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): use logger.exception for display mode errors

Replace logger.error with logger.exception to capture full stack trace
when get_vegas_display_mode() fails on a plugin.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): protect plugin list updates with buffer lock

Move assignment of _ordered_plugins and index resets under _buffer_lock
to prevent race conditions with _prefetch_content() which reads these
variables under the same lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): catch all exceptions in get_vegas_display_mode

Broaden exception handling from AttributeError/TypeError to Exception
so any plugin error in get_vegas_display_mode() doesn't abort the
entire plugin list refresh. The loop continues with the default
FIXED_SEGMENT mode.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(vegas): refresh stream manager when config updates

After updating stream_manager.config, force a refresh to pick up changes
to plugin_order, excluded_plugins, and buffer_ahead settings. Also use
logger.exception to capture full stack traces on config update errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* debug(vegas): add detailed logging for blank image detection

* feat(vegas): extract full scroll content from plugins using ScrollHelper

Plugins like ledmatrix-stocks and odds-ticker use ScrollHelper with a
cached_image that contains their full scrolling content. Instead of
falling back to single-frame capture, now check for scroll_helper.cached_image
first to get the complete scrolling content for Vegas mode.

* debug(vegas): add comprehensive INFO-level logging for plugin content flow

- Log each plugin being processed with class name
- Log which content methods are tried (native, scroll_helper, fallback)
- Log success/failure of each method with image dimensions
- Log brightness check results for blank image detection
- Add visual separators in logs for easier debugging
- Log plugin list refresh with enabled/excluded status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(vegas): trigger scroll content generation when cache is empty

When a plugin has a scroll_helper but its cached_image is not yet
populated, try to trigger content generation by:
1. Calling _create_scrolling_display() if available (stocks pattern)
2. Calling display(force_clear=True) as a fallback

This allows plugins like stocks to provide their full scroll content
even when Vegas mode starts before the plugin has run its normal
display cycle.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: improve exception handling in plugin_adapter scroll content retrieval

Replace broad except Exception handlers with narrow exception types
(AttributeError, TypeError, ValueError, OSError) and use logger.exception
instead of logger.warning/info to capture full stack traces for better
diagnosability.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: narrow exception handling in coordinator and plugin_adapter

- coordinator.py: Replace broad Exception catch around get_vegas_display_mode()
  with (AttributeError, TypeError) and use logger.exception for stack traces
- plugin_adapter.py: Narrow update_data() exception handler to
  (AttributeError, RuntimeError, OSError) and use logger.exception

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: improve Vegas mode robustness and API validation

- display_controller: Guard against None plugin_manager in Vegas init
- coordinator: Restore scrolling state in resume() to match pause()
- api_v3: Validate Vegas numeric fields with range checks and 400 errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:23:56 -05:00

318 lines
13 KiB
JavaScript

/**
* LEDMatrix Password Input Widget
*
* Password input with show/hide toggle and strength indicator.
*
* Schema example:
* {
* "password": {
* "type": "string",
* "x-widget": "password-input",
* "x-options": {
* "placeholder": "Enter password",
* "showToggle": true,
* "showStrength": false,
* "minLength": 8,
* "requireUppercase": false,
* "requireNumber": false,
* "requireSpecial": false
* }
* }
* }
*
* @module PasswordInputWidget
*/
(function() {
'use strict';
const base = window.BaseWidget ? new window.BaseWidget('PasswordInput', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
function triggerChange(fieldId, value) {
if (base) {
base.triggerChange(fieldId, value);
} else {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
}
}
// Deterministic color class mapping to avoid Tailwind JIT purging
const STRENGTH_COLORS = {
gray: 'bg-gray-300',
red: 'bg-red-500',
orange: 'bg-orange-500',
yellow: 'bg-yellow-500',
lime: 'bg-lime-500',
green: 'bg-green-500'
};
function calculateStrength(password, options) {
if (!password) return { score: 0, label: '', color: 'gray' };
let score = 0;
const minLength = options.minLength || 8;
// Length check
if (password.length >= minLength) score += 1;
if (password.length >= minLength + 4) score += 1;
if (password.length >= minLength + 8) score += 1;
// Character variety
if (/[a-z]/.test(password)) score += 1;
if (/[A-Z]/.test(password)) score += 1;
if (/[0-9]/.test(password)) score += 1;
if (/[^a-zA-Z0-9]/.test(password)) score += 1;
// Normalize to 0-4 scale
const normalizedScore = Math.min(4, Math.floor(score / 2));
const levels = [
{ label: 'Very Weak', color: 'red' },
{ label: 'Weak', color: 'orange' },
{ label: 'Fair', color: 'yellow' },
{ label: 'Good', color: 'lime' },
{ label: 'Strong', color: 'green' }
];
return {
score: normalizedScore,
...levels[normalizedScore]
};
}
window.LEDMatrixWidgets.register('password-input', {
name: 'Password Input Widget',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = sanitizeId(options.fieldId || container.id || 'password_input');
const xOptions = config['x-options'] || config['x_options'] || {};
const placeholder = xOptions.placeholder || 'Enter password';
const showToggle = xOptions.showToggle !== false;
const showStrength = xOptions.showStrength === true;
// Validate and sanitize minLength as a non-negative integer
const rawMinLength = xOptions.minLength !== undefined ? parseInt(xOptions.minLength, 10) : 8;
const sanitizedMinLength = (Number.isFinite(rawMinLength) && Number.isInteger(rawMinLength) && rawMinLength >= 0) ? rawMinLength : 8;
const requireUppercase = xOptions.requireUppercase === true;
const requireNumber = xOptions.requireNumber === true;
const requireSpecial = xOptions.requireSpecial === true;
const disabled = xOptions.disabled === true;
const required = xOptions.required === true;
const currentValue = value || '';
let html = `<div id="${fieldId}_widget" class="password-input-widget" data-field-id="${fieldId}" data-min-length="${sanitizedMinLength}" data-require-uppercase="${requireUppercase}" data-require-number="${requireNumber}" data-require-special="${requireSpecial}">`;
html += '<div class="relative">';
html += `
<input type="password"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
placeholder="${escapeHtml(placeholder)}"
${sanitizedMinLength > 0 ? `minlength="${sanitizedMinLength}"` : ''}
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('password-input').onChange('${fieldId}')"
oninput="window.LEDMatrixWidgets.getHandlers('password-input').onInput('${fieldId}')"
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 pr-10 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black placeholder:text-gray-400">
`;
if (showToggle && !disabled) {
html += `
<button type="button"
id="${fieldId}_toggle"
onclick="window.LEDMatrixWidgets.getHandlers('password-input').onToggle('${fieldId}')"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
title="Show/hide password">
<i id="${fieldId}_icon" class="fas fa-eye"></i>
</button>
`;
}
html += '</div>';
// Strength indicator
if (showStrength) {
const strength = calculateStrength(currentValue, xOptions);
const colorClass = STRENGTH_COLORS[strength.color] || STRENGTH_COLORS.gray;
html += `
<div id="${fieldId}_strength" class="mt-2 ${currentValue ? '' : 'hidden'}">
<div class="flex gap-1 mb-1">
<div class="h-1 flex-1 rounded bg-gray-200">
<div id="${fieldId}_bar0" class="h-full rounded ${strength.score >= 1 ? colorClass : ''}" style="width: ${strength.score >= 1 ? '100%' : '0'}"></div>
</div>
<div class="h-1 flex-1 rounded bg-gray-200">
<div id="${fieldId}_bar1" class="h-full rounded ${strength.score >= 2 ? colorClass : ''}" style="width: ${strength.score >= 2 ? '100%' : '0'}"></div>
</div>
<div class="h-1 flex-1 rounded bg-gray-200">
<div id="${fieldId}_bar2" class="h-full rounded ${strength.score >= 3 ? colorClass : ''}" style="width: ${strength.score >= 3 ? '100%' : '0'}"></div>
</div>
<div class="h-1 flex-1 rounded bg-gray-200">
<div id="${fieldId}_bar3" class="h-full rounded ${strength.score >= 4 ? colorClass : ''}" style="width: ${strength.score >= 4 ? '100%' : '0'}"></div>
</div>
</div>
<span id="${fieldId}_strength_label" class="text-xs text-gray-500">${strength.label}</span>
</div>
`;
}
// Error message area
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
html += '</div>';
container.innerHTML = html;
},
getValue: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
return input ? input.value : '';
},
setValue: function(fieldId, value) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
if (input) {
input.value = value || '';
this.handlers.onInput(fieldId);
}
},
validate: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
const errorEl = document.getElementById(`${safeId}_error`);
const widget = document.getElementById(`${safeId}_widget`);
if (!input) return { valid: true, errors: [] };
const errors = [];
let isValid = input.checkValidity();
if (!isValid) {
errors.push(input.validationMessage);
} else if (input.value && widget) {
// Check custom validation requirements
const requireUppercase = widget.dataset.requireUppercase === 'true';
const requireNumber = widget.dataset.requireNumber === 'true';
const requireSpecial = widget.dataset.requireSpecial === 'true';
if (requireUppercase && !/[A-Z]/.test(input.value)) {
isValid = false;
errors.push('Password must contain at least one uppercase letter');
}
if (requireNumber && !/[0-9]/.test(input.value)) {
isValid = false;
errors.push('Password must contain at least one number');
}
if (requireSpecial && !/[^a-zA-Z0-9]/.test(input.value)) {
isValid = false;
errors.push('Password must contain at least one special character');
}
}
if (errorEl) {
if (!isValid && errors.length > 0) {
errorEl.textContent = errors[0];
errorEl.classList.remove('hidden');
input.classList.add('border-red-500');
} else {
errorEl.classList.add('hidden');
input.classList.remove('border-red-500');
}
}
return { valid: isValid, errors };
},
handlers: {
onChange: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('password-input');
widget.validate(fieldId);
triggerChange(fieldId, widget.getValue(fieldId));
},
onInput: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
const strengthEl = document.getElementById(`${safeId}_strength`);
const strengthLabel = document.getElementById(`${safeId}_strength_label`);
const widget = document.getElementById(`${safeId}_widget`);
if (strengthEl && input) {
const value = input.value;
const minLength = parseInt(widget?.dataset.minLength || '8', 10);
if (value) {
strengthEl.classList.remove('hidden');
const strength = calculateStrength(value, { minLength });
// Update bars using shared color mapping
const colorClass = STRENGTH_COLORS[strength.color] || STRENGTH_COLORS.gray;
for (let i = 0; i < 4; i++) {
const bar = document.getElementById(`${safeId}_bar${i}`);
if (bar) {
// Remove all color classes
bar.className = 'h-full rounded';
if (i < strength.score) {
bar.classList.add(colorClass);
bar.style.width = '100%';
} else {
bar.style.width = '0';
}
}
}
if (strengthLabel) {
strengthLabel.textContent = strength.label;
}
} else {
strengthEl.classList.add('hidden');
}
}
},
onToggle: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
const icon = document.getElementById(`${safeId}_icon`);
if (input && icon) {
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
} else {
input.type = 'password';
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
}
}
}
}
});
console.log('[PasswordInputWidget] Password input widget registered');
})();