Files
LEDMatrix/web_interface/static/v3/js/widgets
Chuck 713539e491 fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning (#346)
* fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning

- base.html: add htmx:afterSettle listener to set data-loaded on tab
  containers after HTMX swaps their content, preventing the overview
  partial from being re-fetched (and handlers lost) on every tab switch
- base.html: call htmx.process() in loadOverviewDirect/loadPluginsDirect
  fallbacks so buttons get HTMX handlers even if HTMX finished its
  initial body scan before the fallback fetch completed
- overview.html + index.html (11 buttons): replace event.detail.xhr.responseJSON
  (undefined in HTMX 1.9.x) with JSON.parse(event.detail.xhr.responseText)
  so quick action toast notifications actually fire
- plugins_manager.js: add guarded htmx:afterSettle listener that only calls
  attachInstallButtonHandler when #install-plugin-from-url is in the DOM,
  eliminating the spurious console warning on non-plugin tab loads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): ensure quick-action toasts always fire even on xhr/parse failure

Replace silent catch(e){} in all 11 hx-on:htmx:after-request handlers with a
pattern that sets default message/status before the try block and calls
showNotification(m,s) unconditionally after it, so a fallback toast is shown
whenever xhr is absent or responseText is not valid JSON.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): show error toast on non-JSON 4xx/5xx quick-action responses

In the catch block of all 11 hx-on:htmx:after-request handlers, check
xhr.status >= 400 and downgrade s to 'error' so a failed action that
returns an HTML error page (or other non-JSON body) surfaces as an error
toast instead of the optimistic 'success'/'info' default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): guard setTimeout fallback for attachInstallButtonHandler

The 500ms fallback setTimeout was calling attachInstallButtonHandler()
unconditionally even when the plugins partial wasn't in the DOM, causing
a spurious console.warn on every page load. Add the same element-existence
check already present on the htmx:afterSettle listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix backup API 404s, hardware status 500, and HTMX loading race

- Add all backup API routes to api_v3.py: preview, list, export,
  validate, restore (with plugin reinstall), download, delete
- Fix PermissionError on /hardware/status: return graceful 200 instead
  of 500 when the status file is owned by a different user; also fix
  root cause by writing the file world-readable (0o644) in display_manager
- Fix HTMX race: dispatch htmx:ready window event from HTMX onload
  callback; loadTabContent now waits for that event instead of
  immediately falling back to direct fetch (eliminating the
  "HTMX not available" console warning on initial load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Cancel HTMX fallback timers when htmx:ready fires

The 5-second setTimeout fallbacks for plugins and overview were firing
before the htmx:ready event arrived, logging spurious warnings. Each
timer now self-cancels via htmx:ready so the fallback only triggers
when HTMX genuinely fails to load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address review feedback: error leaks, ok:false, htmx:ready coverage

- Backup endpoints: replace raw str(e) in user-facing responses with a
  generic message; full exception still logged via exc_info=True
- hardware/status: change ok:null to ok:false for PermissionError and
  json.JSONDecodeError so the UI's hw.ok===false check triggers correctly
- base.html: dispatch htmx:ready from the fallback load path so any
  deferred listeners fire on CDN-fallback loads too
- loadTabContent: also listen for htmx-load-failed so overview/wifi/plugins
  fall back to direct fetch when HTMX is completely unavailable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Treat system-managed pip packages as satisfied for dependency marker

When a plugin's requirements.txt includes a package installed via the
system package manager (dnf/apt), pip fails with 'uninstall-no-record-file'
because it can't replace the system-tracked copy. The package is present
and functional, but the missing marker caused the install to be retried
on every service restart.

Detect this specific error pattern: if the only pip failure is
uninstall-no-record-file, write the .dependencies_installed marker and
log a warning instead of returning False, suppressing the repeated warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix uninstall-no-record-file detection condition

The previous check used a string replacement that left 'error:' in the
remaining text, causing the condition to always evaluate false. Simplify
to a direct substring check: if 'uninstall-no-record-file' appears in pip
stderr the affected package is installed at the system level and we write
the marker, suppressing the repeated warning on every restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve CodeQL security findings in backup API

Path traversal (CWE-22):
- backup_download: switch from send_file(user-tainted-path) to
  send_from_directory(_BACKUP_EXPORT_DIR, filename); Flask uses
  werkzeug safe_join internally which CodeQL recognises as a sanitizer
- backup_delete: enumerate the export directory and match by name so
  entry.unlink() operates on a filesystem-derived Path rather than one
  constructed from user input; _safe_backup_path still guards first

Information exposure through exceptions (CWE-209):
- backup_validate: err_msg from validate_backup() can embed exception
  strings containing temp-file paths; log the detail, return a generic
  'Invalid or corrupted backup file' to the client
- Other backup endpoints: already fixed (str(e) -> generic message);
  CodeQL alerts will clear on next scan

plugin_loader.py:185 (path traversal): false positive — requirements_file
is constructed from plugin_dir returned by find_plugin_directory() (a
filesystem scan), not from raw HTTP request input; no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix pre-existing information exposure in version and action endpoints

- get_system_version (alert #218): replaced str(e) with generic message;
  exception still logged via logger.error(exc_info=True)
- execute_system_action (alert #216): removed str(e) and full
  traceback.format_exc() from the HTTP response — the full stack trace
  was being sent directly to clients; replaced with generic message and
  proper logger.error call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix remaining GitHub CodeQL security alerts

- py/stack-trace-exposure: Remove str(e) and traceback.format_exc() from
  all HTTP responses across api_v3.py, pages_v3.py, and app.py; replace
  with generic messages and logger.error(exc_info=True)
- py/reflective-xss: Escape partial_name via markupsafe.escape in the
  load_partial 404 response
- py/path-injection: Add regex validation of plugin_id before filesystem
  use in _load_plugin_config_partial
- py/incomplete-url-substring-sanitization: Replace 'github.com' in
  substring checks with urlparse hostname comparison in store_manager.py
- py/clear-text-logging-sensitive-data: Remove football-scoreboard debug
  prints and sensitive request-body prints from update endpoint
- js/bad-tag-filter: Replace script-only regex in BaseWidget.sanitizeValue
  with DOM-based textContent stripping that removes all HTML
- js/incomplete-sanitization: Fix escapeAttr to properly encode &, ", ',
  <, > using HTML entities instead of backslash escaping
- js/prototype-pollution-utility: Add __proto__/constructor/prototype
  key guards to deepMerge function in plugins_manager.js
- app.py error handlers: Always return generic messages; remove debug-mode
  branches that could expose tracebacks in production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix three remaining CodeQL path-injection and info-exposure alerts

- plugin_loader.py: resolve plugin_dir with strict=True and validate
  marker_path with relative_to() before any filesystem writes, giving
  CodeQL the positive sanitization pattern it requires (py/path-injection)
- api_v3.py _safe_backup_path: replace substring negative checks with a
  strict positive regex (^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$) that
  CodeQL recognises as sanitising the user-supplied filename
  (py/path-injection)
- api_v3.py backup_validate: whitelist known-safe manifest fields before
  returning JSON, preventing any exception strings captured inside
  validate_backup() from reaching the HTTP response (py/stack-trace-exposure)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve 29 open CodeQL security alerts across 5 files

py/flask-debug (#214):
- debug_web_manual.py: read debug mode from LEDMATRIX_FLASK_DEBUG env var
  instead of hardcoded True

py/stack-trace-exposure (#216, #218):
- api_v3.py execute_system_action: remove subprocess stdout/stderr from
  HTTP responses; log via logger instead
- api_v3.py get_git_version: validate output matches safe ref format
  (^[a-zA-Z0-9._-]+$) before including in response
- api_v3.py: remove all remaining traceback.format_exc() dead variables
  and print() debug calls (replaced with logger.debug/warning)

py/reflective-xss (#207, #208, #209, #210, #211, #212):
- api_v3.py: remove plugin_id from all error/success response messages
  (uninstall, install, update, health, not-found responses)
- pages_v3.py load_partial: return static "Partial not found" message
  instead of echoing partial_name
- pages_v3.py _load_starlark_config_partial: add app_id regex validation,
  use static error messages instead of f-strings with app_id

py/path-injection (#187–#206):
- pages_v3.py _load_plugin_config_partial: resolve plugins_base and
  validate _plugin_dir with relative_to() before all file operations;
  same for assets metadata directory
- pages_v3.py _load_starlark_config_partial: resolve starlark_base and
  validate schema_file/config_file paths with relative_to()
- plugin_loader.py _find_plugin_directory: resolve plugins_dir and
  validate strategy-2 candidates with relative_to()
- plugin_loader.py install_dependencies: resolve plugin_dir first, then
  construct requirements_file and marker_path from resolved base
- plugin_loader.py load_module: resolve plugin_dir with strict=True and
  validate entry_file with relative_to() before exec_module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix 15 remaining CodeQL path-injection and stack-trace-exposure alerts

Switch from resolve()+relative_to() to os.path.basename() reassignment,
which CodeQL recognizes as a path sanitizer that breaks the taint chain.
Also remove exception objects from backup_manager validate_backup return
strings to eliminate the stack-trace-exposure taint source.

Fixes alerts #227, #233, #234, #235, #237, #238, #239, #240, #241,
#242, #243, #244, #245, #246, #247.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix broken logger format string and leaked exception in config save error

- pages_v3.py: plain string was used instead of %-style substitution,
  so every manifest-read failure logged the literal "{plugin_id}"
- api_v3.py save_main_config: exception message was still leaking
  through the error response; replace with generic message (consistent
  with the rest of the CodeQL sweep in this PR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 09:29:53 -04:00
..
2026-01-29 10:23:56 -05:00

LEDMatrix Widget Development Guide

Overview

The LEDMatrix Widget Registry system allows plugins to use reusable UI components (widgets) for configuration forms. This system enables:

  • Reusable Components: Use existing widgets (file upload, checkboxes, etc.) without custom code
  • Custom Widgets: Create plugin-specific widgets without modifying the LEDMatrix codebase
  • Backwards Compatibility: Existing plugins continue to work without changes

Available Core Widgets

1. File Upload Widget (file-upload)

Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.

Schema Configuration:

{
  "type": "array",
  "x-widget": "file-upload",
  "x-upload-config": {
    "plugin_id": "my-plugin",
    "max_files": 10,
    "max_size_mb": 5,
    "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"]
  }
}

Features:

  • Drag and drop file upload
  • Image preview with thumbnails
  • Delete functionality
  • Schedule images to show at specific times
  • Progress indicators during upload

2. Checkbox Group Widget (checkbox-group)

Multi-select checkboxes for array fields with enum items.

Schema Configuration:

{
  "type": "array",
  "x-widget": "checkbox-group",
  "items": {
    "type": "string",
    "enum": ["option1", "option2", "option3"]
  },
  "x-options": {
    "labels": {
      "option1": "Option 1 Label",
      "option2": "Option 2 Label"
    }
  }
}

Features:

  • Multiple selection from enum list
  • Custom labels for each option
  • Automatic JSON array serialization

3. Custom Feeds Widget (custom-feeds)

Table-based RSS feed editor with logo uploads.

Schema Configuration:

{
  "type": "array",
  "x-widget": "custom-feeds",
  "items": {
    "type": "object",
    "properties": {
      "name": { "type": "string" },
      "url": { "type": "string", "format": "uri" },
      "enabled": { "type": "boolean" },
      "logo": { "type": "object" }
    }
  },
  "maxItems": 50
}

Features:

  • Add/remove feed rows
  • Logo upload per feed
  • Enable/disable individual feeds
  • Automatic row re-indexing

Other Built-in Widgets

In addition to the three documented above, these widgets are registered and ready to use via x-widget:

Inputs:

  • text-input — Plain text field with optional length constraints
  • textarea — Multi-line text input
  • number-input — Numeric input with min/max validation
  • email-input — Email field with format validation
  • url-input — URL field with format validation
  • password-input — Password field with show/hide toggle

Selectors:

  • select-dropdown — Single-select dropdown for enum fields
  • radio-group — Radio buttons for enum fields (alternative to dropdown)
  • toggle-switch — Boolean toggle (alternative to a checkbox)
  • slider — Numeric range slider for integer/number with min/max
  • color-picker — RGB color picker; outputs [r, g, b] arrays
  • font-selector — Picks from fonts in assets/fonts/ (TTF + BDF)
  • timezone-selector — IANA timezone picker

Date / time / scheduling:

  • date-picker — Single date input
  • day-selector — Days-of-week multi-select (MonSun checkboxes)
  • time-range — Start/end time pair (e.g. for dim schedules)
  • schedule-picker — Full cron-style or weekday/time schedule editor

Composite / data-source:

  • array-table — Generic table editor for arrays of objects
  • google-calendar-picker — Picks from the user's authenticated Google Calendars (used by the calendar plugin)

Internal (typically not used directly by plugins):

  • notification — Toast notification helper
  • base-widget — Base class other widgets extend

The canonical source for each widget's exact schema and options is the file in this directory (e.g., slider.js, color-picker.js). If you need a feature one of these doesn't support, see "Creating Custom Widgets" below.

Using Existing Widgets

To use an existing widget in your plugin's config_schema.json, simply add the x-widget property to your field definition:

{
  "properties": {
    "my_images": {
      "type": "array",
      "x-widget": "file-upload",
      "x-upload-config": {
        "plugin_id": "my-plugin",
        "max_files": 5
      }
    }
  }
}

The widget will be automatically rendered when the plugin configuration form is loaded.

Creating Custom Widgets

Step 1: Create Widget File

Create a JavaScript file in your plugin directory (e.g., widgets/my-widget.js):

// Ensure LEDMatrixWidgets registry is available
if (typeof window.LEDMatrixWidgets === 'undefined') {
    console.error('LEDMatrixWidgets registry not found');
    return;
}

// Register your widget
window.LEDMatrixWidgets.register('my-custom-widget', {
    name: 'My Custom Widget',
    version: '1.0.0',
    
    /**
     * Render the widget HTML
     * @param {HTMLElement} container - Container element to render into
     * @param {Object} config - Widget configuration from schema
     * @param {*} value - Current value
     * @param {Object} options - Additional options (fieldId, pluginId, etc.)
     */
    render: function(container, config, value, options) {
        const fieldId = options.fieldId || container.id;
        // Sanitize fieldId for safe use in DOM IDs and selectors
        const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
        const safeFieldId = sanitizeId(fieldId);
        
        const html = `
            <div class="my-custom-widget">
                <input type="text" 
                       id="${safeFieldId}_input" 
                       value="${this.escapeHtml(value || '')}"
                       class="w-full px-3 py-2 border border-gray-300 rounded">
            </div>
        `;
        container.innerHTML = html;
        
        // Attach event listeners
        const input = container.querySelector(`#${safeFieldId}_input`);
        if (input) {
            input.addEventListener('change', (e) => {
                this.handlers.onChange(fieldId, e.target.value);
            });
        }
    },
    
    /**
     * Get current value from widget
     * @param {string} fieldId - Field ID
     * @returns {*} Current value
     */
    getValue: function(fieldId) {
        // Sanitize fieldId for safe selector use
        const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
        const safeFieldId = sanitizeId(fieldId);
        const input = document.querySelector(`#${safeFieldId}_input`);
        return input ? input.value : null;
    },
    
    /**
     * Set value programmatically
     * @param {string} fieldId - Field ID
     * @param {*} value - Value to set
     */
    setValue: function(fieldId, value) {
        // Sanitize fieldId for safe selector use
        const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
        const safeFieldId = sanitizeId(fieldId);
        const input = document.querySelector(`#${safeFieldId}_input`);
        if (input) {
            input.value = value || '';
        }
    },
    
    /**
     * Event handlers
     */
    handlers: {
        onChange: function(fieldId, value) {
            // Trigger form change event
            const event = new CustomEvent('widget-change', {
                detail: { fieldId, value },
                bubbles: true
            });
            document.dispatchEvent(event);
        }
    },
    
    /**
     * Helper: Escape HTML to prevent XSS
     */
    escapeHtml: function(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    },
    
    /**
     * Helper: Sanitize identifier for use in DOM IDs and CSS selectors
     */
    sanitizeId: function(id) {
        return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
    }
});

Step 2: Reference Widget in Schema

In your plugin's config_schema.json:

{
  "properties": {
    "my_field": {
      "type": "string",
      "description": "My custom field",
      "x-widget": "my-custom-widget",
      "default": ""
    }
  }
}

Step 3: Widget Loading

The widget will be automatically loaded when the plugin configuration form is rendered. The system will:

  1. Check if widget is registered in the core registry
  2. If not found, attempt to load from plugin directory: /static/plugin-widgets/[plugin-id]/[widget-name].js
  3. Render the widget using the registered render function

Widget API Reference

Widget Definition Object

{
    name: string,           // Human-readable widget name
    version: string,        // Widget version
    render: function,       // Required: Render function
    getValue: function,     // Optional: Get current value
    setValue: function,     // Optional: Set value programmatically
    handlers: object        // Optional: Event handlers
}

Render Function

render(container, config, value, options)

Parameters:

  • container (HTMLElement): Container element to render into
  • config (Object): Widget configuration from schema (x-widget-config or schema properties)
  • value (*): Current field value
  • options (Object): Additional options
    • fieldId (string): Field ID
    • pluginId (string): Plugin ID
    • fullKey (string): Full field key path

Get Value Function

getValue(fieldId)

Returns: Current widget value

Set Value Function

setValue(fieldId, value)

Parameters:

  • fieldId (string): Field ID
  • value (*): Value to set

Event Handlers

Widgets can define custom event handlers in the handlers object:

handlers: {
    onChange: function(fieldId, value) {
        // Handle value change
    },
    onFocus: function(fieldId) {
        // Handle focus
    }
}

Best Practices

Security

  1. Always escape HTML: Use escapeHtml() or textContent to prevent XSS
  2. Validate inputs: Validate user input before processing
  3. Sanitize values: Clean values before storing
  4. Sanitize identifiers: Always sanitize identifiers (like fieldId) used as element IDs and in CSS selectors to prevent selector injection/XSS:
    • Use sanitizeId() helper function (available in BaseWidget) or create your own
    • Allow only safe characters: [A-Za-z0-9_-]
    • Replace or remove invalid characters before using in:
      • getElementById(), querySelector(), querySelectorAll()
      • Setting id attributes
      • Building CSS selectors
    • Never interpolate raw fieldId into HTML strings or selectors without sanitization
    • Example: const safeId = fieldId.replace(/[^a-zA-Z0-9_-]/g, '_');

Performance

  1. Lazy loading: Load widget scripts only when needed
  2. Event delegation: Use event delegation for dynamic content
  3. Debounce: Debounce frequent events (e.g., input changes)

Accessibility

  1. Labels: Always associate labels with inputs
  2. ARIA attributes: Use appropriate ARIA attributes
  3. Keyboard navigation: Ensure keyboard accessibility

Error Handling

  1. Graceful degradation: Handle missing dependencies
  2. User feedback: Show clear error messages
  3. Logging: Log errors for debugging

Examples

Example 1: Color Picker Widget

window.LEDMatrixWidgets.register('color-picker', {
    name: 'Color Picker',
    version: '1.0.0',
    
    render: function(container, config, value, options) {
        const fieldId = options.fieldId;
        // Sanitize fieldId for safe use in DOM IDs and selectors
        const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
        const sanitizedFieldId = sanitizeId(fieldId);
        
        container.innerHTML = `
            <div class="flex items-center space-x-2">
                <input type="color" 
                       id="${sanitizedFieldId}_color" 
                       value="${value || '#000000'}"
                       class="h-10 w-20">
                <input type="text" 
                       id="${sanitizedFieldId}_hex" 
                       value="${value || '#000000'}"
                       pattern="^#[0-9A-Fa-f]{6}$"
                       class="px-2 py-1 border rounded">
            </div>
        `;
        
        const colorInput = container.querySelector(`#${sanitizedFieldId}_color`);
        const hexInput = container.querySelector(`#${sanitizedFieldId}_hex`);
        
        if (colorInput && hexInput) {
            colorInput.addEventListener('change', (e) => {
                hexInput.value = e.target.value;
                this.handlers.onChange(fieldId, e.target.value);
            });
            
            hexInput.addEventListener('change', (e) => {
                if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) {
                    colorInput.value = e.target.value;
                    this.handlers.onChange(fieldId, e.target.value);
                }
            });
        }
    },
    
    getValue: function(fieldId) {
        // Sanitize fieldId for safe selector use
        const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
        const sanitizedFieldId = sanitizeId(fieldId);
        const colorInput = document.querySelector(`#${sanitizedFieldId}_color`);
        return colorInput ? colorInput.value : null;
    },
    
    setValue: function(fieldId, value) {
        // Sanitize fieldId for safe selector use
        const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
        const sanitizedFieldId = sanitizeId(fieldId);
        const colorInput = document.querySelector(`#${sanitizedFieldId}_color`);
        const hexInput = document.querySelector(`#${sanitizedFieldId}_hex`);
        if (colorInput && hexInput) {
            colorInput.value = value;
            hexInput.value = value;
        }
    },
    
    handlers: {
        onChange: function(fieldId, value) {
            const event = new CustomEvent('widget-change', {
                detail: { fieldId, value },
                bubbles: true
            });
            document.dispatchEvent(event);
        }
    }
});

Example 2: Slider Widget

window.LEDMatrixWidgets.register('slider', {
    name: 'Slider Widget',
    version: '1.0.0',
    
    render: function(container, config, value, options) {
        const fieldId = options.fieldId;
        // Sanitize fieldId for safe use in DOM IDs and selectors
        const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
        const sanitizedFieldId = sanitizeId(fieldId);
        
        const min = config.minimum || 0;
        const max = config.maximum || 100;
        const step = config.step || 1;
        const currentValue = value !== undefined ? value : (config.default || min);
        
        container.innerHTML = `
            <div class="slider-widget">
                <input type="range" 
                       id="${sanitizedFieldId}_slider"
                       min="${min}"
                       max="${max}"
                       step="${step}"
                       value="${currentValue}"
                       class="w-full">
                <div class="flex justify-between text-xs text-gray-500 mt-1">
                    <span>${min}</span>
                    <span id="${sanitizedFieldId}_value">${currentValue}</span>
                    <span>${max}</span>
                </div>
            </div>
        `;
        
        const slider = container.querySelector(`#${sanitizedFieldId}_slider`);
        const valueDisplay = container.querySelector(`#${sanitizedFieldId}_value`);
        
        if (slider && valueDisplay) {
            slider.addEventListener('input', (e) => {
                valueDisplay.textContent = e.target.value;
                this.handlers.onChange(fieldId, parseFloat(e.target.value));
            });
        }
    },
    
    getValue: function(fieldId) {
        // Sanitize fieldId for safe selector use
        const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
        const sanitizedFieldId = sanitizeId(fieldId);
        const slider = document.querySelector(`#${sanitizedFieldId}_slider`);
        return slider ? parseFloat(slider.value) : null;
    },
    
    setValue: function(fieldId, value) {
        // Sanitize fieldId for safe selector use
        const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
        const sanitizedFieldId = sanitizeId(fieldId);
        const slider = document.querySelector(`#${sanitizedFieldId}_slider`);
        const valueDisplay = document.querySelector(`#${sanitizedFieldId}_value`);
        if (slider) {
            slider.value = value;
            if (valueDisplay) {
                valueDisplay.textContent = value;
            }
        }
    },
    
    handlers: {
        onChange: function(fieldId, value) {
            const event = new CustomEvent('widget-change', {
                detail: { fieldId, value },
                bubbles: true
            });
            document.dispatchEvent(event);
        }
    }
});

Troubleshooting

Widget Not Loading

  1. Check browser console for errors
  2. Verify widget file path is correct
  3. Ensure LEDMatrixWidgets.register() is called
  4. Check that widget name matches schema x-widget value

Widget Not Rendering

  1. Verify render function is defined
  2. Check container element exists
  3. Ensure widget is registered before form loads
  4. Check for JavaScript errors in console

Value Not Saving

  1. Ensure widget triggers widget-change event
  2. Verify form submission includes widget value
  3. Check getValue function returns correct type
  4. Verify field name matches schema property

Migration from Server-Side Rendering

Currently, widgets are server-side rendered via Jinja2 templates. The registry system provides:

  1. Backwards Compatibility: Existing server-side rendered widgets continue to work
  2. Future Enhancement: Client-side rendering support for custom widgets
  3. Handler Availability: All widget handlers are available globally

Future versions may support full client-side rendering, but server-side rendering remains the primary method for core widgets.

Support

For questions or issues:

  • Check existing widget implementations for examples
  • Review browser console for errors
  • Test with simple widget first before complex implementations