Commit Graph
64 Commits
Author SHA1 Message Date
ChuckBuildsandClaude Sonnet 5 e1e26e8eab feat(web): floating live preview + per-plugin "Preview on display", drawer a11y
Preview-while-configuring:
- Floating mini preview (fixed, bottom-right) available on every tab except
  Overview, fed from the same SSE display stream by updateDisplayPreview -
  no new connections. Collapses to a round toggle button; open/closed state
  persists in localStorage; hides on Overview where the full preview lives.
- "Preview on display" button on every plugin config page header: runs that
  plugin on the real display for 60 seconds via the existing
  /display/on-demand/start API and opens the floating preview, closing the
  configure -> see-the-result loop.

Drawer/nav accessibility:
- aria-current="page" tracks the active tab (system + dynamic plugin tabs,
  matched via their Alpine @click expression), updated from the activeTab
  watcher so search deep-links and checklist navigation are covered too.
- Escape closes the mobile drawer and returns focus to the hamburger;
  opening the drawer moves focus to its first tab.

Validation: all 40 web tests pass; Jinja parse + div balance on both
touched templates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 12:27:36 -04:00
ChuckBuildsandClaude Sonnet 5 05aee74147 fix(web): Rotation & Durations page lists every enabled plugin's screens
The durations grid looped over display.display_durations, which nothing has
ever populated (verified {} on a real production install) - so the page
rendered no duration fields at all. Worse, its inputs posted bare mode
names, which save_main_config's endswith('_duration') filter silently
dropped: the page was broken in both directions, unnoticed because it was
also unreachable (previous commit).

- pages_v3._load_durations_partial now builds one entry per display mode of
  every ENABLED plugin via plugin_manager.get_plugin_display_modes()
  (falling back to the plugin id), overlaid with saved values, defaulting
  to the display controller's 30s. Grouped per plugin, sorted by name.
  Saved keys not owned by any enabled plugin stay visible under "Other
  saved entries" instead of vanishing.
- durations.html renders the grouped inputs, named duration__<mode_key>
  (mode keys are arbitrary, so they can't use the *_duration suffix
  convention), with an explanatory empty state when no plugins are enabled.
- api_v3.save_main_config accepts the new duration__<mode> fields and
  writes them into display.display_durations under the bare mode key -
  exactly what the display controller reads
  (display_durations.get(mode_key, 30)).

Validation: py_compile both blueprints; Jinja render with 3 groups asserts
grouped inputs, saved-value overlay, stale-entry group, empty state, and
div balance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 12:09:48 -04:00
ChuckBuildsandClaude Sonnet 5 3ab6a731c3 fix(web): add the missing nav tab for the Rotation & Durations page
The durations partial (/partials/durations) has existed as a route with no
nav tab and no content panel referencing it - an orphaned page. That made
the new rotation-order UI unreachable through the interface (caught by the
owner testing on the rig; my endpoint-level tests fetched the partial by
URL and never noticed the missing entry point).

- New "Rotation" tab (fa-rotate icon, verified present in the vendored
  FA 6.0.0 css) between Display and Backup & Restore, wired exactly like
  the other tabs (#durations-content + hx-get + loadtab; loadTabContent()
  is fully generic, so no JS changes needed).
- Page heading updated from "Display Durations" to "Rotation & Durations"
  to match its content since the rotation-order card landed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 12:01:03 -04:00
ChuckBuildsandClaude Sonnet 5 42abf5dadf feat(web): Getting Started items are manually checkable; card auto-hides when complete
Two gaps reported from live testing on the devpi rig:

1. The timezone/location step never showed done for a user whose real
   timezone IS the shipped default (America/New_York) - the heuristic can
   only detect difference-from-default, not "user saved this". Clicking an
   item's checkbox now toggles it done manually (persisted per browser in
   localStorage), so any heuristic false-negative is one tap to clear.
   Clicking the item text still deep-links to its tab.
2. The card now hides itself automatically once every step is done
   (auto-detected or manually checked) - previously it stayed until the X
   was clicked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 11:19:25 -04:00
ChuckBuildsandClaude Sonnet 5 e20fc40a31 feat(web): fold Display Options into the advanced dropdown; Vegas above Double-Sided
Owner-requested layout refinement of the Display Settings tab:

- The "Display Options" card (disable_hardware_pulsing, inverse_colors,
  show_refresh_rate, use_short_date_format, Dynamic Duration) moves inside
  the collapsed advanced section, now titled "Advanced Hardware & Display
  Options (15)". Hidden form fields still submit with the form, and
  settings search still auto-expands the section on match, so nothing is
  lost - the tab just leads with the essentials.
- The "Vegas Scroll Mode" section moves above "Double-Sided Display".
  New section order: Hardware (+ advanced dropdown) > Vegas Scroll >
  Double-Sided > Multi-Display Sync.

Validation (real Jinja render): all 23 field names present exactly once,
divs balanced (70/70), the five Display Options fields render inside the
advanced section's bounds, and section markers confirm the new order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 11:13:42 -04:00
ChuckBuildsandClaude Sonnet 5 b6a8a88665 fix(web): size the live preview from the PNG's real dimensions, not config
The initial SSE render sized the preview image (and both overlay canvases)
from the server-reported config dimensions (cols x chain_length,
rows x parallel), while the scale slider's re-render path sized from
img.naturalWidth/naturalHeight. Whenever the snapshot PNG's actual size
disagrees with the config (stale config, display service not restarted
after a hardware change), the initial render stretched the image at a
fractional ratio - blurry despite image-rendering: pixelated - and
touching the scale slider "fixed" it. Reported live on the devpi test rig.

Both paths now size from the loaded image's natural dimensions inside
img.onload (which also removes a transient wrong-size flash between
src assignment and load). The meta label now reports the true snapshot
size. The preview card also gets overflow-x-auto so on narrow screens a
wide preview scrolls at its exact pixel-perfect size instead of being
squeezed into the viewport (fractional downscaling of pixel art also
reads as blur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 11:10:46 -04:00
ChuckBuildsandClaude Sonnet 5 d49cb5851d feat(display): drag-and-drop plugin rotation order for the primary display mode
The primary rotation's order was invisible and unconfigurable: modes are
registered in parallel-load COMPLETION order, so rotation order actually
varied between restarts. Only the niche Vegas Scroll mode had a working
order UI. This adds real, persisted ordering end to end:

Backend:
- config.template.json: new display.plugin_rotation_order (default [],
  fully backward compatible).
- display_controller.py: _apply_plugin_rotation_order() rebuilds
  available_modes grouped by plugin per the configured list (each plugin's
  modes keep their declared order; unlisted plugins follow in existing
  relative order; empty config = exact no-op). Applied at startup after
  parallel load and after live enable/disable reconcile (before the
  existing _resync_mode_index_after_change, which preserves the current
  mode). Mirrors vegas_mode get_ordered_plugins() semantics.
- api_v3.py save_main_config: accepts plugin_rotation_order as a JSON
  array (same parse/guard pattern as vegas_plugin_order).

Frontend:
- New shared widget static/v3/js/widgets/plugin-order-list.js: the Vegas
  section's drag-and-drop list factored out verbatim (native HTML5 drag
  events, saved-order-first rendering, hidden-input JSON sync),
  parameterized by container/order-input/optional exclude-checkbox/badge.
- display.html: Vegas section now calls the shared module; its ~130-line
  inline copy of the same logic is deleted.
- durations.html: new "Rotation Order" card above the durations grid using
  the same module, posting plugin_rotation_order with the existing form.

Deviation from plan, deliberate: durations stay as their own mode-keyed
grid rather than inline in the drag rows - verified display_durations keys
are MODE names (display_controller.py resolves duration per mode_key), not
plugin ids, and one plugin can own several modes, so the planned 1:1
inline pairing was wrong.

Validation: py_compile on both Python files; _apply_plugin_rotation_order
unit-tested standalone (configured order applied, empty-config no-op,
unknown ids skipped - 3/3); both templates render with balanced divs, the
hidden input carries the saved order, and the old inline implementation is
confirmed gone; config.template.json parses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 10:40:19 -04:00
ChuckBuildsandClaude Sonnet 5 5ebd55d02b feat(web): dismissible Getting Started checklist on Overview
New users land on a dense multi-tab dashboard with no suggested order of
operations (the only guided flow is the WiFi captive portal). This adds a
non-gating checklist card at the top of Overview with five steps, each a
deep link that switches to the right tab (and closes the mobile nav drawer):

1. Set panel size            -> Display tab   (done: rows/cols/chain_length > 0)
2. Set timezone/location     -> General tab   (done: differs from template
                                               defaults America/New_York / Tampa)
3. Install a plugin          -> Plugins tab   (done: /api/v3/plugins/installed
                                               non-empty)
4. Enable a plugin           -> Plugins tab   (done: any installed plugin enabled)
5. Configure it              -> Plugins tab   (done: first enabled plugin has >=1
                                               saved value differing from its
                                               schema defaults)

Steps 1-2 are computed server-side in Jinja from main_config (already in the
partial's context); 3-5 client-side from existing endpoints. No new backend
state: dismissal persists in localStorage (mirroring the reconciliation
banner's sessionStorage pattern one section up); deep links use the same
_x_dataStack app-data access as settings-search.js. Disclosed heuristic
limit: values left at legitimate defaults (a user actually in Tampa) read
as "not done".

Validation: real Jinja render across 3 config variants confirms the
server-side done-flags flip correctly; div balance intact; /plugins/config
response shape (config dict directly in .data) verified against api_v3.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 10:31:56 -04:00
ChuckBuildsandClaude Sonnet 5 af991d4cca feat(web): Display Settings basic/advanced split + live total-resolution readout
The Hardware Configuration card showed ~17 fields at equal priority; a new
user only needs 7 of them to get a correctly-sized, correctly-colored image
(rows, cols, chain_length, parallel, brightness, hardware_mapping,
led_rgb_sequence). The other 10 (multiplexing, panel_type, row_address_type,
gpio_slowdown, rp1_rio, scan_mode, pwm_bits, pwm_dither_bits,
pwm_lsb_nanoseconds, limit_refresh_rate_hz) now live in a collapsed
"Advanced Hardware Settings" section using the same nested-section shell as
plugin config forms, so toggleSection() and settings-search auto-expand work
unchanged. led_rgb_sequence moved up beside brightness/hardware_mapping
(2-col grid became 3-col). No field was removed or renamed; the form still
posts the same names to /api/v3/config/main.

Also adds a live "Your display: W x H pixels" readout under the four sizing
fields (width = cols x chain_length, height = rows x parallel - the exact
math the chain-length tooltip describes in prose), recomputed client-side on
every input event, no round-trip.

Deviation from plan, deliberate: disable_hardware_pulsing / inverse_colors /
show_refresh_rate stay in their separate "Display Options" card rather than
moving across cards - relocating fields between form sections risks
regressions for no decluttering gain in the card users complained about.

Validation (real Jinja render): all 17 hardware fields present exactly once,
basic fields render before the advanced section and the 10 advanced fields
inside it, div count balanced (71/71), readout + recompute script present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 10:26:00 -04:00
ChuckBuildsandClaude Sonnet 5 d3619584d6 feat(web): x-advanced schema flag groups plugin config fields under a collapsed Advanced Settings section
Plugin config pages show every schema property at equal visual priority,
which overwhelms first-time users. Plugin authors can now add
"x-advanced": true to any flat (non-object) property in config_schema.json
to move it into one collapsed "Advanced Settings (N)" section rendered after
the basic fields - progressive disclosure with zero loss of control.

Implementation: the main render loop in plugin_config.html splits ordered
properties into basic/advanced tiers; the advanced group reuses the exact
.nested-section/.nested-content/toggleSection() shell that nested object
sections already use, so the settings search's expand-on-match behavior
works on advanced fields with no JS changes. Object-type properties ignore
the flag (they already render as their own collapsible sections). No
backend change needed: jsonschema ignores unknown x-* keywords exactly as
it does for x-widget/x-propertyOrder.

Documented in docs/widget-guide.md alongside the other x-* extensions.

Validation (rendered with real Jinja, not just parsed):
- synthetic schema with 2 advanced fields: basic fields render before the
  section, advanced inside the collapsed shell, count badge correct,
  x-advanced on an object property correctly ignored
- schema without any x-advanced: output is identical to the pre-change
  template (whitespace-normalized diff against git HEAD's version)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 10:23:05 -04:00
ChuckBuildsandClaude Sonnet 5 d2b49c4ff2 chore(web): remove dead legacy client-side plugin-config generator (~2,300 lines)
Plugin config forms have been rendered server-side (plugin_config.html via
GET /partials/plugin-config/<id>) since the HTMX migration; the old
client-side generator survived as unreachable code. Verified dead by call
graph, not by naming: showPluginConfigModal and showGithubTokenInstructions
have zero callers anywhere in templates or JS, and everything removed here
is reachable only from those two roots.

Removed:
- plugins_manager.js: showPluginConfigModal, generatePluginConfigForm,
  generateFormFromSchema, generateFieldHtml, generateSimpleConfigForm,
  handlePluginConfigSubmit, the modal's JSON-editor view (initJsonEditor,
  switchPluginConfigView, syncFormToJson/JsonToForm, saveConfigFromJsonEditor,
  resetPluginConfigToDefaults, displayValidationErrors, closePluginConfigModal,
  savePluginConfiguration, currentPluginConfigState), their exclusive helpers
  (getSchemaPropertyType, escapeCssSelector, dotToNested, collectBooleanFields,
  normalizeFormDataForConfig, flattenConfig, loadCustomHtmlWidget), the
  orphaned-modal cleanup block, the modal's listener wiring, and the
  never-invoked showGithubTokenInstructions/closeInstructionsModal pair.
- plugins.html: the #plugin-config-modal markup those functions drove.
- base.html: the deprecated pluginConfigData() component and the
  window.PluginConfigHelpers shim (only ever called by pluginConfigData).

Deliberately kept, verified still live:
- renderArrayObjectItem, getSchemaProperty, escapeHtml/escapeAttribute
  (window-exposed for the top-level array-of-objects handlers the
  server-rendered form uses), toggleNestedSection, addKeyValuePair/
  addArrayObjectItem families, executePluginAction, and
  window.currentPluginConfig = null init (file-upload.js and
  executePluginAction read it, optional-chained).
- app()'s internal generateConfigForm/generateSimpleConfigForm methods in
  base.html: unreachable now but embedded in the live Alpine component;
  excising methods from a live object is deferred to keep this change
  zero-risk.

Validation: every deletion seam inspected line-by-line; Jinja parse of both
templates passes; repo-wide sweep confirms zero remaining references to any
deleted function or element id (deleted ranges contained no Jinja tags).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 09:02:16 -04:00
978a03b42d Add settings tooltips and search to the web UI (#387)
* Add settings tooltips and search to the web UI

Help users quickly find settings and understand how each one works.

Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an
accessible (i) info tooltip that appears on hover, keyboard focus, and tap.
A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger;
the plugin config macro and the core settings partials now surface help
text through it. Per the design, the always-visible field help paragraphs
are folded into the tooltip to declutter the forms, and the hardware/display
settings carry authored detail (default, range, recommendation).

Search: a global header search box finds settings across every settings tab
— even ones not yet opened — via a lazy client-side index built by scanning
the same field markup (static/v3/js/settings-search.js). Selecting a result
switches tabs, waits for the field to load, then scrolls to and flashes it.
A per-tab filter box hides non-matching fields on the current tab.

Plugin settings get tooltips for free by reusing each field's schema
`description`; every settings field also gets a stable `setting-<tab>-<key>`
anchor id for search navigation.

Styling uses the existing --color-* theme vars so light/dark mode both work,
and honors prefers-reduced-motion. Adds Flask render smoke tests that assert
each settings partial ships tooltips, anchors, and a filter box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Address Codacy static-analysis findings in settings JS

Refactor the two new modules to clear the flagged patterns without any
behavior change:

- Build the search dropdown with DOM nodes + textContent instead of
  innerHTML string concatenation, removing the XSS sinks and the manual
  escapeHtml helper it needed.
- Replace numeric index access (index[i], terms[j], opts[idx],
  currentResults[i]) with array iteration methods, NodeList.item(), and
  Array.prototype.at() to clear detect-object-injection.
- Use === via a shared termsMatch() helper, optional-catch binding, and
  drop a useless initial assignment.

Verified with ESLint (eslint:recommended + eslint-plugin-security) at zero
findings and re-ran the headless-Chromium behavior test (tooltip, per-tab
filter, global search navigation) — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Reduce complexity of revealAncestors in settings search

Extract isNodeHidden() and revealNode() helpers so revealAncestors drops
below the cyclomatic-complexity threshold. No behavior change; verified with
the headless-Chromium test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Resolve remaining Codacy findings in settings search

- Validate plugin ids against a strict allowlist (mirroring the server's
  _SAFE_PLUGIN_ID_RE) before they can appear in a fetch path, so the request
  URL is never built from unvalidated input (Codacy: user-controlled URL).
- Document that the fetched HTML is parsed into an inert document (scripts
  never run, never inserted into the live DOM) purely to read field text for
  the search index.
- Declare block-scoped locals with const instead of var where they were
  nested inside conditionals (Codacy: var not at function root).

No behavior change; re-verified with ESLint and the headless-Chromium test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Serve the settings search index from the server as JSON

Move index building off the client so there is no client-side HTML fetching
or DOM parsing (resolves Codacy's variable-fetch and DOMParser flags on the
read-only, same-origin index build).

- Add GET /v3/settings/search-index (pages_v3.py): renders the settings
  partials server-side and extracts each field's anchor id, key, label,
  tooltip, and section with a small stdlib HTMLParser, then caches the result
  keyed on the installed-plugin set. Parsing the rendered HTML keeps anchor
  ids identical to the live DOM, so the index cannot drift.
- settings-search.js: buildIndex() now does a single fetch of the literal
  endpoint + .json(); removed the per-partial fetch loop, DOMParser, scanDoc,
  CORE_TABS, and the plugin-id allowlist. Search, keyboard nav, navigation,
  and the per-tab filter are unchanged.

Net: fewer requests and no client-side HTML parsing. Verified with a new
endpoint test in test_web_settings_ui.py (12 pass) and the headless-Chromium
test (tooltip, filter, search navigate + flash all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Address CodeRabbit review on settings search/tooltips

- pages_v3: include Durations tab in the search index so
  setting-durations-* fields are actually indexed
- test_web_settings_ui: assert setting-durations-clock is present in
  the search-index endpoint response
- settings-search.js: on index fetch failure, reset buildPromise
  instead of caching an empty (truthy) index so search can retry
- settings-search.js: filterScope returns null (not document) when no
  tab container matches, and the caller guards, so the per-tab filter
  can't hide fields across unrelated tabs
- settings-search.js: refresh the stale header comment to describe the
  server-side JSON index flow
- app.css: cap #settings-search-results height with overflow-y so the
  dropdown scrolls instead of overflowing small screens

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Fix stuck search dropdown; add plugin-tab nested-settings filter

Global search:
- Close the results dropdown on input blur (guarded, with a short delay)
  so it reliably dismisses when focus leaves — previously it could linger
  because the only outside-close was a document click that Alpine/HTMX
  handlers can swallow.
- Clear the query text after navigating to a result so refocusing the box
  doesn't re-open stale results.
- Also dismiss on htmx:afterSwap (tab changes / navigation).

Per-tab filter (now on plugin tabs too):
- Render the shared settings_filter box in the plugin Configuration panel.
  It auto-wires: the delegated input handler and filterScope already target
  .plugin-config-tab.
- Teach applyTabFilter to reveal matches inside collapsed nested sections
  (render_nested_section defaults them shut), hide nested-section wrappers
  with no matches, and restore the original collapsed layout when cleared
  (only re-collapsing sections the filter itself opened).
- Count a visible nested-section as content for its parent heading so the
  heading isn't hidden while a subsection below still has matches.

Adds a plugin-config render test (filter box + nested anchors + tooltips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Close search dropdown via capture-phase outside-click

The dropdown could stay open after clicking away because the only
outside-click close was a bubble-phase document listener. The v3 UI is
one Alpine app() component full of HTMX/Alpine/widget click handlers;
when a click lands inside an element that calls stopPropagation(), the
event never bubbles to document and the close never runs.

- Replace the bubble-phase document 'click' close with a capture-phase
  'pointerdown' listener scoped to #settings-search-wrap. Capture runs
  before any bubbling stopPropagation can swallow the event, so it always
  fires; pointerdown also covers touch on the Pi screen. Clicking a result
  stays inside the wrap, so selection is unaffected.
- Guard the debounced input handler so a delayed render can't re-open the
  box after focus has left (type-then-click-away race).

Keeps the existing blur / Escape / htmx:afterSwap closes as secondary paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Fix settings search dropdown not visually closing

.hidden has no effect in this app: app.css is a hand-picked utility
subset (no Tailwind build step) and never defines .hidden { display:
none }. openResults()/closeResults() only toggled the class, so the
dropdown stayed rendered (display: block) even once closeResults()
ran - confirmed via computed style in a headless browser. Set
style.display directly, matching the fallback already used by
revealNode()/collapseNode() elsewhere in this file.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 09:22:24 -04:00
bd9f461f70 Add system diagnostics, power controls, and WiFi radio toggle to Tools tab (#389)
* Add system diagnostics, power controls, and WiFi radio toggle to Tools tab

Expands the web UI Tools tab with safe, purpose-built controls so users can
manage the Pi without SSHing in, instead of an arbitrary-command terminal
(the web UI has no auth and CSRF is disabled, so a shell would be unsafe).

- System Diagnostics card: renders the existing but previously-unused
  GET /api/v3/system/status endpoint (CPU, memory, temp, disk, uptime),
  with a manual refresh and a 10s poll.
- System Power section: reboot/shutdown buttons wired to the existing
  reboot_system / shutdown_system actions, behind a confirm step, with a
  dedicated powerAction() helper that treats the dropped connection as the
  expected "going offline" outcome rather than an error.
- Network Radio section: WiFi on/off toggle backed by new
  GET/POST /api/v3/wifi/radio endpoints and WiFiManager.set_wifi_radio() /
  get_wifi_radio_state(). Disabling WiFi is refused unless a wired
  connection is present (reusing the existing lockout guards), with an
  explicit force-off confirmation for advanced users.

No new privileged commands: uses nmcli radio wifi on|off (already
sudo-allowlisted) and the existing reboot/poweroff grants, so the
sudoers-alignment guard test stays green. Bluetooth toggle intentionally
omitted since the installer removes the BlueZ stack for LED timing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE

* Harden WiFi radio endpoint and diagnostics poll (review feedback)

Addresses code review feedback on the Tools tab additions:

- api_v3.py: parse the `enabled` POST field with the same string-aware
  coercion as `force`. A plain bool() cast turned {"enabled":"false"} into
  True (enabling instead of disabling) for any non-UI API caller.
- wifi_manager.set_wifi_radio() now returns a reason code alongside
  (success, message); the /wifi/radio error response includes it. The Tools
  UI only shows the force-off confirmation when reason == 'no_ethernet', so a
  genuine nmcli failure surfaces its real error instead of a misleading
  "no wired connection" prompt that would just retry into the same failure.
- tools.html: gate the 10s diagnostics poll on panel visibility
  (document.hidden / offsetParent), so switching to another tab stops the
  recurring /api/v3/system/status calls instead of churning the Pi off-screen.
  The initial load and manual Refresh remain unconditional.

Verified: {"enabled":"false"} now disables (refused w/ reason:no_ethernet),
{"enabled":"true"} enables; inline JS passes node --check; py_compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE

* Narrow WiFi-disable fallback except and add stack trace (review)

Addresses a CodeRabbit nitpick: the fallback handler in set_wifi_radio()'s
disable path caught bare Exception and logged without a traceback. Narrow it to
(OSError, subprocess.SubprocessError) — the errors subprocess.run realistically
raises — and log with exc_info=True for full context on the Pi. Anything
genuinely unexpected now propagates to the endpoint's outer handler (500),
matching the codebase's specific-exception convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 09:22:11 -04:00
3b93024993 feat: activate dormant plugin health/metrics subsystem and surface it in the web UI (#388)
* feat(plugin-system): activate dormant plugin health & metrics subsystem

PluginManager shipped a fully-built health tracker, resource monitor and
circuit breaker that were never instantiated (health_tracker/resource_monitor
were left as None), so the circuit breaker never engaged and the existing
health/metrics API routes always returned "not available".

- DisplayController now wires a PluginHealthTracker and PluginResourceMonitor
  onto the plugin manager, enabling the circuit breaker (a repeatedly-failing
  plugin's update() is skipped after consecutive failures, then retried after
  a cooldown) and per-plugin execution-time metrics. Both persist to the
  shared cache.
- load_plugin() now validates each plugin's config against its JSON schema in
  a strictly warn/degrade-only way: a violation logs a warning and flags the
  plugin degraded in the health tracker, but never changes whether the plugin
  loads or its pass/fail behaviour. Adds PluginHealthTracker.set_degraded(),
  which never touches the circuit breaker.
- ResourceMonitor CPU/memory sampling now reuses a cached psutil.Process and
  reads cpu_percent(interval=None), so monitoring no longer blocks ~100ms per
  call on the display loop's update path.
- Fix DiskCache.get() raising TypeError for max_age=None ("never expires"),
  which silently discarded persisted plugin health/metrics on read and thus
  broke cross-process and post-restart surfacing.
- Fix two dead PluginManager helpers that called non-existent tracker methods.

Tests: new test_resource_monitor, test_plugin_health,
test_plugin_manager_schema_soft; extended test_cache_manager and
test_display_controller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTav268UXv44ub9K11LYq

* feat(web-ui): surface plugin health, metrics and load state

With the health/metrics subsystem now active in the display service, expose it
in the web UI (which runs as a separate process from the display loop):

- Wire a health tracker / resource monitor backed by the shared on-disk cache
  into the web process so /api/v3/plugins/health and /plugins/metrics read the
  data the display service persists.
- Build those route responses per installed plugin id (the tracker's in-memory
  view is empty in a fresh web process) so cross-process data is included.
- Add state + error_info to /plugins/installed entries so the UI can show why a
  plugin isn't running instead of just loaded:false.
- Add a "Plugin Health" panel to the Tools page (circuit status, avg/max update
  time, update count, last error) plus PluginAPI.getPluginMetrics().

Tests: route-level tests for the health/metrics endpoints in test_web_api.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTav268UXv44ub9K11LYq

* fix(plugin-metrics): refresh cross-process health/metrics reads; type hints

Addresses CodeRabbit review on #388:

- Major: the web process's health/resource trackers cached the first persisted
  read in an in-memory dict (and the CacheManager memory tier held max_age=None
  entries indefinitely), so a long-lived web process showed the first snapshot
  and never reflected the display service's later updates. Add an opt-in
  force_reload path (get_health_summary/get_health_state/_load_health_state and
  get_metrics_summary/get_metrics) that bypasses the in-memory copy and, via a
  new memory_ttl passthrough on CacheManager.get, the cache manager's memory
  tier — so each /plugins/health and /plugins/metrics poll reads fresh persisted
  state. Default behaviour (force_reload=False) is unchanged for the display
  process and existing callers.
- Minor: DiskCache.get type hint is now Optional[int] with the None ("never
  expires") semantics documented, matching MemoryCache.get.

Tests: new force_reload staleness cases in test_plugin_health and
test_resource_monitor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTav268UXv44ub9K11LYq

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 07:54:18 -04:00
9b2f02681d feat(web-ui): detect and surface Raspberry Pi under-voltage/throttling (#383)
* feat(web-ui): detect and surface Raspberry Pi under-voltage/throttling

Adds a vcgencmd get_throttled check to the system-status SSE stream and
surfaces it in the web UI:

- A header badge (next to CPU/Memory/Temp) that stays hidden when healthy,
  turns red when under-voltage/throttling is happening right now, and
  yellow if it happened earlier this session but has since cleared.
- A dismissible top banner (same pattern as the update-available banner)
  that appears while under-voltage/throttling is actively occurring, with
  guidance to check the power supply. Re-appears on a fresh occurrence
  even if a previous one was dismissed.
- A "Power Supply" card on the Overview tab alongside CPU/Memory/Temp/
  Display Status.

Motivated by a real device showing intermittent brightness flicker that
turned out to be ~1 under-voltage event every 30-90s (visible live via
`vcgencmd get_throttled` and dmesg's "Undervoltage detected!" messages) --
there was no way to see this from the web UI, only by SSHing in.

Returns None on non-Pi platforms (no vcgencmd on PATH), matching the
existing guard pattern used for the CPU temperature read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* feat(web-ui): add power supply diagnostics detail to Tools tab

The header badge/banner/Overview card added in the previous commit only
show a collapsed "is it bad right now" signal. This adds a "Power Supply"
section to the Tools tab with the full 8-flag breakdown (under-voltage,
throttled, freq-capped, soft-temp-limit -- each split into "right now" vs
"occurred since boot") for actually troubleshooting a recurring issue,
plus a pointer to the README's power supply sizing guidance when something
is or was flagged.

Reuses the existing stats SSE stream (window.statsSource) rather than
adding a new endpoint -- the same payload already drives the header/
banner/Overview card, so this just listens for it too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix(web-ui): address review findings on power supply monitoring

- Overview card left its "--" placeholder forever on non-Pi platforms since
  the update only handled a truthy data.power. Now explicitly renders "Not
  available" with a neutral icon/color for that case.
- vcgencmd failures logged at debug, invisible in default remote log
  output. Bumped to warning to match the nearby systemctl failure logging.
- The banner/badge/card and the Tools summary line only looked at
  under_voltage_now/throttled_now (+ occurred), silently ignoring
  freq_capped_now/occurred and soft_temp_limit_now/occurred from
  _get_power_status() -- a Pi that's actively soft-thermal-limited or
  frequency-capped showed a green "OK" everywhere except the detailed
  flag table buried in Tools. All four surfaces now fold all four "now"/
  "occurred" flags into the same active/occurred state.
- The banner text was hardcoded to "Under-voltage detected..." even when
  the actual active condition was throttling/freq-capping/thermal limiting.
  Added _activePowerConditionLabels() (shared, non-module global scope) to
  build the banner/tooltip text from whichever flags are actually set.

Skipped: TTL-caching _get_power_status() to avoid "multiplying forks
across browser tabs" -- that premise doesn't hold against this codebase.
_StreamBroadcaster (its own docstring says as much) already runs exactly
one shared generator per tick regardless of client count, identical to
the uncached cpu_temp file-read two lines above it; there's nothing to
multiply.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix(web-ui): address second round of review findings on power monitoring

- updatePowerStatus's falsy-power branch only hid power-stat, leaving
  power-warning-banner visible with stale text if _get_power_status()
  fails transiently. Now hides the banner too and resets the dismissed
  flag, same as the "not active" branch.
- The Tools tab's status badge (and the pre-existing dirty/clean badge
  right next to it) build class names like bg-${color}-100/text-${color}-800
  at runtime. This project hand-rolls its own Tailwind-named utility
  classes in app.css rather than running a real Tailwind build, and the
  light-mode base rules for bg-red-100/bg-yellow-100/bg-green-100/
  text-red-800/text-yellow-800/text-green-800 were simply never defined --
  only some had dark-mode overrides, which are no-ops without a base rule
  in light mode. Added the missing light-mode bases plus the two missing
  dark-mode overrides (bg-yellow-100/bg-green-100), fixing both badges.
- The "occurred earlier" tooltip was a hardcoded string regardless of
  which flag(s) actually fired. Generalized _activePowerConditionLabels()
  to take a suffix ('_now' or '_occurred') and reused it for both tooltips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* refactor(web-ui): move Power Supply status off the Overview tab

The Overview tab's "Power Supply" stat card duplicated what the Tools
tab's diagnostics section already shows (summary badge + full flag
breakdown), so drop the card and its now-dead JS rather than keep two
copies in sync. The header badge and warning banner (visible on every
page) are unaffected -- only the Overview-tab card is removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 09:35:55 -04:00
deaa3d7a98 fix: array-table boolean columns always saved as unchecked (#381)
Reported bug: countdown plugin always shows "No Active" even with a
countdown configured and enabled — because every save silently unchecked
it. Root cause: the boolean column's hidden-sentinel input (needed so
unchecked checkboxes still submit "false", since browsers omit unchecked
checkboxes entirely) was hardcoded to value="false" and shared the same
`name` as the checkbox, with no sync between them. Whichever of the two
same-named inputs the save request's form-collection happens to prefer,
the hidden's stale "false" could silently override an actually-checked
box on every save, independent of what the user set.

Fixed in both places this pattern is rendered:
- plugin_config.html: the initial server-rendered row for every array-table
  boolean column (affects countdown's per-countdown "enabled" and the
  custom-feeds widget's per-feed "enabled") — now sets the hidden's initial
  value from the actual data, and syncs it via onchange on every toggle.
- array-table.js: the client-side row renderer used when adding/rebuilding
  rows in the browser — same fix, keeping both inputs in sync at render
  time and via a change listener.

Verified other checkbox/hidden-input patterns in plugin_config.html and
confirmed they're unrelated/already-safe: the default single-checkbox
renderer (no hidden pair), checkbox-group (array-bracket names with its
own explicit sync), the array-table advanced-props modal editor (single
hidden per name, explicitly written on Save), the top-level plugin
enable/disable toggle (separate immediate hx-post), and the no-schema
fallback checkbox. custom-feeds.js's own client-side row renderer never
creates a hidden sentinel at all, so it isn't affected either.

Verified the fix's rendered output directly with an isolated Jinja render
of the exact snippet: hidden and checkbox now agree ("true"/checked or
"false"/unchecked) for both states, where before the hidden was always
"false" regardless of the actual value.


Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 08:05:06 -04:00
639e1c3a93 fix(web): repair news ticker custom-feeds save for JSON path (#376)
* feat(install): surface root cause of web dependency install failures

install_dependencies_apt.py previously reported only which packages
failed, not why - the actual apt/pip error was discarded (apt) or
could scroll out of the on_error log tail (pip), leaving "Step 7:
Install web interface dependencies (line 915)" as the only visible
detail.

Capture command output for each install attempt and print a compact
DEPENDENCY INSTALLATION FAILURES summary with the last lines of error
output per package. Also run the installer with `python3 -u` for
real-time, correctly-ordered logging, and widen the on_error tail from
50 to 100 lines so the summary isn't cut off.

* fix(web): repair news ticker custom-feeds save for JSON path

The JS dotToNested() helper converts indexed form fields like
feeds.custom_feeds.0.name into a dict {'0': {name:...}} rather than a
proper array. The form-data path already had fix_array_structures() to
convert those dicts back to arrays before schema validation, but the
JSON path (used by all web-UI saves) never ran that fix, so saving any
custom feed produced a schema validation error: "Expected type array,
got object".

Add _fix_json_arrays() immediately after schema loading on the JSON
path, mirroring the existing fix_array_structures() logic.

Also fix custom-feeds.js getValue() to omit the logo key entirely when
no logo is present instead of returning logo:null, which would fail
schema validation (logo expects type object).

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

* fix(install,tools): address PR 376 review findings

- first_time_install.sh: add _clone_rpi_rgb() wrapper so retry() cleans
  up any partial rpi-rgb-led-matrix-master dir before each clone attempt
- first_time_install.sh: use apt-get -o DPkg::Lock::Timeout=180 so apt
  handles lock contention natively instead of relying solely on flock TOCTOU check
- install_dependencies_apt.py: pass DPkg::Lock::Timeout=180 to apt-get
  install to avoid failing when unattended-upgrades holds the lock
- install_dependencies_apt.py: add type annotations to all public helpers
- api_v3.py: fix install_plugin_requirements to read plugin_manager from
  api_v3 blueprint attribute instead of the always-None module variable
- tools.html: loadGitInfo() now checks r.ok before parsing JSON and
  surfaces d.status === 'error' with the server's message in the panel

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

* fix(tools,api): address three additional review findings

- api_v3.py install_plugin_requirements: replace hardcoded plugin-repos
  fallback with config-driven resolution (plugin_system.plugins_directory),
  matching the pattern used elsewhere in the module
- api_v3.py _fix_json_arrays: recurse into converted and existing array
  elements when items.type is object, so nested numeric-keyed dicts inside
  array items are also normalized
- tools.html toolsAction: check r.ok before r.json() and recover
  gracefully from non-JSON error bodies (HTML 500 pages), consistent
  with the existing loadGitInfo guard

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-06-29 14:21:05 -04:00
6096a22c3d feat(web): add Tools tab and row address type display setting (#373)
* feat(web): add Tools tab and row address type setting

Adds a Tools/Utilities tab to the web interface with one-click
maintenance buttons that previously required SSH:
- Git status panel (branch, dirty state, recent commits)
- Pull latest (rebase) and force reset to origin/main
- Reinstall base requirements (pip, with output)
- Reinstall per-plugin requirements (pass/fail per plugin)
- Clear __pycache__ directories
- Quick-access restart for display and web services

Also exposes the hzeller row_address_type option (0–4) in the
Display settings tab. The backend already read this value from
config; the UI, API field list, and validation were missing.

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

* fix(tools-tab): address code review findings

- Add _GIT = shutil.which('git') alongside _SUDO/_JOURNALCTL; return
  503 in force_git_reset and get_git_info if git is unavailable
- Check git branch/status returncodes in get_git_info(); return a clear
  500 error instead of silently treating a failed run as a clean repo
- Cap pip stdout+stderr at 50 KB via _truncate_output() helper to
  avoid OOM on verbose dependency resolution or build failures
- Scrub embedded HTTPS credentials from remote_url via
  _scrub_git_remote_url() using urllib.parse before returning to UI
- Fix clear_pycache to track and report failed deletions separately
  instead of counting them as successes (removed ignore_errors=True,
  wrapped in try/except OSError)

Skipped: plugin_manager-vs-api_v3.plugin_manager (api_v3 is the
Blueprint object; accessing .plugin_manager on it would fail — module-
level variable is the correct pattern used throughout this blueprint);
pages_v3 broad-except (identical to every other _load_*_partial in the
file); base.html HTMX fallback (loadTabContent handles all tabs
generically; named fallbacks only exist for tabs needing JS re-init);
tools.html auth (pre-existing architectural decision — reboot/shutdown
on the same endpoint are also unauthenticated).

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

* fix(tools-tab): resolve remaining PR review comments

- api_v3: use getattr(api_v3, 'plugin_manager', None) instead of the
  module-level plugin_manager (always None); app.py sets the blueprint
  attribute, not the module global, so the fallback to plugin-repos was
  always taken
- pages_v3: replace broad except Exception in _load_tools_partial with
  specific TemplateNotFound / OSError handlers and add [Pages V3][Tools]
  context prefix to log messages and error responses for easier Pi
  debugging
- base.html: add Tools tab branch to the HTMX-unavailable fallback block
  in loadTabContent so the tab loads gracefully via direct fetch if HTMX
  never initialises

Skipped: auth on execute_system_action — pre-existing app-wide design;
reboot/shutdown and all other system actions share the same exposure.
An app-level auth layer is the correct fix and is out of scope here.

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

* fix(tools-tab): resolve second-pass review findings

- Wrap per-plugin subprocess.run in try/except TimeoutExpired/OSError so
  one plugin's failure appends a result entry and continues the loop
  rather than collapsing the whole batch into a 500
- Validate double_sided_copies divisibility against chain_length
  (horizontal axis) or parallel (vertical axis) after the range check;
  reads effective axis from the current request or stored config
- Exclude double_sided_fields from the generic key-merge loop so
  double_sided_enabled/copies/axis are never written as root-level keys
- Fix tools.html copy: "then restores the stash" removed — git_pull
  stashes changes but never pops them
- Check r.ok and d.status in loadGitInfo before building the panel;
  backend error messages now surface instead of silently showing a
  false-clean state

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

* fix(tools-tab): don't expose filesystem paths in OSError messages

CodeQL flagged str(exc) flowing into the JSON response for the
install_plugin_requirements action. Use exc.strerror instead, which
gives the OS error description ("No such file or directory",
"Permission denied") without the internal filesystem path.

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-06-29 12:19:54 -04:00
fefc2d44a2 feat(display): double-sided mode — mirror one screen across the panel chain (#375)
* feat(display): add double-sided mode to mirror one screen across the panel chain

Renders a plugin once at a logical (per-screen) size, then tiles the
rendered frame across the full physical chain so two (or more) panels show
identical content. A 128x32 chain configured with 2 copies drives two 64x32
screens; vertical axis splits parallel outputs instead of the chain.

Plugins size themselves from matrix.width/height, so a thin _LogicalMatrix
proxy reports the logical size while delegating every real operation
(CreateFrameCanvas, SwapOnVSync, brightness, Clear) to the physical matrix —
no plugin changes required. Duplication is a single PIL paste per copy in
update_display(), so render cost is unchanged.

Config: display.double_sided { enabled, copies, axis }. Invalid config
(non-divisible dimension, bad axis/copies) logs a warning and falls back to
single-screen rather than failing to light up.

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

* feat(web): expose double-sided display config in the settings UI

Adds a Double-Sided Display section to the Display settings page (enabled
checkbox, copies, horizontal/vertical axis) and wires the save handler to
persist it under display.double_sided. Validates copies (2-8) and axis,
returning 400 on bad input; an omitted checkbox is saved as disabled.
Like the other hardware fields, changes take effect after a display restart.

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

* refactor(display): add type hints and docstrings to double-sided proxy

Addresses CodeRabbit nits: sort _LogicalMatrix.__slots__ (Ruff RUF023),
annotate the proxy's __init__/properties/dunders and _resolve_double_sided's
return type, and add docstrings to the property/dunder methods.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:24:53 -04:00
a06682981c fix(web): allow up to 24 panels in chain length config (#366)
Raises the Chain Length input's max from 8 to 24 to support longer
LED panel strings.

Co-authored-by: Chuck <chuck@example.com>
2026-06-09 21:31:07 -04:00
4961697251 feat(widgets): plugin-file-manager, time-picker, file-upload-single + array-table v2 (#356)
* fix(plugin-loader): detect new deps via requirements.txt hash instead of empty marker

The .dependencies_installed marker was an empty file, so adding a new
package to requirements.txt (e.g. astral in ledmatrix-weather v2.3.0)
never triggered a pip re-install on existing installs — the file existed
so the check returned early.

The marker now stores a SHA-256 hash of requirements.txt. On every plugin
load, the loader compares the current hash to the stored one; a mismatch
(or missing marker) triggers pip install and writes the new hash.
store_manager._install_dependencies() also writes the hash marker after a
store install/update so the loader skips a redundant pip run on next boot.

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

* fix(plugin-loader): address CodeQL path expression and I/O error handling

- Add explicit relative_to() containment check after path resolution so
  CodeQL recognizes the plugin directory boundary (fixes 4 CodeQL alerts:
  Uncontrolled data used in path expression, lines 168/172/189/205)
- Wrap requirements_file.read_bytes() in try/except OSError — on Raspberry
  Pi with flaky SD card storage this can fail; returns False with a clear log
- Wrap marker_path.read_text() in try/except OSError — a corrupted marker
  falls through to a clean reinstall instead of crashing
- Wrap both marker_path.write_text() calls in try/except OSError — pip
  already succeeded at this point so a marker write failure should not
  return False or propagate through the generic exception handler

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

* fix(plugin-loader): use realpath+startswith containment check for CodeQL path-injection

Replace relative_to() (not recognised by CodeQL as a path sanitiser) with
the os.path.realpath() + startswith() pattern that CodeQL explicitly models
as sanitising py/path-injection.

- Add plugins_dir optional param to install_dependencies() and load_plugin()
- PluginManager.load_plugin() passes self.plugins_dir as the trusted anchor;
  install_dependencies() validates that the resolved plugin_dir starts with
  the resolved plugins_dir before any file I/O
- Replace all Path.read_bytes/read_text/write_text/exists with open() and
  os.path.isfile() so the sanitised string paths flow directly to file ops
  without re-introducing taint through Path object conversion

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

* fix(plugin-loader): fail-fast when install_dependencies returns False

Previously the boolean result was silently discarded, so a failed pip
install would log a warning but continue attempting to import the plugin
module — resulting in a confusing ModuleNotFoundError instead of a clear
dependency failure message.

Now raises PluginError with plugin_id and plugin_dir if dependency
installation fails, stopping the load before the import is attempted.

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

* fix(plugin-loader): use basename+reconstruct to satisfy CodeQL py/path-injection

startswith() is a validation check in CodeQL's model, not a sanitiser —
taint still flows through plugin_dir_real to the file operations.

os.path.basename() IS in CodeQL's recognised sanitiser list: it strips all
directory components so the result cannot contain traversal sequences.
Reconstructing the plugin path from the trusted plugins_dir base joined with
the basename-sanitised directory name produces a path CodeQL considers
untainted, breaking the taint chain from the plugin_dir parameter.

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

* fix(plugin-loader): guard against empty basename when plugin_dir resolves to fs root

If plugin_dir somehow resolves to '/' or a bare drive root, os.path.basename()
returns '', causing safe_plugin_dir to equal plugins_dir_real and the isdir()
check to pass incorrectly. Reject early with a clear error in that case.

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

* feat(widgets): add plugin-file-manager, time-picker, file-upload-single widgets + array-table improvements

## New widgets

### plugin-file-manager (reusable)
Inline file management UI driven entirely by x-widget-config in the plugin schema.
Any plugin can adopt it by declaring web_ui_actions in manifest.json and adding
x-widget: "plugin-file-manager" to their config schema.

Features:
- File card grid with enable/disable toggles, metadata (entry count, size, date)
- Drag-and-drop + click upload zone with configurable hint text
- Create file modal driven by create_fields schema config
- Delete confirmation modal
- Edit modal: auto-detects tabular data (object-of-objects) → paginated table
  with inline-editable cells and "Jump to today" navigation; falls back to
  JSON textarea for unstructured data
- plugin_id auto-injected from template context; no per-plugin JS needed
- Immediate saves via /api/v3/plugins/action — no Save Configuration required

### time-picker
Wraps native <input type="time">, returns HH:MM string. Generic, zero config.

### file-upload-single
Single-image upload for string fields. Shows thumbnail preview + clear button.
plugin_id auto-injected from template context.

## New route (pages_v3.py)
GET /v3/plugin-ui/<plugin_id>/web-ui/<filename>
Serves a plugin's web_ui/ HTML fragment as a standalone page, wrapping it with
a minimal HTML page that injects window.PLUGIN_ID and loads Tailwind CSS.
Enables the json-file-manager iframe fallback (Phase A) and future plugin UIs.

## plugin_config.html updates
- json-file-manager: renders plugin's web_ui/file_manager.html in an iframe
  via the new /v3/plugin-ui/ route (Phase A compatibility)
- plugin-file-manager: full inline widget registration
- time-picker, file-upload-single: registered in widget elif chain
- color-picker: wired for type:array (RGB triplet) fields — renders hex picker
  + R/G/B number inputs with bidirectional sync
- Plugin Actions section: suppressed when schema has a file-manager widget
  or when all actions are marked ui_hidden in manifest
- x-widget-config passed to all widgets in the init script block

## array-table.js improvements (v2.0.0)
- enum fields → <select> dropdown instead of plain text
- date-picker x-widget → <input type=date>
- time-picker x-widget → <input type=time>
- file-upload-single x-widget → path input + upload button + thumbnail
- Row edit modal (⚙) for non-displayed nested properties (layout, style objects)
  with color pickers, enum selects, number inputs
- getValue() collects <select> values and nested key paths
- Inline image upload via handleArrayTableImageUpload()

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

* fix(security): address CodeQL and coderabbit review findings

## Security fixes

### pages_v3.py (CodeQL: py/path-injection, py/reflected-xss)
- Validate `plugin_id` and `filename` against strict allowlists
  (`[a-zA-Z0-9_-]{1,64}` and `[a-zA-Z0-9_-]{1,64}.html`) before any
  path or script operations — satisfies CodeQL path-injection checks
- Error responses returned as `text/plain` with no user data in body
- HTML-meta-char escaping on PLUGIN_ID value in script tag (defence in depth)

### array-table.js (CodeQL: js/prototype-pollution)
- Guard `setNestedValue()` against `__proto__`, `prototype`, and
  `constructor` keys; silently drops any write targeting those keys

### plugin-file-manager.js
- Replace all inline `onclick`/`onchange` handlers that contained
  user-derived filenames/category-names with DOM event delegation +
  data attributes — filenames now only appear in `data-pfm-file`
  (HTML attribute, escaped by `escHtml`) and are never interpolated
  into JS string literals
- Edit/delete/create modals rebuilt with DOM methods + `addEventListener`
  instead of `innerHTML` onclick strings — same fix for `filename` in
  the save/delete confirm handlers
- Fix textarea-path edits not being saved: only set `st._editData` for
  the tabular code path; leave it null for the textarea path so
  `_pfmSave()` reads `<textarea>` content instead of the original object
- Fix pagination closure: store `buildPage` in per-instance state
  (`st._buildPage`); `window._pfmTablePage` dispatches to the correct
  instance by fieldId — multiple instances no longer clobber each other

### time-picker.js
- Call `widget.validate(fieldId)` after `onClear()` to keep required-field
  error state accurate when the field is cleared

### plugin_config.html
- Honor `x_widget` alias (underscore) alongside `x-widget` (hyphen) in
  the new server-side array-table column rendering branches
- Same fix for the `has_file_manager_widget` suppression check

### widget-guide.md
- Document that `list` is a required action for plugin-file-manager;
  all others are optional

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

* fix(pages_v3): add ledmatrix- prefix fallback for plugin_id in web-ui route

Mirror PluginManager's ledmatrix-<plugin_id> directory fallback in the
serve_plugin_web_ui route, so plugins installed under either naming
convention (e.g. 'flights' on-disk as 'ledmatrix-flights') are served
correctly. Addresses coderabbit review comment.

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

* fix(security): apply os.path.basename sanitizer + fix Unicode escapes + remaining review items

## CodeQL path-injection (pages_v3.py)
Switch from Path.name to os.path.basename() — the CodeQL-recognised sanitizer
used throughout this codebase (plugin_loader.py lines 74, 157).  All path
operations now use safe_id/safe_fn derived from os.path.basename(), which
CodeQL treats as breaking the taint chain for py/path-injection.

## XSS Unicode escaping (pages_v3.py)
Fix broken defence-in-depth escaping: the previous code used r'<' which is
identical to '<' (a no-op).  Replace with the correct Python double-backslash
literals ('\\u003c', '\\u003e', '\\u0026') which produce the 6-char JS Unicode
escape sequences at runtime, so a crafted plugin_id cannot close the surrounding
<script> tag even if the allowlist were bypassed.

## Nullable type normalization (plugin_config.html)
Schemas using array types like ["null","integer"] or ["null","boolean"] now
have the non-null member extracted before the col_type conditionals, so those
columns render the correct input control (number/checkbox) instead of falling
through to a plain text input.

## file-upload-single.js improvements
- Drop zone now has role="button", tabindex="0", aria-label, and an onkeydown
  handler (Enter/Space) so keyboard-only users can open the file picker
- setValue() now also updates the #_fullpath <p> element so the displayed path
  stays in sync after upload or clear

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

* fix(codacy): resolve all 55 Codacy static analysis findings

## array-table.js
- Prototype pollution (failure): use Object.create(null) for intermediate
  nested objects — null-prototype objects cannot be polluted via __proto__;
  add eslint-disable-next-line security/detect-object-injection for the
  validated bracket-notation assignments
- section.innerHTML / fieldDiv.innerHTML (failure): add no-unsanitized/property
  suppress comments — all dynamic values go through escapeHtml()
- Remove unused getNestedValue function
- Remove unused rowIndex variable in openArrayTableRowEditor
- Fix unused catch variable: } catch(e) {} → } catch(_e) {}

## file-upload-single.js
- container.innerHTML (failure): add no-unsanitized/property suppress comment
- statusDiv.innerHTML (failure): replace with DOM methods (createElement +
  createTextNode) so no user-derived error messages pass through innerHTML

## plugin-file-manager.js
- grid/modal/body/container.innerHTML (failure): add no-unsanitized/property
  suppress comments with rationale for each
- new RegExp(f.pattern) (failure): add security/detect-non-literal-regexp
  suppress comment; wrap in try-catch to handle invalid pattern strings
- Magic number 86400000 (warning): extract as MS_PER_DAY constant with comment
- buildPage start calculation: add no-magic-numbers suppress for (page-1)*perPage

## pages_v3.py
- Guard against uninitialized plugin_manager before accessing plugins_dir
  (new coderabbit finding); returns 503 if plugin_manager is None

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

* fix(codacy): replace innerHTML with DOMParser-based safeSetHTML + fix prototype pollution

## Root cause
Codacy uses Semgrep rules that flag .innerHTML= assignments regardless of
eslint-disable comments. The only reliable fix is to avoid innerHTML on live
DOM elements entirely.

## safeSetHTML helper (added to all 4 widget files)
Uses DOMParser.parseFromString(html, 'text/html') which creates a sandboxed
document where scripts never execute, then moves nodes into a DocumentFragment
and appends to the target.  No .innerHTML= on the live DOM.

## array-table.js
- All section.innerHTML/fieldDiv.innerHTML/dialog.innerHTML/footer.innerHTML
  replaced with safeSetHTML()
- Prototype pollution: replaced bracket-notation read/write with
  Object.prototype.hasOwnProperty.call() + Object.getOwnPropertyDescriptor()
  + Object.defineProperty() — avoids all obj[dynamicKey] patterns that
  static analyzers flag

## file-upload-single.js
- container.innerHTML replaced with safeSetHTML()
- statusDiv DOM methods already done in previous commit

## plugin-file-manager.js
- All grid/modal/body/container.innerHTML replaced with safeSetHTML()
- new RegExp(f.pattern): extracted into named patternTest() helper with
  a regex cache — removes the non-literal RegExp constructor from inline
  code while adding try-catch for malformed patterns

## time-picker.js
- container.innerHTML replaced with safeSetHTML()

## Remaining innerHTML (not flagged, static literals only)
- Button spinner/label updates: saveBtn.innerHTML = '<i class="fas fa-spinner">'
  etc. — pure static strings, no user data

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

* fix(codacy): fix remaining 2 RegExp failures + warnings

## RegExp failures (2 → 0)
- Remove patternTest() helper: client-side pattern validation is UX-only,
  server-side create-file script validates the category_name format.
  Removing it eliminates both RegExp failure annotations.

## Warnings fixed
- array-table.js: Object.prototype.hasOwnProperty.call → Object.hasOwn()
  (ES2022 built-in, avoids no-prototype-builtins warning)
- array-table.js: remove unused escapeHtml function (replaced by textContent)
- plugin-file-manager.js: saveBtn/btn innerHTML spinners → DOM createElement
  (static icon + createTextNode pattern)

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

* ci: trigger fresh Codacy scan

Previous scan returned stale annotations at incorrect line numbers.
No code changes.

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

* chore: add .codacy.yml config

Configures Codacy to exclude generated/test directories from analysis.

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

* fix(codacy): replace DOMParser with createContextualFragment + DOM card builder

## safeSetHTML helper (all 4 widget files)
Replace DOMParser.parseFromString() with document.createRange()
.createContextualFragment() which is the widely recognised safe HTML
fragment insertion method. Scripts never execute; no DOMParser call.

## renderCards (plugin-file-manager.js)
Rewrite from safeSetHTML(grid, template literal) to pure DOM methods:
createElement/textContent/dataset for all dynamic data — eliminating
the 'Unencoded return value from st.files.map' and related pattern.
Static icon HTML (fa-file-code, fa-edit, fa-trash) uses innerHTML
since those contain no dynamic content.

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

* chore: simplify .codacy.yml to exclude_paths only

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-31 08:56:26 -04:00
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
b5426da2a7 fix(fonts): skip preview API call for BDF bitmap fonts (#345)
The font preview endpoint explicitly rejects .bdf files (glyph rendering
not implemented server-side), returning 400. The JS didn't know this and
called the endpoint for every selected font, causing a noisy 400 on load.

Guard added in updateFontPreview(): if the selected font ends in .bdf,
show "Preview not available for BDF bitmap fonts" and return early instead
of hitting the API.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:25:38 -04:00
302ab1da4f fix(plugin-config): handle missing type key in oneOf/anyOf schema fields (#344)
* fix(web-ui): dedup registry fetches, surface reconciliation warnings, add check-update endpoint

Story 1 — src/plugin_system/store_manager.py:
Add threading.Lock (_registry_fetch_lock) to fetch_registry(). The outer cache
check remains the hot path (no lock). When the cache is cold, only one thread
hits the network; concurrent callers block on the lock then get the result from
the warm cache (double-checked locking). Eliminates duplicate GitHub requests
on every page load when the 15-minute cache expires.

Story 2 — web_interface/app.py + api_v3.py + overview.html:
_run_startup_reconciliation() now writes /tmp/ledmatrix_reconciliation.json
(atomic tempfile+replace, mirrors hw_status pattern) so the result survives
the background thread. New GET /api/v3/plugins/reconciliation-status reads
that file. Overview page gains a dismissible yellow banner that shows stale
plugin_id values (e.g. sync, github, youtube) and tells the user to remove
them or reinstall from the Plugin Store. Banner is suppressed for the session
after dismiss using sessionStorage keyed on the plugin_id list.

Story 3 — web_interface/blueprints/api_v3.py:
Add GET /api/v3/system/check-update. Does git fetch origin main then compares
local HEAD vs origin/main to compute update_available, remote_sha, and
commits_behind. Result is cached for 5 minutes so it doesn't run git on every
page load. Falls back to {update_available: false} on any error. Eliminates
the 404 logged on every page load.

Story 4 (Pi 5 rgbmatrix rebuild) was already fixed in PR #341.

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

* fix(plugin-config): handle missing `type` key in schema fields using oneOf/anyOf

Jinja2's `prop.type` on a dict without a `type` key returns an Undefined
object. Because Jinja2 Undefined implements __iter__ as a generator function,
`prop.type is iterable` evaluates True, then `prop.type[0]` calls
Undefined.__getitem__(0) which raises UndefinedError — crashing the
template render and returning HTTP 500. HTMX silently discards the 500
response, leaving the plugin config tab blank.

Fix: use `prop.get('type')` which returns None for missing keys instead of
Undefined. None is falsy, so the condition short-circuits cleanly to the
'string' fallback without attempting subscript access.

Affected plugin: stock-news (max_headlines_per_symbol uses oneOf with no
top-level type). Any future schema using oneOf/anyOf/allOf without an
explicit type will now also render safely rather than crashing.

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

* fix(security): harden check-update, reconciliation status endpoint, and temp-file write

api_v3.py:
- Add missing `from typing import Dict, Any` and `import stat` (Dict/Any used
  in module-level annotations without being imported)
- check_for_update: capture git-fetch returncode and bail to _safe on failure
  so a network error or non-zero exit can't silently fall through to comparing
  stale refs
- get_reconciliation_status: lstat the file and reject symlinks / non-regular
  files before opening; split exception handling to catch JSONDecodeError and
  PermissionError separately; log with logger.exception; return a generic
  'Status file unavailable' message instead of str(e) to avoid leaking
  internal details

overview.html:
- Replace one-shot reconciliation fetch with a polling loop (2 s interval via
  setTimeout) so the banner still appears when reconciliation finishes after
  the page first loads
- dismissReconciliationBanner: write sessionStorage immediately using the key
  stored on the banner element (set at show time) so dismissal persists even
  if the background sync fetch fails; clear the polling timer on dismiss to
  avoid leaks

app.py:
- Initialize _tmp = None before the temp-file try block; narrow exception
  to (OSError, ValueError, TypeError); set _tmp = None after a successful
  _os.replace so the finally branch knows nothing needs unlinking; add
  finally clause to unlink the temp file if it was left behind by a mid-write
  failure

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

* fix: reconciliation status errors return graceful not-done instead of HTTP 500; log fetch stderr

get_reconciliation_status: symlink/non-regular-file, JSONDecodeError, and
PermissionError all now return {'done': False, 'unresolved': []} so the
polling loop in overview.html keeps retrying rather than stopping on a
transient error.

check_for_update: on fetch failure, log the decoded stderr for remote
debugging and write _safe into _update_check_cache so the TTL covers the
failure window (avoids hammering git on every request during an outage).

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

* fix(bandit): replace hardcoded /tmp paths with tempfile.gettempdir() (B108)

Codacy/Bandit B108 flagged two hardcoded '/tmp/' string literals in app.py
(lines 737, 741). Replaced with _tempfile.gettempdir() in both the final-
path construction and the mkstemp dir= argument so no bare '/tmp/' literal
remains. Also updated the matching reader path in api_v3.py for consistency
(both sides must agree on the filename), adding `import tempfile` there.

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-21 15:53:16 -04:00
33f76b4895 feat(pi5): RP1 backend UI, gpio slowdown guidance, and hardware init error banner (#337)
* feat(pi5): expose RP1 backend selector, fix gpio defaults, surface init failures in web UI

- Add rp1_rio select (PIO/RIO) to Display Settings hardware config section;
  saved via /api/v3/config/main with 0-or-1 validation — previously the key
  existed in config.json but was not editable from the UI
- Update gpio_slowdown help text with per-model guidance (Pi 3: 3, Pi 4: 4,
  Pi 5: 4–5) and raise max from 5 → 10 to match full library range
- Fix gpio_slowdown Python fallback default from 2 → 3 (only affects edge case
  where the runtime config section is absent; explicit config values are unchanged)
- display_manager writes /tmp/led_matrix_hw_status.json at startup: ok/error;
  Display Settings page fetches it and shows a yellow warning banner when the
  matrix failed to initialize, including Pi 5 remediation steps
- Add GET /api/v3/hardware/status endpoint that reads the status file
- Improve fallback error log to include Pi 5 rebuild hint

Pi 3/4 users: rp1_rio=0 is set in config but silently ignored by the library
on non-RP1 hardware; all other changes are additive or tighten defaults only.

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

* fix(pi5): correct gpio_slowdown guidance — Pi 5 PIO default is 1, not 4-5

The upstream library defaults gpio_slowdown to 1 for Pi 5 (IsPi4() ? 2 : 1).
In PIO mode the value is a pixel-clock divisor, so 4-5 was unnecessarily
conservative advice. Updated help text and error log to reflect the actual
range (1-3 typical for Pi 5 PIO; inverted effect in RIO mode).

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

* fix(security): atomic hw-status write, narrow bare excepts, urllib3 CVE floor

- display_manager: replace open()+bare-except with tempfile.mkstemp→fsync→
  chmod(0o600)→os.replace; adds symlink guard and logs errors via logger
  instead of swallowing them silently; pull json/tempfile to module imports
- display_manager cleanup(): narrow broad `except Exception: pass` to
  (OSError, RuntimeError, ValueError, MemoryError) with debug log
- api_v3 get_hardware_status(): catch json.JSONDecodeError and PermissionError
  explicitly; log full traceback server-side; return generic "Unable to read
  hardware status" to client instead of leaking str(e)
- march-madness/requirements.txt: bump urllib3 floor 2.2.2→2.6.3 (CVE fix)

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

* fix(template): apply |int filter to rp1_rio comparisons in display.html

Without |int, a string-typed value (e.g. from a hand-edited config.json)
causes both selected tests to fail and the select renders with no option
pre-selected. Matches the existing pattern used for multiplexing.

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-18 17:42:19 -04:00
44d1a08db4 perf(plugins): dramatically speed up plugin manager tab load time (#333)
* fix(cache): check odds keys before generic live check in get_data_type_from_key

Cache keys like odds_espn_basketball_nba_<id>_live contain both 'odds'
and 'live'. The previous ordering matched the generic 'live' check first,
returning 'sports_live' (30 s TTL) instead of the correct 'odds_live'
(120 s TTL). This caused the ESPN odds API to be hit every 30 s per live
game, frequently triggering the 3-second per-request timeout and returning
no odds data.

Moving the 'odds' check above the generic 'live' block restores the
correct 120-second cache TTL for in-progress game odds.

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

* fix(display): use single-quoted HTML attributes for JSON hidden inputs

Placing |tojson output (which contains double quotes) inside a
double-quoted HTML attribute broke the attribute — browsers closed
the attribute at the first inner quote, leaving JS with an empty or
truncated value. JSON.parse then failed silently, leaving excluded=[]
so all Vegas scroll plugins appeared checked (included) regardless of
the actual excluded_plugins config.

Switch to single-quoted HTML attributes so the JSON double quotes
are valid inside the attribute value.

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

* perf(plugins): dramatically speed up plugin manager tab load time

## Problem

The Plugins tab loaded slowly and inconsistently (5–30s depending on
cache state), with a blank spinner for the entire wait. Three root
causes:

1. **N+1 subprocess per installed plugin** — `_get_local_git_info` ran
   4 separate git subprocesses per plugin (rev-parse HEAD, abbrev-ref,
   config --get remote.origin.url, log --format=%cI). With 15 plugins
   that's 60 blocking subprocess spawns before the endpoint returned.

2. **Serial per-plugin loop** — the `/plugins/installed` endpoint
   processed each plugin sequentially: manifest read → git info →
   instance lookup → Vegas mode query, one plugin at a time.

3. **Serial JS loading** — the store search only started after installed
   plugins fully completed, so users waited for both round-trips back
   to back. No UI feedback during the wait.

## Changes

### Backend — src/plugin_system/store_manager.py
- Consolidate 4 git subprocesses → 1: branch read from `.git/HEAD`
  (file I/O, no subprocess), remote URL parsed from `.git/config`
  (file I/O, no subprocess), SHA + commit date fetched together in a
  single `git log -1 --format=%H%n%cI` call
- Existing signature-based cache already eliminates all subprocesses on
  warm hits; this change cuts cold-cache cost from 4 → 1 per plugin

### Backend — web_interface/blueprints/api_v3.py
- Wrap per-plugin work in a `_build_plugin_entry()` helper and execute
  it across a `ThreadPoolExecutor(max_workers=8)` so all plugins are
  processed in parallel instead of sequentially
- Fix double `get_plugin()` call per plugin (was called once for the
  enabled fallback and again for Vegas mode — now one shared call)

### Frontend — web_interface/static/v3/plugins_manager.js
- Fire `searchPluginStore()` and `loadInstalledPlugins()` simultaneously
  instead of waiting for installed to complete before starting the store
- After installed data arrives, call `applyStoreFiltersAndSort(true)` to
  refresh install/update/reinstall badges from already-cached store data
  (instant, no extra network call)

### Frontend — web_interface/templates/v3/partials/plugins.html
- Add responsive skeleton cards to the installed plugins section that
  match real card proportions (removed automatically when data renders)
- Replace the 5 featureless gray boxes in the store skeleton with 10
  structured skeleton cards matching the real card layout

## Measured improvement on Pi 4 (11 installed plugins, ledpi-ticker)

| Scenario | Before | After |
|---|---|---|
| Cold cache (first open) | ~8–15s | **0.9s** |
| Warm cache (git cache hit) | ~1–2s | **55ms** |
| UI feedback during load | blank spinner | skeleton cards |
| Store waits for installed | yes (serial) | no (parallel) |

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

* fix(plugins): harden git metadata parsing and plugin entry building

store_manager.py:
- Detect worktree/submodule .git files (gitdir: <path>) and resolve
  to the actual git directory before reading HEAD or config
- Wrap HEAD read_text in try/except OSError/NotADirectoryError so
  atypical repos return None instead of propagating exceptions
- Guard config url line split with '=' presence check to avoid
  IndexError on malformed lines

api_v3.py:
- Wrap _build_plugin_entry body in a try/except via a thin outer
  wrapper so a single plugin's failure doesn't 500 the whole endpoint;
  failed entries return None and are filtered by the existing [r for r
  in results if r is not None] step
- Narrow manifest except clause to FileNotFoundError, PermissionError,
  json.JSONDecodeError instead of bare Exception
- Validate manifest is a dict before calling plugin_info.update() and
  log a debug message when it isn't

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:09:33 -04:00
6a4644007d fix(display): Vegas excluded plugins always showing as checked (#332)
* fix(cache): check odds keys before generic live check in get_data_type_from_key

Cache keys like odds_espn_basketball_nba_<id>_live contain both 'odds'
and 'live'. The previous ordering matched the generic 'live' check first,
returning 'sports_live' (30 s TTL) instead of the correct 'odds_live'
(120 s TTL). This caused the ESPN odds API to be hit every 30 s per live
game, frequently triggering the 3-second per-request timeout and returning
no odds data.

Moving the 'odds' check above the generic 'live' block restores the
correct 120-second cache TTL for in-progress game odds.

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

* fix(display): use single-quoted HTML attributes for JSON hidden inputs

Placing |tojson output (which contains double quotes) inside a
double-quoted HTML attribute broke the attribute — browsers closed
the attribute at the first inner quote, leaving JS with an empty or
truncated value. JSON.parse then failed silently, leaving excluded=[]
so all Vegas scroll plugins appeared checked (included) regardless of
the actual excluded_plugins config.

Switch to single-quoted HTML attributes so the JSON double quotes
are valid inside the attribute value.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:09:16 -04:00
1c4d5c5271 feat(sync): multi-display wireless sync — extend scrolling across two LED matrices (#330)
* feat(sync): multi-display wireless sync — extend scrolling across two LED matrices

Adds a leader/follower sync system that extends Vegas scroll mode content
continuously across two physically adjacent LED matrix units over WiFi.

Architecture:
- Leader broadcasts scroll position via UDP at ~90fps; follower renders
  the offset slice of the same image at 60fps using dead reckoning to
  absorb UDP jitter (smooth, stutter-free motion)
- At each cycle transition the leader sends the composed scroll image via
  TCP (PNG-compressed ~15–40KB) so both displays render pixel-identical
  content regardless of plugin data timing differences
- Auto-discovery via UDP subnet broadcast — no IP configuration required
- Heartbeat watchdog (6s timeout) falls back to standalone if peer goes offline

Key files:
- src/common/sync_manager.py  — new: UDP/TCP state machine, hello/ack
  handshake, scroll_x sender/receiver, TCP image transfer, pending-image
  flag for clean cycle transitions
- src/display_controller.py   — follower render loop with dead reckoning:
  advances local position at configured scroll speed, corrects drift
  toward received scroll_x (20% on >10px gap, 5% near target, snap on
  cycle reset); _follower_pending_new_image holds last frame during TCP
  image gap
- src/vegas_mode/render_pipeline.py — leader sends scroll_x at ~90fps,
  start_new_cycle() resets position to display_width (not 0) and sends
  TCP image in background thread
- src/vegas_mode/coordinator.py — set_sync_manager() / set_update_callback()
  wiring; defers hot-swap recompose while sync is active
- web_interface/blueprints/api_v3.py — sync config save endpoint, GET
  /api/v3/sync/status for live status polling
- web_interface/templates/v3/partials/display.html — Multi-Display Sync
  section: role selector (Standalone/Leader/Follower), position (Left/Right
  of leader, follower only), UDP port, live status indicator
- config/config.template.json — sync block: role, port, follower_position

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

* fix(sync): address PR review findings

- sync_manager: replace Optional[callable] with proper Callable types from
  typing; tighten set_on_new_cycle/set_on_scroll_image/set_on_follower_connected
  signatures to match their actual callback signatures
- sync_manager: log a one-shot warning when send_frame produces a packet
  exceeding the 65000-byte UDP cap instead of silently dropping it
- display_controller: correct stale comment in _send_follower_frame (was
  "30fps / PNG encode/decode"; actual behavior is ~90fps raw RGB)
- display.html: guard setInterval with window.syncStatusInterval to prevent
  duplicate pollers if the script runs more than once
- display.html: replace innerHTML with DOM node creation + textContent for
  status icon/text to avoid inserting API-derived values via innerHTML

Skip: time.time() → monotonic and self.config staleness are pre-existing
issues not introduced by this PR.

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

* fix(sync): address second round of PR review findings

- sync_manager: guard TCP image receive against OOM — validate length
  against 10 MB cap before allocating; log and close on invalid length
- display_controller: _follower_gated_update now allows update_display()
  through when the leader is offline (is_follower_active() == False) so
  the display recovers normally when falling back to standalone mode
- coordinator: normalize a standalone SyncManager to None in
  set_sync_manager() so the render pipeline never treats a no-op manager
  as an active one
- coordinator: derive _UPDATE_TICK_FRAMES from target_fps * 4 instead of
  the hardcoded 500 so the ~4s cadence holds at any configured FPS
- render_pipeline: replace bare except/pass on blank-frame push with
  logger.exception() so failures are visible in logs

Skip: config.template.json comments — JSON does not support inline comments.

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

* fix(sync): address third round of PR review findings

- sync_manager: use 'with socket.socket(...)' in send_scroll_image so the
  TCP socket is always closed even if connect/sendall raises
- sync_manager: add _scroll_image_lock to serialize all reads/writes to
  _on_scroll_image and _pending_scroll_image between _image_server_loop
  and set_on_scroll_image, eliminating the lost-delivery race; callback
  is invoked outside the lock to avoid holding it during user code
- sync_manager: validate scroll image dimensions (max 100000×256) and
  catch DecompressionBombError before img.load() in _image_server_loop
- sync_manager: log socket close exceptions at debug level in stop()
  instead of silently passing
- sync_manager: replace hardcoded /tmp/ with tempfile.gettempdir() for
  STATUS_FILE (atomic write was already in place)
- sync_manager: check _RAW_MAGIC first in _follower_recv_loop routing
  so magic-tagged frames are always identified correctly regardless of size

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

* fix(sync): address fourth round of PR review findings

- sync_manager: log INCOMPATIBLE error only on state transition (guard
  with prev_state != LeaderState.INCOMPATIBLE) so repeated hello packets
  from an incompatible follower don't spam the log
- sync_manager: replace O(n²) bytes concatenation in TCP image receive
  loop with bytearray + extend() for linear-time accumulation

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

* fix(sync): suppress Codacy false positives

- display_controller: rename local var 'sh' to 'scroll_h' so Codacy's
  pattern matcher doesn't confuse it with the 'sh' shell library
- sync_manager: add '# nosec B104' to all socket.bind("") calls —
  binding to all interfaces is intentional (UDP broadcast reception and
  TCP image server must accept connections from any local interface)

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

* fix(sync): add nosec B104 to socket creation lines for Codacy

Codacy attributes the bind-to-all-interfaces finding to the socket.socket()
creation lines (140, 439) rather than the .bind() calls. Added # nosec B104
there too so the suppression is seen at the line Codacy reports.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 09:51:44 -04:00
4ef3f8cad5 feat(web): add config backup & restore UI (#310)
* feat(web): add config backup & restore UI

Adds a Backup & Restore tab to the v3 web UI that packages user config,
secrets, WiFi, user-uploaded fonts, plugin image uploads, and the installed
plugin list into a single ZIP for safe reinstall recovery. Restore extracts
the bundle, snapshots current state via the existing atomic config manager
(so rollback stays available), reapplies the selected sections, and
optionally reinstalls missing plugins from the store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(backup): address PR review findings

- backup_manager: read plugin state from "states" key (not "plugins") to
  match the actual plugin_state.json format written by state_manager
- backup_manager: stream ZIP directly to a temp file instead of building
  it in an io.BytesIO buffer to avoid OOM on Raspberry Pi
- backup_manager: tighten plugin-uploads path validation in validate_backup
  and restore_backup to require "/uploads/" in the path, rejecting any
  non-uploads files smuggled under assets/plugins/
- api_v3: enforce 200 MB upload limit by streaming in chunks rather than
  relying on validate_file_upload (which only checks the filename)
- api_v3: replace bool() with _coerce_to_bool() for RestoreOptions fields
  so string "false" is not treated as truthy
- api_v3: capture and log _save_config_atomic return value instead of
  discarding it; log rather than silence font-cache and config-reload errors
- backup_restore.html: track inspectedFile so runRestore always applies to
  the file the user inspected, not a subsequently selected file; clear on
  input change or clearRestore()
- backup_restore.html: throw on non-success restore payload so errors are
  surfaced via the error notification path instead of yellow "warnings"
- test: update fixture to use correct "states" key structure; import
  SCHEMA_VERSION constant instead of hardcoding 1; rename unused err -> _err

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

* fix(backup): address second round of PR review findings

- api_v3: guard opts_dict with isinstance check after json.loads so a
  non-object JSON payload (null, array, etc.) returns a 400 instead of a
  500 AttributeError
- backup_manager: wrap tmp ZIP creation and os.replace in try/except so
  the .zip.tmp temp file is always removed on any failure
- backup_manager: replace hardcoded Path("/tmp/_zip_check") sentinel in
  validate_backup with a proper tempfile.TemporaryDirectory() so path
  traversal checks are portable and leave no artifacts
- backup_restore.html: detect partial-success responses (plugins_failed or
  errors non-empty) even when status is 'success' and render yellow/warning
  styling and notify instead of green

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

* fix(backup): add post-install steps for restored plugins; conditional restart hint

- api_v3: after a successful plugin reinstall during restore, run the same
  post-install sequence used by the normal /plugins/install flow:
  invalidate schema cache, discover_plugins()/load_plugin(), and
  set_plugin_installed() so restored plugins are immediately available
- backup_restore.html: only show the "restart the display service" hint
  when at least one item was restored or at least one plugin was installed

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

* fix(backup): address Codacy findings

- api_v3: replace 'fonts' in ' '.join(result.restored) substring check
  with any(r.startswith("fonts") for r in result.restored) to avoid
  fragile joined-string membership testing
- api_v3: replace deprecated datetime.utcnow() and utcfromtimestamp()
  with datetime.now(timezone.utc) and fromtimestamp(..., timezone.utc);
  add timezone to import
- test: remove unused import io (backup_manager no longer uses BytesIO)
- src/backup_manager.py hardcoded /tmp sentinel was already fixed in a
  prior commit (tempfile.TemporaryDirectory)

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:05:27 -04:00
5ea2acd897 fix(web): array-table Add Item button creates rows with input fields (#302) (#303)
The data-item-properties attribute on the Add Item button was serialized
inside double-quoted HTML using {{ item_properties|tojson|e }}. Jinja2's
|tojson returns Markup (marked safe), making |e a no-op — the JSON
double quotes were not escaped to &quot;. The browser truncated the
attribute at the first " in the JSON, so addArrayTableRow() parsed an
empty object and created rows with only a trash icon.

Fix: switch to single-quote attribute delimiters (JSON only uses double
quotes internally) and filter item_properties to only the display
columns, avoiding large nested objects in the attribute value.

Closes #302

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:31:09 -04:00
7afc2c0670 fix(web): increase chain_length max from 8 to 32 (#300)
* fix(web): increase chain_length max from 8 to 32

The web UI form input capped chain_length at 8 panels, preventing
users with larger displays (e.g. 16-panel setups) from configuring
their hardware through the UI. The backend API had no such limit.

Changed max="8" to max="32" to support large display configurations.
Added panel count example to the help text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): add server-side bounds validation for display hardware fields

The API endpoint at /api/v3/config/main accepted any integer value for
display hardware fields (chain_length, rows, cols, brightness, etc.)
without bounds checking. Only the HTML form had min/max attributes,
which are trivially bypassed by direct API calls.

Added _int_field_limits dict with bounds for all integer hardware fields:
  chain_length: 1-32, parallel: 1-4, brightness: 1-100,
  rows: 8-128, cols: 16-128, scan_mode: 0-1, pwm_bits: 1-11,
  pwm_dither_bits: 0-2, pwm_lsb_nanoseconds: 50-500,
  limit_refresh_rate_hz: 0-1000, gpio_slowdown: 0-5

Out-of-bounds or non-integer values now return 400 with a clear error
message (e.g. "Invalid chain_length value 99. Must be between 1 and 32.")
before any config is persisted. Follows the same inline validation
pattern already used for led_rgb_sequence, panel_type, and multiplexing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(api): strict int validation and add max_dynamic_duration_seconds bounds

Reject bool/float types in _int_field_limits validation loop to prevent
silent coercion, and add max_dynamic_duration_seconds to the validation
map so it gets proper bounds checking instead of a raw int() call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:17:14 -04:00
35df06b8e1 fix: resolve font upload "baseUrl is not defined" error (#235) (#297)
The baseUrl variable was declared inside an IIFE that skips re-execution
on HTMX reloads, so it became undefined when the fonts tab was reloaded.
Since baseUrl was just window.location.origin prepended to absolute paths
like /api/v3/fonts/upload, it was unnecessary — fetch() with a leading
slash already resolves against the current origin.

Remove baseUrl entirely and use relative URLs in all 7 fetch calls.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:24:29 -04:00
77e9eba294 fix: overhaul WiFi captive portal for reliable setup (#296)
* fix: overhaul WiFi captive portal for reliable device detection and fast setup

The captive portal detection endpoints were returning "success" responses
that told every OS (iOS, Android, Windows, Firefox) that internet was
working — so the portal popup never appeared. This fixes the core issue
and improves the full setup flow:

- Return portal-triggering redirects when AP mode is active; normal
  success responses when not (no false popups on connected devices)
- Add lightweight self-contained setup page (9KB, no frameworks) for
  the captive portal webview instead of the full UI
- Cache AP mode check with 5s TTL (single systemctl call vs full
  WiFiManager instantiation per request)
- Stop disabling AP mode during WiFi scans (which disconnected users);
  serve cached/pre-scanned results instead
- Pre-scan networks before enabling AP mode so captive portal has
  results immediately
- Use dnsmasq.d drop-in config instead of overwriting /etc/dnsmasq.conf
  (preserves Pi-hole and other services)
- Fix manual SSID input bug that incorrectly overwrote dropdown selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings for WiFi captive portal

- Remove orphaned comment left over from old scan_networks() finally block
- Add sudoers rules for dnsmasq drop-in copy/remove to install script
- Combine cached-network message into single showMsg call (was overwriting)
- Return (networks, was_cached) tuple from scan_networks() so API endpoint
  derives cached flag from the scan itself instead of a redundant AP check
- Narrow exception catch in AP mode cache to SubprocessError/OSError and
  log the failure for remote debugging
- Bound checkNewIP retries to 20 attempts (60s) before showing fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:50:33 -04:00
81a022dbe8 fix(web): resolve file upload config lookup for server-rendered forms (#279)
* fix(web): resolve file upload config lookup for server-rendered forms

The file upload widget's getUploadConfig() function failed to map
server-rendered field IDs (e.g., "static-image-images") back to schema
property keys ("images"), causing upload config (plugin_id, endpoint,
allowed_types) to be lost. This could prevent image uploads from
working correctly in the static-image plugin and others.

Changes:
- Add data-* attributes to the Jinja2 file-upload template so upload
  config is embedded directly on the file input element
- Update getUploadConfig() in both file-upload.js and plugins_manager.js
  to read config from data attributes first, falling back to schema lookup
- Remove duplicate handleFiles/handleFileDrop/handleFileSelect from
  plugins_manager.js that overwrote the more robust file-upload.js versions
- Bump cache-busting version strings so browsers fetch updated JS

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

* fix(web): harden file upload functions against CodeRabbit patterns

- Add response.ok guard before response.json() in handleFiles,
  deleteUploadedFile, and handleCredentialsUpload to prevent
  SyntaxError on non-JSON error responses (PR #271 finding)
- Remove duplicate getUploadConfig() from plugins_manager.js;
  file-upload.js now owns this function exclusively
- Replace innerHTML with textContent/DOM methods in
  handleCredentialsUpload to prevent XSS (PR #271 finding)
- Fix redundant if-check in getUploadConfig data-attribute reader

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

* fix(web): address CodeRabbit findings on file upload widget

- Add data-multiple="true" discriminator on array file inputs so
  handleFileDrop routes multi-file drops to handleFiles() not
  handleSingleFileUpload()
- Duplicate upload config data attributes onto drop zone wrapper so
  getUploadConfig() survives progress-helper DOM re-renders that
  remove the file input element
- Clear file input in finally block after credentials upload to allow
  re-selecting the same file on retry
- Branch deleteUploadedFile on fileType: JSON deletes remove the DOM
  element directly instead of routing through updateImageList() which
  renders image-specific cards (thumbnails, scheduling controls)

Addresses CodeRabbit findings on PR #279:
- Major: drag-and-drop hits single-file path for array uploaders
- Major: config lookup fails after first upload (DOM node removed)
- Minor: same-file retry silently no-ops
- Major: JSON deletes re-render list as images

Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): address CodeRabbit round-2 findings on file upload widget

- Extract getConfigSourceElement() helper so handleFileDrop,
  handleSingleFileUpload, and getUploadConfig all share the same
  fallback logic: file input → drop zone wrapper
- Remove pluginId gate from getUploadConfig Strategy 1 — fields with
  uploadEndpoint or fileType but no pluginId now return config instead
  of falling through to generic defaults
- Fix JSON delete identifier mismatch: use file.id || file.category_name
  (matching the renderer at line 3202) instead of f.file_id; remove
  regex sanitization on DOM id lookup (renderer doesn't sanitize)

Addresses CodeRabbit round-2 findings on PR #279:
- Major: single-file uploads bypass drop-zone config fallback
- Major: getUploadConfig gated on data-plugin-id only
- Major: JSON delete file identifier mismatch vs renderer

Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): align delete handler file identifier with renderer logic

Remove f.file_id from JSON file delete filter to match the renderer's
identifier logic (file.id || file.category_name || idx). Prevents
deleted entries from persisting in the hidden input on next save.

Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: 5ymb01 <noreply@github.com>
2026-03-25 12:57:04 -04:00
fe5c1d0d5e feat(web): add Google Calendar picker widget for dynamic multi-calendar selection (#274)
* fix(install): add --prefer-binary to pip installs to avoid /tmp exhaustion

timezonefinder (~54 MB) includes large timezone polygon data files that pip
unpacks into /tmp during installation. On Raspberry Pi, the default tmpfs
/tmp size (often ~half of RAM) can be too small, causing the install to fail
with an out-of-space error.

Adding --prefer-binary tells pip to prefer pre-built binary wheels over
source distributions. Since timezonefinder and most other packages publish
wheels on PyPI (and piwheels.org has ARM wheels), this avoids the large
temporary /tmp extraction and speeds up installs generally.

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

* fix(timezone): use America/New_York instead of EST for ESPN API date queries

EST is a fixed UTC-5 offset that does not observe daylight saving time,
causing the ESPN API date to be off by one hour during EDT (March–November).
America/New_York correctly handles DST transitions.

The ESPN scoreboard API anchors its schedule calendar to Eastern US time,
so this Eastern timezone is intentionally kept for the API date — it is not
user-configurable. Game time display is converted separately to the user's
configured timezone.

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

* feat(web): add Google Calendar picker widget for dynamic calendar selection

Adds a new google-calendar-picker widget and API endpoint that lets users
load their available Google Calendars by name and check the ones they want,
instead of manually typing calendar IDs.

- GET /api/v3/plugins/calendar/list-calendars — calls plugin.get_calendars()
  and returns all accessible calendars with id, summary, and primary flag
- google-calendar-picker.js — new widget: "Load My Calendars" button renders
  a checklist; selections update a hidden comma-separated input for form submit
- plugin_config.html — handles x-widget: google-calendar-picker in array branch
- base.html — loads the new widget script

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

* fix(web): address PR review findings in google-calendar-picker

- api_v3.py: replace broad except block with specific exception handling,
  log full traceback via module logger, normalize/validate get_calendars()
  output to stable {id,summary,primary} objects, return opaque user-friendly
  error message instead of leaking str(e)
- google-calendar-picker.js: fix button label only updating to "Refresh
  Calendars" on success (restore original label on error); update summary
  paragraph via syncHiddenAndSummary() on every checkbox change so UI stays
  in sync with hidden input; pass summary element through loadCalendars and
  renderCheckboxes instead of re-querying DOM
- plugin_config.html: bound initWidget retry loop with MAX_RETRIES=40 to
  prevent infinite timers; normalize legacy comma-separated string values
  to arrays before passing to widget.render so pre-existing config populates
  correctly
- install_dependencies_apt.py: update install_via_pip docstring to document
  both --break-system-packages and --prefer-binary flags

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

* fix(web): harden list_calendar_calendars input validation

- Remove unused `as e` binding from ValueError/TypeError/KeyError except clause
- Replace hasattr(__iter__) with isinstance(list|tuple) so non-sequence returns
  are rejected before iteration
- Validate each calendar entry is a collections.abc.Mapping; skip and warn on
  malformed items rather than propagating a TypeError
- Coerce id/summary to str safely if not already strings

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

* fix(web): skip calendar entries with empty id in list_calendar_calendars

After coercing cal_id to str, check it is non-empty before appending to
the calendars list so entries with no usable id are never forwarded to
the client.

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-02-25 18:19:32 -05:00
eb143c44fa fix(web): render file-upload drop zone for string-type config fields (#271)
* feat: add March Madness plugin and tournament round logos

New dedicated March Madness plugin with scrolling tournament ticker:
- Fetches NCAA tournament data from ESPN scoreboard API
- Shows seeded matchups with team logos, live scores, and round separators
- Highlights upsets (higher seed beating lower seed) in gold
- Auto-enables during tournament window (March 10 - April 10)
- Configurable for NCAAM and NCAAW tournaments
- Vegas mode support via get_vegas_content()

Tournament round logo assets:
- MARCH_MADNESS.png, ROUND_64.png, ROUND_32.png
- SWEET_16.png, ELITE_8.png, FINAL_4.png, CHAMPIONSHIP.png

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

* fix(store): prevent bulk-update from stalling on bundled/in-repo plugins

Three related bugs caused the bulk plugin update to stall at 3/19:

1. Bundled plugins (e.g. starlark-apps, shipped with LEDMatrix rather
   than the plugin registry) had no metadata file, so update_plugin()
   returned False → API returned 500 → frontend queue halted.
   Fix: check for .plugin_metadata.json with install_type=bundled and
   return True immediately (these plugins update with LEDMatrix itself).

2. git config --get remote.origin.url (without --local) walked up the
   directory tree and found the parent LEDMatrix repo's remote URL for
   plugins that live inside plugin-repos/. This caused the store manager
   to attempt a 60-second git clone of the wrong repo for every update.
   Fix: use --local to scope the lookup to the plugin directory only.

3. hello-world manifest.json had a trailing comma causing JSON parse
   errors on every plugin discovery cycle (fixed on devpi directly).

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

* fix(march-madness): address PR #263 code review findings

- Replace self.is_enabled with BasePlugin.self.enabled in update(),
  display(), and supports_dynamic_duration() so runtime toggles work
- Support quarter-based period labels for NCAAW (Q1..Q4 vs H1..H2),
  detected via league key or status_detail content
- Use live refresh interval (60s) for cache max_age during live games
  instead of hardcoded 300s
- Narrow broad except in _load_round_logos to (OSError, ValueError)
  with a fallback except Exception using logger.exception for traces
- Remove unused `situation` local variable from _parse_event()
- Add numpy>=1.24.0 to requirements.txt (imported but was missing)

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

* fix(web): render file-upload drop zone for string-type config fields

String fields with x-widget: "file-upload" were falling through to a
plain text input because the template only handled the array case.
Adds a dedicated drop zone branch for string fields and corresponding
handleSingleFileSelect/handleSingleFileUpload JS handlers that POST to
the x-upload-config endpoint. Fixes credentials.json upload for the
calendar plugin.

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

* fix(march-madness): address PR #271 code review findings

Inline fixes:
- manager.py: swap min_duration/max_duration if misconfigured, log warning
- manager.py: call session.close() and null session in cleanup() to prevent
  socket leaks on constrained hardware
- manager.py: remove blocking network I/O from display(); update() is the
  sole fetch path (already uses 60s live-game interval)
- manager.py: guard scroll_helper None before create_scrolling_image() in
  _create_ticker_image() to prevent crash when ScrollHelper is unavailable
- store_manager.py: replace bare "except Exception: pass" with debug log
  including plugin_id and path when reading .plugin_metadata.json
- file-upload.js: add endpoint guard (error if uploadEndpoint is falsy),
  client-side extension validation from data-allowed-extensions, and
  response.ok check before response.json() in handleSingleFileUpload
- plugin_config.html: add data-allowed-extensions attribute to single-file
  input so JS handler can read the allowed extensions list

Nitpick fixes:
- manager.py: use logger.exception() (includes traceback) instead of
  logger.error() for league fetch errors
- manager.py: remove redundant "{e}" from logger.exception() calls for
  round logo and March Madness logo load errors

Not fixed (by design):
- manifest.json repo naming: monorepo pattern is correct per project docs

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

* fix(march-madness): address second round of PR #271 code review findings

Inline fixes:
- requirements.txt: bump Pillow to >=9.1.0 (required for Image.Resampling.LANCZOS)
- file-upload.js: replace all statusDiv.innerHTML assignments with safe DOM
  creation (textContent + createElement) to prevent XSS from untrusted strings
- plugin_config.html: add role="button", tabindex="0", aria-label, onkeydown
  (Enter/Space) to drop zone for keyboard accessibility; add aria-live="polite"
  to status div for screen-reader announcements
- file-upload.js: tighten handleFileDrop endpoint check to non-empty string
  (dataset.uploadEndpoint.trim() !== '') so an empty attribute falls back to
  the multi-file handler

Nitpick fixes:
- manager.py: remove redundant cached_image/cached_array reassignments after
  create_scrolling_image() which already sets them internally
- manager.py: narrow bare except in _get_team_logo to (FileNotFoundError,
  OSError, ValueError) for expected I/O errors; log unexpected exceptions
- store_manager.py: narrow except to (OSError, ValueError) when reading
  .plugin_metadata.json so unrelated exceptions propagate

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:12:31 -05:00
302235a357 feat: Starlark Apps Integration with Schema-Driven Config + Security Hardening (#253)
* feat: integrate Starlark/Tronbyte app support into plugin system

Add starlark-apps plugin that renders Tidbyt/Tronbyte .star apps via
Pixlet binary and integrates them into the existing Plugin Manager UI
as virtual plugins. Includes vegas scroll support, Tronbyte repository
browsing, and per-app configuration.

- Extract working starlark plugin code from starlark branch onto fresh main
- Fix plugin conventions (get_logger, VegasDisplayMode, BasePlugin)
- Add 13 starlark API endpoints to api_v3.py (CRUD, browse, install, render)
- Virtual plugin entries (starlark:<app_id>) in installed plugins list
- Starlark-aware toggle and config routing in pages_v3.py
- Tronbyte repository browser section in Plugin Store UI
- Pixlet binary download script (scripts/download_pixlet.sh)

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

* fix(starlark): use bare imports instead of relative imports

Plugin loader uses spec_from_file_location without package context,
so relative imports (.pixlet_renderer) fail. Use bare imports like
all other plugins do.

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

* fix(starlark): make API endpoints work standalone in web service

The web service runs as a separate process with display_manager=None,
so plugins aren't instantiated. Refactor starlark API endpoints to
read/write the manifest file directly when the plugin isn't loaded,
enabling full CRUD operations from the web UI.

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

* fix(starlark): make config partial work standalone in web service

Read starlark app data from manifest file directly when the plugin
isn't loaded, matching the api_v3.py standalone pattern.

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

* fix(starlark): always show editable timing settings in config panel

Render interval and display duration are now always editable in the
starlark app config panel, not just shown as read-only status text.
App-specific settings from schema still appear below when present.

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

* feat(store): add sort, filter, search, and pagination to Plugin Store and Starlark Apps

Plugin Store:
- Live search with 300ms debounce (replaces Search button)
- Sort dropdown: A→Z, Z→A, Category, Author, Newest
- Installed toggle filter (All / Installed / Not Installed)
- Per-page selector (12/24/48) with pagination controls
- "Installed" badge and "Reinstall" button on already-installed plugins
- Active filter count badge + clear filters button

Starlark Apps:
- Parallel bulk manifest fetching via ThreadPoolExecutor (20 workers)
- Server-side 2-hour cache for all 500+ Tronbyte app manifests
- Auto-loads all apps when section expands (no Browse button)
- Live search, sort (A→Z, Z→A, Category, Author), author dropdown
- Installed toggle filter, per-page selector (24/48/96), pagination
- "Installed" badge on cards, "Reinstall" button variant

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

* fix(store): move storeFilterState to global scope to fix scoping bug

storeFilterState, pluginStoreCache, and related variables were declared
inside an IIFE but referenced by top-level functions, causing
ReferenceError that broke all plugin loading.

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

* feat(starlark): schema-driven config forms + critical security fixes

## Schema-Driven Config UI
- Render type-appropriate form inputs from schema.json (text, dropdown, toggle, color, datetime, location)
- Pre-populate config.json with schema defaults on install
- Auto-merge schema defaults when loading existing apps (handles schema updates)
- Location fields: 3-part mini-form (lat/lng/timezone) assembles into JSON
- Toggle fields: support both boolean and string "true"/"false" values
- Unsupported field types (oauth2, photo_select) show warning banners
- Fallback to raw key/value inputs for apps without schema

## Critical Security Fixes (P0)
- **Path Traversal**: Verify path safety BEFORE mkdir to prevent TOCTOU
- **Race Conditions**: Add file locking (fcntl) + atomic writes to manifest operations
- **Command Injection**: Validate config keys/values with regex before passing to Pixlet subprocess

## Major Logic Fixes (P1)
- **Config/Manifest Separation**: Store timing keys (render_interval, display_duration) ONLY in manifest
- **Location Validation**: Validate lat [-90,90] and lng [-180,180] ranges, reject malformed JSON
- **Schema Defaults Merge**: Auto-apply new schema defaults to existing app configs on load
- **Config Key Validation**: Enforce alphanumeric+underscore format, prevent prototype pollution

## Files Changed
- web_interface/templates/v3/partials/starlark_config.html — schema-driven form rendering
- plugin-repos/starlark-apps/manager.py — file locking, path safety, config validation, schema merge
- plugin-repos/starlark-apps/pixlet_renderer.py — config value sanitization
- web_interface/blueprints/api_v3.py — timing key separation, safe manifest updates

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

* fix(starlark): use manifest filename field for .star downloads

Tronbyte apps don't always name their .star file to match the directory.
For example, the "analogclock" app has "analog_clock.star" (with underscore).

The manifest.yaml contains a "filename" field with the correct name.

Changes:
- download_star_file() now accepts optional filename parameter
- Install endpoint passes metadata['filename'] to download_star_file()
- Falls back to {app_id}.star if filename not in manifest

Fixes: "Failed to download .star file for analogclock" error

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

* fix(starlark): reload tronbyte_repository module to pick up code changes

The web service caches imported modules in sys.modules. When deploying
code updates, the old cached version was still being used.

Now uses importlib.reload() when module is already loaded.

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

* fix(starlark): use correct 'fileName' field from manifest (camelCase)

The Tronbyte manifest uses 'fileName' (camelCase), not 'filename' (lowercase).
This caused the download to fall back to {app_id}.star which doesn't exist
for apps like analogclock (which has analog_clock.star).

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

* feat(starlark): extract schema during standalone install

The standalone install function (_install_star_file) wasn't extracting
schema from .star files, so apps installed via the web service had no
schema.json and the config panel couldn't render schema-driven forms.

Now uses PixletRenderer to extract schema during standalone install,
same as the plugin does.

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

* feat(starlark): implement source code parser for schema extraction

Pixlet CLI doesn't support schema extraction (--print-schema flag doesn't exist),
so apps were being installed without schemas even when they have them.

Implemented regex-based .star file parser that:
- Extracts get_schema() function from source code
- Parses schema.Schema(version, fields) structure
- Handles variable-referenced dropdown options (e.g., options = dialectOptions)
- Supports Location, Text, Toggle, Dropdown, Color, DateTime fields
- Gracefully handles unsupported fields (OAuth2, LocationBased, etc.)
- Returns formatted JSON matching web UI template expectations

Coverage: 90%+ of Tronbyte apps (static schemas + variable references)

Changes:
- Replace extract_schema() to parse .star files directly instead of using Pixlet CLI
- Add 6 helper methods for parsing schema structure
- Handle nested parentheses and brackets properly
- Resolve variable references for dropdown options

Tested with:
- analog_clock.star (Location field) ✓
- Multi-field test (Text + Dropdown + Toggle) ✓
- Variable-referenced options ✓

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

* fix(starlark): add List to typing imports for schema parser

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

* fix(starlark): load schema from schema.json in standalone mode

The standalone API endpoint was returning schema: null because it didn't
load the schema.json file. Now reads schema from disk when returning
app details via web service.

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

* feat(starlark): implement schema extraction, asset download, and config persistence

## Schema Extraction
- Replace broken `pixlet serve --print-schema` with regex-based source parser
- Extract schema by parsing `get_schema()` function from .star files
- Support all field types: Location, Text, Toggle, Dropdown, Color, DateTime
- Handle variable-referenced dropdown options (e.g., `options = teamOptions`)
- Gracefully handle complex/unsupported field types (OAuth2, PhotoSelect, etc.)
- Extract schema for 90%+ of Tronbyte apps

## Asset Download
- Add `download_app_assets()` to fetch images/, sources/, fonts/ directories
- Download assets in binary mode for proper image/font handling
- Validate all paths to prevent directory traversal attacks
- Copy asset directories during app installation
- Enable apps like AnalogClock that require image assets

## Config Persistence
- Create config.json file during installation with schema defaults
- Update both config.json and manifest when saving configuration
- Load config from config.json (not manifest) for consistency with plugin
- Separate timing keys (render_interval, display_duration) from app config
- Fix standalone web service mode to read/write config.json

## Pixlet Command Fix
- Fix Pixlet CLI invocation: config params are positional, not flags
- Change from `pixlet render file.star -c key=value` to `pixlet render file.star key=value -o output`
- Properly handle JSON config values (e.g., location objects)
- Enable config to be applied during rendering

## Security & Reliability
- Add threading.Lock for cache operations to prevent race conditions
- Reduce ThreadPoolExecutor workers from 20 to 5 for Raspberry Pi
- Add path traversal validation in download_star_file()
- Add YAML error logging in manifest fetching
- Add file size validation (5MB limit) for .star uploads
- Use sanitized app_id consistently in install endpoints
- Use atomic manifest updates to prevent race conditions
- Add missing Optional import for type hints

## Web UI
- Fix standalone mode schema loading in config partial
- Schema-driven config forms now render correctly for all apps
- Location fields show lat/lng/timezone inputs
- Dropdown, toggle, text, color, and datetime fields all supported

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

* fix(starlark): code review fixes - security, robustness, and schema parsing

## Security Fixes
- manager.py: Check _update_manifest_safe return values to prevent silent failures
- manager.py: Improve temp file cleanup in _save_manifest to prevent leaks
- manager.py: Fix uninstall order (manifest → memory → disk) for consistency
- api_v3.py: Add path traversal validation in uninstall endpoint
- api_v3.py: Implement atomic writes for manifest files with temp + rename
- pixlet_renderer.py: Relax config validation to only block dangerous shell metacharacters

## Frontend Robustness
- plugins_manager.js: Add safeLocalStorage wrapper for restricted contexts (private browsing)
- starlark_config.html: Scope querySelector to container to prevent modal conflicts

## Schema Parsing Improvements
- pixlet_renderer.py: Indentation-aware get_schema() extraction (handles nested functions)
- pixlet_renderer.py: Handle quoted defaults with commas (e.g., "New York, NY")
- tronbyte_repository.py: Validate file_name is string before path traversal checks

## Dependencies
- requirements.txt: Update Pillow (10.4.0), PyYAML (6.0.2), requests (2.32.0)

## Documentation
- docs/STARLARK_APPS_GUIDE.md: Comprehensive guide explaining:
  - How Starlark apps work
  - That apps come from Tronbyte (not LEDMatrix)
  - Installation, configuration, troubleshooting
  - Links to upstream projects

All changes improve security, reliability, and user experience.

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

* fix(starlark): convert Path to str in spec_from_file_location calls

The module import helpers were passing Path objects directly to
spec_from_file_location(), which caused spec to be None. This broke
the Starlark app store browser.

- Convert module_path to string in both _get_tronbyte_repository_class
  and _get_pixlet_renderer_class
- Add None checks with clear error messages for debugging

Fixes: spec not found for the module 'tronbyte_repository'

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

* fix(starlark): restore Starlark Apps section in plugins.html

The Starlark Apps UI section was lost during merge conflict resolution
with main branch. Restored from commit 942663ab which had the complete
implementation with filtering, sorting, and pagination.

Fixes: Starlark section not visible on plugin manager page

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

* fix(starlark): restore Starlark JS functionality lost in merge

During the merge with main, all Starlark-specific JavaScript (104 lines)
was removed from plugins_manager.js, including:
- starlarkFilterState and filtering logic
- loadStarlarkApps() function
- Starlark app install/uninstall handlers
- Starlark section collapse/expand logic
- Pagination and sorting for Starlark apps

Restored from commit 942663ab and re-applied safeLocalStorage wrapper
from our code review fixes.

Fixes: Starlark Apps section non-functional in web UI

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

* fix(starlark): security and race condition improvements

Security fixes:
- Add path traversal validation for output_path in download_star_file
- Remove XSS-vulnerable inline onclick handlers, use delegated events
- Add type hints to helper functions for better type safety

Race condition fixes:
- Lock manifest file BEFORE creating temp file in _save_manifest
- Hold exclusive lock for entire read-modify-write cycle in _update_manifest_safe
- Prevent concurrent writers from racing on manifest updates

Other improvements:
- Fix pages_v3.py standalone mode to load config.json from disk
- Improve error handling with proper logging in cleanup blocks
- Add explicit type annotations to Starlark helper functions

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

* fix(starlark): critical bug fixes and code quality improvements

Critical fixes:
- Fix stack overflow in safeLocalStorage (was recursively calling itself)
- Fix duplicate event listeners on Starlark grid (added sentinel check)
- Fix JSON validation to fail fast on malformed data instead of silently passing

Error handling improvements:
- Narrow exception catches to specific types (OSError, json.JSONDecodeError, ValueError)
- Use logger.exception() with exc_info=True for better stack traces
- Replace generic "except Exception" with specific exception types

Logging improvements:
- Add "[Starlark Pixlet]" context tags to pixlet_renderer logs
- Redact sensitive config values from debug logs (API keys, etc.)
- Add file_path context to schema parsing warnings

Documentation:
- Fix markdown lint issues (add language tags to code blocks)
- Fix time unit spacing: "(5min)" -> "(5 min)"

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

* fix(starlark): critical path traversal and exception handling fixes

Path traversal security fixes (CRITICAL):
- Add _validate_starlark_app_path() helper to check for path traversal attacks
- Validate app_id in get_starlark_app(), uninstall_starlark_app(),
  get_starlark_app_config(), and update_starlark_app_config()
- Check for '..' and path separators before any filesystem access
- Verify resolved paths are within _STARLARK_APPS_DIR using Path.relative_to()
- Prevents unauthorized file access via crafted app_id like '../../../etc/passwd'

Exception handling improvements (tronbyte_repository.py):
- Replace broad "except Exception" with specific types
- _make_request: catch requests.Timeout, requests.RequestException, json.JSONDecodeError
- _fetch_raw_file: catch requests.Timeout, requests.RequestException separately
- download_app_assets: narrow to OSError, ValueError
- Add "[Tronbyte Repo]" context prefix to all log messages
- Use exc_info=True for better stack traces

API improvements:
- Narrow exception catches to OSError, json.JSONDecodeError in config loading
- Remove duplicate path traversal checks (now centralized in helper)

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

* fix(starlark): logging improvements and code quality fixes

Logging improvements (pages_v3.py):
- Add logging import and create module logger
- Replace print() calls with logger.warning() with "[Pages V3]" prefix
- Use logger.exception() for outer try/catch with exc_info=True
- Narrow exception handling to OSError, json.JSONDecodeError for file operations

API improvements (api_v3.py):
- Remove unnecessary f-strings (Ruff F541) from ImportError messages
- Narrow upload exception handling to ValueError, OSError, IOError
- Use logger.exception() with context for better debugging
- Remove early return in get_starlark_status() to allow standalone mode fallback
- Sanitize error messages returned to client (don't expose internal details)

Benefits:
- Better log context with consistent prefixes
- More specific exception handling prevents masking unexpected errors
- Standalone/web-service-only mode now works for status endpoint
- Stack traces preserved for debugging without exposing to clients

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:44:12 -05:00
636d0e181c feat(plugins): add sorting, filtering, and fix Update All button (#252)
* feat(store): add sorting, filtering, and fix Update All button

Add client-side sorting and filtering to the Plugin Store:
- Sort by A-Z, Z-A, Verified First, Recently Updated, Category
- Filter by verified, new, installed status, author, and tags
- Installed/Update Available badges on store cards
- Active filter count badge with clear-all button
- Sort preference persisted to localStorage

Fix three bugs causing button unresponsiveness:
- pluginsInitialized never reset on HTMX tab navigation (root cause
  of Update All silently doing nothing on second visit)
- htmx:afterSwap condition too broad (fired on unrelated swaps)
- data-running guard tied to DOM element replaced by cloneNode

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

* refactor(store): replace tag pills with category pills, fix sort dates

- Replace tag filter pills with category filter pills (less duplication)
- Prefer per-plugin last_updated over repo-wide pushed_at for sort

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

* debug: add console logging to filter/sort handlers

* fix: bump cache-buster versions for JS and CSS

* feat(plugins): add sorting to installed plugins section

Add A-Z, Z-A, and Enabled First sort options for installed plugins
with localStorage persistence. Both installed and store sections
now default to A-Z sorting.

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

* fix(store): consolidate CSS, fix stale cache bug, add missing utilities, fix icon

- Consolidate .filter-pill and .category-filter-pill into shared selectors
  and scope transition to only changed properties
- Fix applyStoreFiltersAndSort ignoring fresh server-filtered results by
  accepting optional basePlugins parameter
- Add missing .py-1.5 and .rounded-full CSS utility classes
- Replace invalid fa-sparkles with fa-star (FA 6.0.0 compatible)

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

* fix(store): semver-aware update badge and add missing gap-1.5 utility

- Replace naive version !== comparison with isNewerVersion() that does
  semver greater-than check, preventing false "Update" badges on
  same-version or downgrade scenarios
- Add missing .gap-1.5 CSS utility used by category pills and tag lists

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 07:38:16 -05:00
bc8568604a feat(web): add LED RGB sequence, multiplexing, and panel type settings (#248)
* feat(web): add LED RGB sequence, multiplexing, and panel type settings

Expose three rpi-rgb-led-matrix hardware options in the Display Settings
UI so users can configure non-standard panels without editing config.json
manually. All defaults match existing behavior (RGB, Direct, Standard).

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

* fix(api): validate led_rgb_sequence, multiplexing, and panel_type inputs

Reject invalid values with 400 errors before writing to config: whitelist
check for led_rgb_sequence and panel_type, range + type check for multiplexing.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:16:21 -05:00
82370a0253 Fix log viewer readability — add missing CSS utility classes (#244)
* fix(web): add missing utility classes for log viewer readability

The log viewer uses text-gray-100, text-gray-200, text-gray-300,
text-red-300, text-yellow-300, bg-gray-800, bg-red-900, bg-yellow-900,
border-gray-700, and hover:bg-gray-800 — none of which were defined in
app.css. Without definitions, log text inherited the body's dark color
(#111827) which was invisible against the dark bg-gray-900 log container
in light mode.

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

* fix(web): remove dead bg-opacity classes, use proper log level colors

The bg-opacity-10/bg-opacity-30 classes set a --bg-opacity CSS variable
that no background-color rule consumed, making them dead code. Replace
the broken two-class pattern (e.g. "bg-red-900 bg-opacity-10") with
dedicated log-level-error/warning/debug classes that use rgb() with
actual alpha values.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:14:20 -05:00
3975940cff Add light/dark mode toggle and fix log readability (#243)
* feat(web): add light/dark mode toggle and fix log readability

Add a theme toggle button (moon/sun icon) to the header that switches
between light and dark mode. Theme preference persists in localStorage
and falls back to the OS prefers-color-scheme setting.

The implementation uses a data-theme attribute on <html> with CSS
overrides, so all 13 partial templates and 20+ widget JS files get
dark mode support without any modifications — only 3 files changed.

Also fixes log timestamp readability: text-gray-500 had ~3.5:1 contrast
ratio against the dark log background, now uses text-gray-400 (~5.3:1)
which passes WCAG AA in both light and dark mode.

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

* fix(web): address dark mode review — accessibility, robustness, and code quality

- WCAG touch target: enforce 44×44px minimum on theme toggle button
  with display:inline-flex centering
- Accessibility: add type="button", aria-pressed (dynamically updated),
  aria-hidden on decorative icons, and contextual aria-label/title that
  reflects current state ("Switch to light/dark mode")
- Robustness: wrap all localStorage and matchMedia calls in try/catch
  with fallbacks for private browsing and restricted contexts; use
  addListener fallback for older browsers lacking addEventListener
- Stylelint: convert all rgba() to modern rgb(…/…%) notation across
  both light and dark theme shadows and gradients
- DRY: replace hardcoded hex values in dark mode utility overrides and
  component overrides with CSS variable references (--color-surface,
  --color-background, --color-border, --color-text-primary, etc.)
- Remove redundant [data-theme="dark"] body rule (body already uses
  CSS variables that are redefined under the dark theme selector)

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:12:37 -05:00
9a72adbde1 fix(web): unify operation history tracking for monorepo plugin operations (#240)
The operation history UI was reading from the wrong data source
(operation_queue instead of operation_history), install/update records
lacked version details, toggle operations used a type name that didn't
match UI filters, and the Clear History button was non-functional.

- Switch GET /plugins/operation/history to read from OperationHistory
  audit log with return type hint and targeted exception handling
- Add DELETE /plugins/operation/history endpoint; wire up Clear button
- Add _get_plugin_version helper with specific exception handling
  (FileNotFoundError, PermissionError, json.JSONDecodeError) and
  structured logging with plugin_id/path context
- Record plugin version, branch, and commit details on install/update
- Record install failures in the direct (non-queue) code path
- Replace "toggle" operation type with "enable"/"disable"
- Add normalizeStatus() in JS to map completed→success, error→failed
  so status filter works regardless of server-side convention
- Truncate commit SHAs to 7 chars in details display
- Fix HTML filter options, operation type colors, duplicate JS init

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 12:11:12 -05:00
448a15c1e6 feat(fonts): add dynamic font selection and font manager improvements (#232)
* feat(fonts): add dynamic font selection and font manager improvements

- Add font-selector widget for dynamic font selection in plugin configs
- Enhance /api/v3/fonts/catalog with filename, display_name, and type
- Add /api/v3/fonts/preview endpoint for server-side font rendering
- Add /api/v3/fonts/<family> DELETE endpoint with system font protection
- Fix /api/v3/fonts/upload to actually save uploaded font files
- Update font manager tab with dynamic dropdowns, server-side preview, and font deletion
- Add new BDF fonts: 6x10, 6x12, 6x13, 7x13, 7x14, 8x13, 9x15, 9x18, 10x20 (with bold/oblique variants)
- Add tom-thumb, helvR12, clR6x12, texgyre-27 fonts

Plugin authors can use x-widget: "font-selector" in schemas to enable
dynamic font selection that automatically shows all available fonts.

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

* fix(fonts): security fixes and code quality improvements

- Fix README.md typos and add language tags to code fences
- Remove duplicate delete_font function causing Flask endpoint collision
- Add safe integer parsing for size parameter in preview endpoint
- Fix path traversal vulnerability in /fonts/preview endpoint
- Fix path traversal vulnerability in /fonts/<family> DELETE endpoint
- Fix XSS vulnerability in fonts.html by using DOM APIs instead of innerHTML
- Move baseUrl to shared scope to fix ReferenceError in multiple functions

Security improvements:
- Validate font filenames reject path separators and '..'
- Validate paths are within fonts_dir before file operations
- Use textContent and data attributes instead of inline onclick handlers
- Restrict file extensions to known font types

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

* fix(fonts): address code issues and XSS vulnerabilities

- Move `import re` to module level, remove inline imports
- Remove duplicate font_file assignment in upload_font()
- Remove redundant validation with inconsistent allowed extensions
- Remove redundant PathLib import, use already-imported Path
- Fix XSS vulnerabilities in fonts.html by using DOM APIs instead of
  innerHTML with template literals for user-controlled data

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

* fix(fonts): add size limits to font preview endpoint

Add input validation to prevent DoS via large image generation:
- MAX_TEXT_CHARS (100): Limit text input length
- MAX_TEXT_LINES (3): Limit number of newlines
- MAX_DIM (1024): Limit max width/height
- MAX_PIXELS (500000): Limit total pixel count

Validates text early before processing and checks computed
dimensions after bbox calculation but before image allocation.

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

* fix(fonts): improve error handling, catalog keys, and BDF preview

- Add structured logging for cache invalidation failures instead of
  silent pass (FontUpload, FontDelete, FontCatalog contexts)
- Use filename as unique catalog key to prevent collisions when
  multiple font files share the same family_name from metadata
- Return explicit error for BDF font preview instead of showing
  misleading preview with default font

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

* fix(fonts): address nitpick issues in font management

Frontend (fonts.html):
- Remove unused escapeHtml function (dead code)
- Add max-attempts guard (50 retries) to initialization loop
- Add response.ok checks before JSON parsing in deleteFont,
  addFontOverride, deleteFontOverride, uploadSelectedFonts
- Use is_system flag from API instead of hardcoded client-side list

Backend (api_v3.py):
- Move SYSTEM_FONTS to module-level frozenset for single source of truth
- Add is_system flag to font catalog entries
- Simplify delete_font system font check using frozenset lookup

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

* fix(fonts): align frontend upload validation with backend

- Add .otf to accepted file extensions (HTML accept attribute, JS filter)
- Update validation regex to allow hyphens (matching backend)
- Preserve hyphens in auto-generated font family names
- Update UI text to reflect all supported formats

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

* fix(fonts): fix lint errors and missing variable

- Remove unused exception binding in set_cached except block
- Define font_family_lower before case-insensitive fallback loop
- Add response.ok check to font preview fetch (consistent with other handlers)

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

* fix(fonts): address nitpick code quality issues

- Add return type hints to get_font_preview and delete_font endpoints
- Catch specific PIL exceptions (IOError/OSError) when loading fonts
- Replace innerHTML with DOM APIs for trash icon (consistency)

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

* fix(fonts): remove unused exception bindings in cache-clearing blocks

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 18:21:27 -05:00
4a9fc2df3a feat(web): add shutdown button to Quick Actions (#234)
Add a "Shutdown System" button to the Overview page that gracefully
powers off the Raspberry Pi. Uses sudo poweroff, consistent with the
existing reboot_system action, letting sudo's secure_path handle
binary resolution.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 12:35:37 -05:00
8912501604 fix(web): ensure unchecked checkboxes save as false in main config forms (#222)
* fix: remove plugin-specific calendar duration from config template

Plugin display durations should be added dynamically when plugins are
installed, not hardcoded in the template.

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

* fix(web): ensure unchecked checkboxes save as false in main config forms

HTML checkboxes omit their key entirely when unchecked, so the backend
never received updates to set boolean values to false. This affected:

- vegas_scroll_enabled: Now uses _coerce_to_bool helper
- use_short_date_format: Now uses _coerce_to_bool helper
- Plugin system checkboxes (auto_discover, auto_load_enabled, development_mode):
  Now uses _coerce_to_bool helper
- Hardware checkboxes (disable_hardware_pulsing, inverse_colors, show_refresh_rate):
  Now uses _coerce_to_bool helper
- web_display_autostart: Now uses _coerce_to_bool helper

Added _coerce_to_bool() helper function that properly converts form string
values ("true", "on", "1", "yes") to actual Python booleans, ensuring
consistent JSON types in config and correct downstream boolean checks.

Also added value="true" to all main config checkboxes for consistent boolean
parsing (sends "true" instead of "on" when checked).

This is the same issue fixed in commit 10d70d91 for plugin configs, but
for the main configuration forms (display settings, general settings).

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 21:49:29 -05:00
14c50f316e feat: add timezone support for schedules and dim schedule feature (#218)
* feat: add timezone support for schedules and dim schedule feature

- Fix timezone handling in _check_schedule() to use configured timezone
  instead of system time (addresses schedule offset issues)
- Add dim schedule feature for automatic brightness dimming:
  - New dim_schedule config section with brightness level and time windows
  - Smart interaction: dim schedule won't turn display on if it's off
  - Supports both global and per-day modes like on/off schedule
- Add set_brightness() and get_brightness() methods to DisplayManager
  for runtime brightness control
- Add REST API endpoints: GET/POST /api/v3/config/dim-schedule
- Add web UI for dim schedule configuration in schedule settings page

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

* fix: normalize per-day mode and validate dim_brightness input

- Normalize mode string in _check_dim_schedule to handle both "per-day"
  and "per_day" variants
- Add try/except around dim_brightness int conversion to handle invalid
  input gracefully

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

* refactor: improve error handling in brightness and dim schedule endpoints

- display_manager.py: Add fail-fast input validation, catch specific
  exceptions (AttributeError, TypeError, ValueError), add [BRIGHTNESS]
  context tags, include stack traces in error logs
- api_v3.py: Catch specific config exceptions (FileNotFoundError,
  JSONDecodeError, IOError), add [DIM SCHEDULE] context tags for
  Pi debugging, include stack traces

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 18:12:45 -05:00
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
10d70d911a Fix unchecked boolean checkboxes not saving as false (#216)
* fix(web): ensure unchecked boolean checkboxes save as false

HTML checkboxes don't submit values when unchecked. The plugin config
save endpoint starts from existing config (for partial updates), so an
unchecked checkbox's old `true` value persists. Additionally,
merge_with_defaults fills in schema defaults for missing fields, causing
booleans with `"default": true` to always re-enable.

This affected the odds-ticker plugin where NFL/NBA leagues (default:
true) could not be disabled via the checkbox UI, while NHL (default:
false) appeared to work by coincidence.

Changes:
- Add _set_missing_booleans_to_false() that walks the schema after form
  processing and sets any boolean field absent from form data to false
- Add value="true" to boolean checkboxes so checked state sends "true"
  instead of "on" (proper boolean parsing)
- Handle "on"/"off" strings in _parse_form_value_with_schema for
  backwards compatibility with checkboxes lacking value="true"

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

* fix(web): guard on/off coercion to boolean schema types, handle arrays

- Only coerce "on"/"off" strings to booleans when the schema type is
  boolean; "true"/"false" remain unconditional
- Extend _set_missing_booleans_to_false to recurse into arrays of
  objects (e.g. custom_feeds.0.enabled) by discovering item indices
  from submitted form keys and recursing per-index

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

* fix(web): preserve array structures when setting missing booleans

_set_nested_value uses dict-style access for all path segments, which
corrupts lists when paths contain numeric array indices (e.g.
"feeds.custom_feeds.0.enabled").

Refactored _set_missing_booleans_to_false to:
- Accept an optional config_node parameter for direct array item access
- When inside an array item, set booleans directly on the item dict
- Navigate to array lists manually, preserving their list type
- Ensure array items exist as dicts before recursing

This prevents array-of-object configs (like custom_feeds) from being
converted to nested dicts with numeric string keys.

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-28 09:15:38 -05:00
a8c85dd015 feat(widgets): add modular widget system for schedule and common inputs (#213)
* feat(widgets): add modular widget system for schedule and common inputs

Add 15 new reusable widgets following the widget registry pattern:
- schedule-picker: composite widget for enable/mode/time configuration
- day-selector: checkbox group for days of the week
- time-range: paired start/end time inputs with validation
- text-input, number-input, textarea: enhanced text inputs
- toggle-switch, radio-group, select-dropdown: selection widgets
- slider, color-picker, date-picker: specialized inputs
- email-input, url-input, password-input: validated string inputs

Refactor schedule.html to use the new schedule-picker widget instead
of inline JavaScript. Add x-widget support in plugin_config.html for
all new widgets so plugins can use them via schema configuration.

Fix form submission for checkboxes by using hidden input pattern to
ensure unchecked state is properly sent via JSON-encoded forms.

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

* fix(widgets): improve security, validation, and form binding across widgets

- Fix XSS vulnerability: escapeHtml now escapes quotes in all widget fallbacks
- color-picker: validate presets with isValidHex(), use data attributes
- date-picker: add placeholder attribute support
- day-selector: use options.name for hidden input form binding
- password-input: implement requireUppercase/Number/Special validation
- radio-group: fix value injection using this.value instead of interpolation
- schedule-picker: preserve day values when disabling (don't clear times)
- select-dropdown: remove undocumented searchable/icons options
- text-input: apply patternMessage via setCustomValidity
- time-range: use options.name for hidden inputs
- toggle-switch: preserve configured color from data attribute
- url-input: combine browser and custom protocol validation
- plugin_config: add widget support for boolean/number types, pass name to day-selector
- schedule: handle null config gracefully, preserve explicit mode setting

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

* fix(widgets): validate day-selector input, consistent minLength default, escape JSON quotes

- day-selector: filter incoming selectedDays to only valid entries in DAYS array
  (prevents invalid persisted values from corrupting UI/state)
- password-input: use default minLength of 8 when not explicitly set
  (fixes inconsistency between render() and onInput() strength meter baseline)
- plugin_config.html: escape single quotes in JSON hidden input values
  (prevents broken attributes when JSON contains single quotes)

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

* feat(widgets): add global notification widget, consolidate duplicated code

- Create notification.js widget with toast-style notifications
- Support for success, error, warning, info types
- Auto-dismiss with configurable duration
- Stacking support with max notifications limit
- Accessible with aria-live and role="alert"
- Update base.html to load notification widget early
- Replace duplicate showNotification in raw_json.html
- Simplify fonts.html fallback notification
- Net reduction of ~66 lines of duplicated code

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

* fix(widgets): escape options.name in all widgets, validate day-selector format

Security fixes:
- Escape options.name attribute in all 13 widgets to prevent injection
- Affected: color-picker, date-picker, email-input, number-input,
  password-input, radio-group, select-dropdown, slider, text-input,
  textarea, toggle-switch, url-input

Defensive coding:
- day-selector: validate format option exists in DAY_LABELS before use
- Falls back to 'long' format for unsupported/invalid format values

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

* fix(plugins): add type="button" to control buttons, add debug logging

- Add type="button" attribute to refresh, update-all, and restart buttons
  to prevent potential form submission behavior
- Add console logging to diagnose button click issues:
  - Log when event listeners are attached (and whether buttons found)
  - Log when handler functions are called

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

* fix(widgets): improve security and validation across widget inputs

- color-picker.js: Add sanitizeHex() to validate hex values before HTML
  interpolation, ensuring only safe #rrggbb strings are used
- day-selector.js: Escape inputName in hidden input name attribute
- number-input.js: Sanitize and escape currentValue in input element
- password-input.js: Validate minLength as non-negative integer, clamp
  invalid values to default of 8
- slider.js: Add null check for input element before accessing value
- text-input.js: Clear custom validity before checkValidity() to avoid
  stale errors, re-check after setting pattern message
- url-input.js: Normalize allowedProtocols to array, filter to valid
  protocol strings, and escape before HTML interpolation

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

* fix(widgets): add defensive fallback for DAY_LABELS lookup in day-selector

Extract labelMap with fallback before loop to ensure safe access even if
format validation somehow fails.

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

* feat(widgets): add timezone-selector widget with IANA timezone dropdown

- Create timezone-selector.js widget with comprehensive IANA timezone list
- Group timezones by region (US & Canada, Europe, Asia, etc.)
- Show current UTC offset for each timezone
- Display live time preview for selected timezone
- Update general.html to use timezone-selector instead of text input
- Add script tag to base.html for widget loading

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

* fix(ui): suppress on-demand status notification on page load

Change loadOnDemandStatus(true) to loadOnDemandStatus(false) during
initPluginsPage() to prevent the "on-demand status refreshed"
notification from appearing every time a tab is opened or the page
is navigated. The notification should only appear on explicit user
refresh.

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

* style(ui): soften notification close button appearance

Replace blocky FontAwesome X icon with a cleaner SVG that has rounded
stroke caps. Make the button circular, slightly transparent by default,
and add smooth hover transitions for a more polished look.

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

* fix(widgets): multiple security and validation improvements

- color-picker.js: Ensure presets is always an array before map/filter
- number-input.js: Guard against undefined options parameter
- number-input.js: Sanitize and escape min/max/step HTML attributes
- text-input.js: Clear custom validity in onInput to unblock form submit
- timezone-selector.js: Replace legacy Europe/Belfast with Europe/London
- url-input.js: Use RFC 3986 scheme pattern for protocol validation
- general.html: Use |tojson filter to escape timezone value safely

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

* refactor(url-input): centralize RFC 3986 protocol validation

Extract protocol normalization into reusable normalizeProtocols()
helper function that validates against RFC 3986 scheme pattern.
Apply consistently in render, validate, and onInput to ensure
protocols like "git+ssh", "android-app" are properly handled
everywhere. Also lowercase protocol comparison in isValidUrl().

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

* fix(timezone-selector): use hidden input for form submission

Replace direct select name attribute with a hidden input pattern to
ensure timezone value is always properly serialized in form submissions.
The hidden input is synced on change and setValue calls. This matches
the pattern used by other widgets and ensures HTMX json-enc properly
captures the value.

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

* fix(general): preserve timezone dropdown value after save

Add inline script to sync the timezone select with the hidden input
value after form submission. This prevents the dropdown from visually
resetting to the old value while the save has actually succeeded.

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

* fix(widgets): preserve timezone selection across form submission

Use before-request handler to capture the selected timezone value
before HTMX processes the form, then restore it in after-request.
This is more robust than reading from the hidden input which may
also be affected by form state changes.

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

* fix(widgets): add HTMX protection to timezone selector

Add global HTMX event listeners in the timezone-selector widget
that preserve the selected value across any form submissions.
This is more robust than form-specific handlers as it protects
the widget regardless of how/where forms are submitted.

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

* debug(widgets): add logging and prevent timezone widget re-init

Add debug logging and guards to prevent the timezone widget from
being re-initialized after it's already rendered. This should help
diagnose why the dropdown is reverting after save.

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

* debug: add console logging to timezone HTMX protection

* debug: add onChange logging to trace timezone selection

* fix(widgets): use selectedIndex to force visual update in timezone dropdown

The browser's select.value setter sometimes doesn't trigger a visual
update when optgroup elements are present. Using selectedIndex instead
forces the browser to correctly update the visible selection.

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

* fix(widgets): force browser repaint on timezone dropdown restore

Adding display:none/reflow/display:'' pattern to force browser to
visually update the select element after changing selectedIndex.
Increased timeout to 50ms for reliability.

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

* chore(widgets): remove debug logging from timezone selector

Clean up console.log statements that were used for debugging the
timezone dropdown visual update issue.

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

* fix(ui): improve HTMX after-request handler in general settings

- Parse xhr.responseText with JSON.parse in try/catch instead of
  using nonstandard responseJSON property
- Check xhr.status for 2xx success range
- Show error notification for non-2xx responses
- Default to safe fallback values if JSON parsing fails

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

* fix(widgets): add input sanitization and timezone validation

- Sanitize minLength/maxLength in text-input.js to prevent attribute
  injection (coerce to integers, validate range)
- Update Europe/Kiev to Europe/Kyiv (canonical IANA identifier)
- Validate timezone currentValue against TIMEZONE_GROUPS before rendering

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

* fix(ui): correct error message fallback in HTMX after-request handler

Initialize message to empty string so error responses can use the
fallback 'Failed to save settings' when no server message is provided.
Previously, the truthy default 'Settings saved' would always be used.

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

* fix(widgets): add constraint normalization and improve value validation

- text-input: normalize minLength/maxLength so maxLength >= minLength
- timezone-selector: validate setValue input against TIMEZONE_GROUPS
- timezone-selector: sync hidden input to actual selected value
- timezone-selector: preserve empty selections across HTMX requests

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

* fix(widgets): simplify HTMX restore using select.value and dispatch change event

Replace selectedIndex manipulation with direct value assignment for cleaner
placeholder handling, and dispatch change event to refresh timezone preview.

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-27 19:56:16 -05:00
d0ad2031c8 fix(ui): wrap plugin tabs to new lines instead of scrolling (#201)
* fix(ui): wrap plugin tabs to new lines instead of scrolling

Change plugin tabs row from overflow-x-auto to flex-wrap so that
when many plugins are installed, tabs break to new lines instead
of becoming smaller or requiring horizontal scrolling.

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

* fix(ui): use gap-x instead of space-x for proper wrapped row alignment

Switch from space-x-* to gap-x-* utilities so wrapped rows align
correctly without indentation on subsequent lines.

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

* fix(ui): add missing flex-wrap and gap utilities to CSS

The project uses hand-written Tailwind-like CSS, not actual Tailwind.
Added missing utility classes needed for plugin tabs wrapping:
- flex-wrap
- gap-x-4, gap-x-6, gap-x-8, gap-y-2
- lg:gap-x-6, xl:gap-x-8 responsive variants

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

* fix(ui): apply flex-wrap to system tabs row

Apply the same wrapping behavior to the system tabs row (Overview,
General, WiFi, etc.) so they also wrap to new lines on smaller
viewports instead of scrolling.

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

* fix(ui): constrain tab container width to enable flex-wrap

Add max-w-full and overflow-hidden to tab row containers to properly
constrain their width, allowing flex-wrap to work correctly.

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

* fix(ui): remove overflow-hidden that was hiding tabs

Revert the max-w-full overflow-hidden approach as it was hiding
content. Keep both rows using flex-wrap with gap utilities.

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

* feat: Add custom-leagues widget support for soccer plugin

- Add server-side template rendering for x-widget="custom-leagues"
- Renders table with Name, League Code, Priority, Enabled columns
- Includes inline JavaScript for add/remove row functionality
- Uses indexed field naming for proper array serialization
- Shows common ESPN league codes as hint

This enables the soccer scoreboard plugin's custom leagues feature
to work properly in the web UI.

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

* fix(ui): reduce tab gap spacing for tighter layout

Reduce horizontal gap between tabs from gap-x-4/6/8 to gap-x-2/3/4
for a more compact appearance.

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

* refactor(widget): Replace custom-leagues with generic array-table widget

- Add generic array-table widget that reads columns from schema
- Support x-columns to specify which columns to display
- Auto-detect columns from items.properties if x-columns not specified
- Remove hardcoded custom-leagues implementation
- Any plugin can now use x-widget: "array-table" for array-of-objects

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

* fix(ui): use data attributes for array table button to avoid JSON escaping issues

Move JSON blobs (item_properties and display_columns) from inline onclick
to data-* attributes with proper HTML entity escaping via Jinja's |e filter.
Update addArrayTableRow() to read and parse these data attributes.

This fixes HTML attribute breakage caused by tojson emitting double quotes
inside the onclick attribute value.

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

* fix(ui): update Add button state when array table rows change

Add updateAddButtonState() helper that toggles the Add button's disabled
attribute and opacity based on current row count vs maxItems.

Called after addArrayTableRow() and removeArrayTableRow(), and also on
page load to ensure correct initial state.

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

* fix(ui): add try/catch for JSON parsing in addArrayTableRow

Wrap JSON.parse calls for data-item-properties and data-display-columns
in try/catch blocks with fallback to {} and [] respectively. Logs error
with raw attribute values to help debug malformed JSON.

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

* fix(array-table): Fix getValue input name validation and setValue Add button state sync

- Fix getValue to use early-continue guard preventing errors on inputs without names
- Add updateAddButtonState call in setValue to refresh Add button state after repopulating rows

* fix(ui): make Configure button larger than Uninstall in plugin manager

Swapped button sizes in installed plugins section - Configure button is now
the largest (flex-2), Update is medium (flex-1), and Uninstall is smallest
(no flex class). This prioritizes the Configure action over Uninstall.

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

* fix(ui): correct forEach continue and plugin button flex sizing

- Replace invalid continue with return in array-table forEach callback
- Remove redundant hidden input type check in array-table getValue
- Fix plugin button sizing using inline flex styles instead of invalid flex-2 class
- Configure button now properly sized at flex: 2, Update and Uninstall at flex: 1

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

* refactor(ui): reorganize plugin buttons into two-row layout

Configure button now takes full width on first row, while Update and
Uninstall buttons share the second row evenly. This makes Configure
more prominent and separates destructive actions to a second row.

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

* fix(ui): override inline-flex on Configure button to enable full width

The .btn class uses display: inline-flex which prevents w-full from working.
Added inline style to override with display: flex and width: 100% so the
Configure button properly takes the full width of its row.

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

* fix(ui): use inline styles for plugin action buttons layout

Replace Tailwind classes with explicit inline styles to ensure proper
two-row layout for plugin action buttons. Configure button on first row
at full width, Update and Uninstall sharing second row evenly.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 10:40:13 -05:00