141 Commits

Author SHA1 Message Date
3d6ef5c7b4 chore(git): stop tracking runtime db state files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:35:50 +12:00
78a4ce009c feat(ui): refresh preset data flow and bump driver pointer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:56 +12:00
7ccab6fbc4 feat(zones): persist per-zone brightness and update submodules
Store zone brightness in model/data flow, apply it in the zones UI, and record updated led-driver, led-simulator, and led-tool submodule pointers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:49:06 +12:00
pi
827eb97203 feat(settings): server global brightness and Wi-Fi driver resync
- Serve GET /settings as JSON by removing duplicate HTML route (use /settings/page for the standalone UI).

- Save global_brightness via PUT; broadcast to connected drivers; push saved level when outbound WS connects.

- Zones UI loads brightness from GET /settings only (no localStorage).

- Bump led-driver submodule for settings.save on brightness with save flag.

- Extend API doc and endpoint tests for global_brightness.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:15:30 +12:00
pi
3cca0cffc5 chore: bump led-tool and led-driver submodules
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:32 +12:00
pi
d36828bde2 feat(ui): persist header brightness slider in localStorage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:31 +12:00
pi
ed0048c795 chore(service): avoid network-online stall and speed pipenv boot
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:31 +12:00
pi
b316edbaf9 fix(wifi): stagger driver ws dials and extend initial retry window
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:31 +12:00
c1b0c41ef2 fix(transport): disable UART ESP-NOW bridge by default
Require serial_enabled true in settings to open serial_port; default false in
set_defaults for Wi-Fi-only and dev machines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 15:07:16 +12:00
3bb75d49de feat(util): add binary envelope packing and message helpers
Includes tests for v1/v2 envelope round-trips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:37 +12:00
3d77cb448a chore: add vertical stand OpenSCAD model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:35 +12:00
49383c0003 feat(espnow): add espnow-sender utility
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:35 +12:00
7d821b9c1c chore(db): add local preset fixtures
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:35 +12:00
9b7e387ea6 chore(scripts): add dev-run helper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:34 +12:00
b4f0d1891e chore(submodule): bump led-driver and led-tool; register led-simulator
led-simulator was already a gitlink; add the missing .gitmodules entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:30 +12:00
0da30b6d6b fix(submodule): update led-tool pointer to existing commit 2026-04-30 23:28:39 +12:00
6cbb728d9a feat(patterns): add new pattern suite and improve mobile controls
Add a broad set of LED patterns with metadata/tests and update zone/profile preset seeding, while refining mobile/desktop UI behavior for scrolling, brightness controls, and bulk pattern sending.
2026-04-23 20:07:55 +12:00
ff92451a76 feat(profiles): seed twinkle preset defaults
Made-with: Cursor
2026-04-21 20:43:25 +12:00
60485bc06a feat(ui): add clear device presets action
Made-with: Cursor
2026-04-21 00:44:38 +12:00
f6f299c3e5 feat(presets): add radiate pattern defaults
Made-with: Cursor
2026-04-20 23:38:02 +12:00
66485f5c59 chore(led-driver): bump submodule for patterns and tests
Made-with: Cursor
2026-04-19 23:28:22 +12:00
5f9ff9bcc9 style(ui): presets patterns and layout tweaks
Made-with: Cursor
2026-04-19 23:28:08 +12:00
35730b36f0 feat(api): improve pattern deploy and device tcp handling
Made-with: Cursor
2026-04-19 23:28:01 +12:00
d516833cc3 feat(profiles): seed colour cycle flicker and flame presets
Made-with: Cursor
2026-04-19 23:27:57 +12:00
220be64dec feat(db): add flicker flame presets and pattern metadata
Made-with: Cursor
2026-04-19 23:27:49 +12:00
b433477c64 chore(db): trim device registry
Made-with: Cursor
2026-04-19 23:27:37 +12:00
43b7047c57 chore(submodule): bump led-tool for cli upload flags
Made-with: Cursor
2026-04-15 00:46:40 +12:00
167417d1ec feat(ui): add web led-tool usb controls
Made-with: Cursor
2026-04-15 00:46:31 +12:00
fb8141b320 fix(server): close http listener cleanly on shutdown 2026-04-15 00:00:23 +12:00
96712dda88 feat(controller): migrate wifi drivers from tcp to websocket clients 2026-04-14 23:13:26 +12:00
f5a7b42e7c fix(rules): revert unintended submodule changes 2026-04-14 21:54:02 +12:00
1b1e9d727e chore(rules): enforce strict user-scoped changes 2026-04-14 21:50:55 +12:00
668d29b786 chore(test): move pytest defaults to pyproject.toml
Made-with: Cursor
2026-04-12 02:39:39 +12:00
e5f42e099e chore: remove esp32 firmware tree and dev mpremote helper
Made-with: Cursor
2026-04-12 02:39:37 +12:00
a9edda38ef test(browser): fixture, env host and pacing, safer colour inputs
Made-with: Cursor
2026-04-12 02:34:46 +12:00
edec5ff460 chore(git): ignore pytest cache and ropeproject
Made-with: Cursor
2026-04-12 02:34:44 +12:00
pi
264eb7296f test: fix zone_ctl fixture, pattern assertions, and browser cleanup
Made-with: Cursor
2026-04-12 00:27:43 +12:00
pi
fbd4295302 feat(ui): patterns list and create form layout
Made-with: Cursor
2026-04-12 00:13:58 +12:00
pi
7bdb324ebc feat(patterns): driver_patterns helper, on/off ota guard, drop duplicate py tree
Made-with: Cursor
2026-04-12 00:13:56 +12:00
pi
28b19b5219 docs: zones, transports, pattern ota, and submodule readmes
Made-with: Cursor
2026-04-12 00:13:54 +12:00
pi
75ddd559c9 chore(db,led-tool): sync device/zone data and led-tool submodule
Made-with: Cursor
2026-04-11 15:20:26 +12:00
pi
5a1067263a chore: add pattern samples, http driver helpers, OTA/UDP test tools
- patterns/: sample dynamic pattern modules for OTA
- esp32/msg.json: example bridge message shape
- models/http_driver.py, wifi_peer.py: Wi-Fi driver HTTP poll helpers
- tests: pattern OTA send script and UDP discovery echo server
- Submodule led-driver: http_poll and test utilities

Made-with: Cursor
2026-04-11 15:19:15 +12:00
pi
e67de6215a feat(patterns,api): pattern OTA, graceful shutdown, driver delivery updates
- Pattern controller/UI and presets patterns tab for OTA to Wi-Fi drivers
- Device controller extensions; driver_delivery chunk handling
- main: SIGINT/SIGTERM shutdown, TCP/UDP server close coordination
- Submodule led-driver: Wi-Fi default transport, lazy espnow import, dynamic patterns

Made-with: Cursor
2026-04-11 15:10:23 +12:00
pi
7179b6531e feat(controller): udp hello discovery and remove tcp registration
Made-with: Cursor
2026-04-06 21:28:13 +12:00
pi
fd618d7714 feat(zones): rename tabs to zones across api, ui, and storage
Made-with: Cursor
2026-04-06 18:22:03 +12:00
pi
d1ffb857c8 feat(ui): devices tcp status, tabs send, preset websocket hooks
Made-with: Cursor
2026-04-06 00:22:00 +12:00
pi
f8eba0ee7e feat(api): tcp driver registry, identify, preset push delivery
- Track Wi-Fi TCP clients, liveness pings, disconnect broadcast, bind errors via gather\n- Device list/get include connected; POST identify with __identify preset\n- Presets push/send delivery helpers; bump led-driver hello type

Made-with: Cursor
2026-04-06 00:21:57 +12:00
pi
e6b5bf2cf1 feat(devices): wifi tcp registry, device API/UI, tests; bump led-tool
Made-with: Cursor
2026-04-05 21:13:07 +12:00
pi
fbae75b957 chore(cursor): add scoped-fixes rule for minimal changes
Made-with: Cursor
2026-04-05 21:13:03 +12:00
pi
93476655fc test: add tcp mock server with bind conflict hints
Made-with: Cursor
2026-04-05 16:41:23 +12:00
pi
09a87b79d2 docs(ui): update help assets and regenerate help pdf 2026-03-26 00:40:40 +13:00
pi
ec39df00fc feat(settings/espnow): validate wifi_channel and wire into firmware 2026-03-26 00:40:21 +13:00
pi
43d494bcb9 fix(api): prevent circular reference in pattern create 2026-03-26 00:40:08 +13:00
pi
fed312a397 fix(test/endpoints): add pytest coverage for all Microdot routes 2026-03-26 00:39:41 +13:00
63235c7822 fix(ui): enforce save semantics for default and preset chunks 2026-03-22 02:53:34 +13:00
5badf17719 refactor(ui): simplify modal interactions and refresh fixtures 2026-03-22 02:00:28 +13:00
4597573ac5 fix(ui): update preset send/default behavior in edit mode 2026-03-22 01:47:32 +13:00
1550122ced fix(ui): populate preset patterns when definitions are empty
Made-with: Cursor
2026-03-22 00:08:12 +13:00
b7c45fd72c docs(ui): switch user-facing spelling to colour
Made-with: Cursor
2026-03-22 00:00:12 +13:00
9479d0d292 chore(cursor): add commit and spelling rules
Made-with: Cursor
2026-03-21 23:54:33 +13:00
3698385af4 feat(ui): help sections, menu order, remove settings, send presets edit-only
Made-with: Cursor
2026-03-21 23:51:02 +13:00
ef968ebe39 docs: run/edit mode, profiles behavior, send presets
Made-with: Cursor
2026-03-21 23:51:00 +13:00
a5432db99a feat(ui): gate profile create/clone/delete to edit mode
Made-with: Cursor
2026-03-21 23:50:59 +13:00
764d918d5b data: update local db fixtures and browser test expectations
Made-with: Cursor
2026-03-21 23:15:55 +13:00
edadb40cb6 docs: rewrite API reference for current HTTP and driver flows
Made-with: Cursor
2026-03-21 23:15:44 +13:00
9323719a85 feat(ui): add run/edit workflow and improve preset color editing
Made-with: Cursor
2026-03-21 23:15:31 +13:00
91de705647 feat(profiles): seed new profiles and refresh tabs on apply
Made-with: Cursor
2026-03-21 23:15:19 +13:00
3ee7b74152 fix(api): stabilize palette and preset endpoints
Made-with: Cursor
2026-03-21 23:15:08 +13:00
98bbdcbb3d chore: add dev watch command to Pipfile scripts
Made-with: Cursor
2026-03-21 23:15:00 +13:00
a2abd3e833 data: refresh db JSON fixtures
Made-with: Cursor
2026-03-21 20:17:33 +13:00
550217c443 ui: data-bwignore on AP password fields for password managers
Made-with: Cursor
2026-03-21 20:17:33 +13:00
2d2032e8b9 esp32: log startup and UART receive for debugging
Made-with: Cursor
2026-03-21 20:17:33 +13:00
81bf4dded5 docs: update msg.json example payload
Made-with: Cursor
2026-03-21 20:17:33 +13:00
a75e27e3d2 feat: device model, API, static UI, and endpoint tests
Made-with: Cursor
2026-03-21 20:17:33 +13:00
13538c39a6 tests: skip browser tests when no driver; try Firefox after Chrome
Made-with: Cursor
2026-03-21 20:17:33 +13:00
7b724e9ce1 tests: point model tests at db/ and align palette assertions
Made-with: Cursor
2026-03-21 20:17:33 +13:00
aaca5435e9 chore: gitignore local settings.json (session secret)
Made-with: Cursor
2026-03-21 20:17:33 +13:00
b64dacc1c3 Stop ignoring esp32; drop esp32 rules from .gitignore
Made-with: Cursor
2026-03-21 20:08:24 +13:00
8689bdb6ef Restore esp32 MicroPython sources (main, benchmark_peers)
Adjust .gitignore to ignore esp32/* except *.py so firmware .bin stays untracked.

Made-with: Cursor
2026-03-21 19:59:52 +13:00
c178e87966 Ignore esp32 folder 2026-03-21 19:53:19 +13:00
dfe7ae50d2 Add led-tool and led-driver submodules 2026-03-21 19:52:59 +13:00
8e87559af6 Add led-tool and led-driver as submodules 2026-03-21 19:52:14 +13:00
aa3546e9ac Remove obsolete scripts and root config files
Drop clear-debug-log, install, run_web, send_empty_json, esp32 helpers,
and root msg.json/settings.json in favor of current layout.

Made-with: Cursor
2026-03-21 19:47:29 +13:00
b56af23cbf Add scripts: start, copy ESP32 main, install boot service
Made-with: Cursor
2026-03-15 23:43:27 +13:00
ac9fca8d4b Pi port: serial transport, addressed ESP-NOW bridge, port 80
- Run app on Raspberry Pi: serial to ESP32 bridge at 912000 baud, /dev/ttyS0
- Remove ESP-NOW/MicroPython-only code from src (espnow, p2p, wifi, machine/Pin)
- Transport: always send 6-byte MAC + payload; optional to/destination_mac in API and WebSocket
- Settings and model DB use project paths (no root); fix sys.print_exception for CPython
- Preset/settings controllers use get_current_sender(); template paths for cwd=src
- Pipfile: run from src, PORT from env; scripts for port 80 (setcap) and test
- ESP32 bridge: receive 6-byte addr + payload, LRU peer management (20 max), handle ESP_ERR_ESPNOW_EXIST
- Add esp32/main.py, esp32/benchmark_peers.py, scripts/setup-port80.sh, scripts/test-port80.sh

Made-with: Cursor
2026-03-15 17:16:07 +13:00
0fdc11c0b0 ESP-NOW: STA interface, notify browser on send failure
- Activate STA interface before ESP-NOW to fix ESP_ERR_ESPNOW_IF
- Notify browser on send failure: WebSocket sends error JSON; preset API returns 503
- Use exceptions for failure (not return value) to avoid false errors when send succeeds
- presets.js: handle server error messages in WebSocket onmessage

Made-with: Cursor
2026-03-08 23:47:55 +13:00
91bd78ab31 Add favicon route and minor cleanup
- Add /favicon.ico route (204) to avoid browser 404
- CSS formatting tweaks
- Pipfile trailing newline

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 11:49:11 +13:00
2be0640622 Remove WiFi station (client) support
- Drop station connect/status/credentials from wifi util and settings API
- Remove station activation from main
- Remove station UI and JS from index, settings template, and help.js
- Device settings now only configure WiFi Access Point

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 11:49:04 +13:00
0e96223bf6 Send tab defaults with presets.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:40:22 +13:00
d8b33923d5 Fix heartbeat LED pin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:40:14 +13:00
4ce515be1c Update Python dependencies for device tooling.
This adds ampy support and refreshes lockfile versions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:28 +13:00
f88bf03939 Update browser tests for mobile preset layout.
This keeps UI checks aligned with the new tab/preset flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:21 +13:00
7cd4a91350 Add favicon handler and heartbeat LED blink.
This keeps the UI console clean and makes device status visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:15 +13:00
d907ca37ad Refresh tabs/presets UI and add a mobile menu.
This improves navigation and profile workflows on smaller screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:09 +13:00
6c6ed22dbe Scope presets to active profiles and support cloning.
This keeps data isolated per profile while letting users duplicate setups quickly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 13:51:02 +13:00
00514f0525 Add in-app settings menu and fix settings API
Move WiFi and device name configuration into a modal menu, reuse existing settings endpoints, and harden settings serialization and startup for MicroPython.
2026-01-29 00:54:20 +13:00
cf1d831b5a Align controller backend and data with new presets
Update palettes, profiles, tabs, preset sending, and ESPNow message format to match the new preset defaults and driver short-field schema.
2026-01-29 00:04:23 +13:00
fd37183400 Update frontend for presets, tabs, and help
Align frontend with new preset ID usage and shortened driver fields, improve tab/preset interactions, and refine help and editor UI.
2026-01-28 23:27:50 +13:00
5fdeb57b74 Extend endpoint and browser tests for ESPNow and UI
Add coverage for /presets/send and updated tab/preset UI workflows in HTTP and Selenium tests.
2026-01-28 04:44:41 +13:00
1576383d09 Update tab UI, presets interactions, and help
Refine tab presets selection and editing, add per-tab removal, improve layout, and provide an in-app help modal.
2026-01-28 04:44:30 +13:00
8503315bef Add ESPNow preset send backend support
Implement ESPNow helper model, WebSocket forwarding, and /presets/send endpoint that chunks and broadcasts presets to devices.
2026-01-28 04:43:45 +13:00
928263fbd8 Remove Python cache files from version control
- Remove __pycache__ directories that should not be tracked
- These are now ignored via .gitignore
2026-01-27 13:05:22 +13:00
7e33f7db6a Add additional configuration and utility files
- Add install script and message configuration
- Add settings controller and templates
- Add ESP-NOW message utility
- Update API documentation
2026-01-27 13:05:09 +13:00
e74ef6d64f Update main application and dependencies
- Update main.py and run_web.py for local development
- Update microdot session handling
- Update wifi utility
2026-01-27 13:05:07 +13:00
3ed435824c Add Selenium dependency for browser tests 2026-01-27 13:05:04 +13:00
d7fabf58a4 Fix MicroPython compatibility issues in Model class
- Fix JSONDecodeError handling (use ValueError for MicroPython)
- Fix sys.print_exception argument issues
- Improve error handling in load() method
- Add proper file persistence with flush() and os.sync()
2026-01-27 13:05:02 +13:00
a7e921805a Update controllers to return JSON and fix parameter handling
- Fix decorator parameter order issues with @with_session
- Return JSON responses instead of HTML fragments
- Add proper error handling with JSON error responses
- Fix route parameter conflicts in delete and update endpoints
2026-01-27 13:05:01 +13:00
c56739c5fa Refactor UI to use JavaScript instead of HTMX
- Replace HTMX with plain JavaScript for tab management
- Consolidate tab UI into single button like profiles
- Add cookie-based current tab storage (client-side)
- Update profiles.js to work with new JSON response format
2026-01-27 13:05:00 +13:00
fd52e40d17 Add endpoint tests and consolidate test directory
- Add HTTP endpoint tests to mimic browser interactions
- Move old test files from test/ to tests/ directory
- Add comprehensive endpoint tests for tabs, profiles, presets, patterns
- Add README documenting test structure and how to run tests
2026-01-27 13:04:56 +13:00
f48c8789c7 Add browser automation tests for UI workflows
- Add Selenium-based browser tests for tabs, profiles, presets, and color palette
- Test drag and drop functionality for presets in tabs
- Include cleanup functionality to remove test data after tests
- Tests run against device at 192.168.4.1
2026-01-27 13:04:54 +13:00
80ff216e54 Update preset format with n7/n8 parameters
- Add n7 and n8 fields to preset definitions
- Update preset data format
2026-01-17 21:40:38 +13:00
1fb3dee942 Update tab storage to 2D grid format
- Change presets from flat array to 2D grid layout
- Add presets_flat array for backward compatibility
- Support 3-column grid layout for preset positioning
2026-01-17 21:40:37 +13:00
a4502055fb Add test utilities and scripts
- Add test directory with main.py, p2p.py, ws.py
- Add send_empty_json.py WebSocket test script
2026-01-17 21:40:11 +13:00
6e61ec8de6 Add P2P communication module
- Implement ESP-NOW async communication
- Support sending string, dict, or bytes data
- Use asend for async broadcast messaging
2026-01-17 21:40:10 +13:00
48d02f0e70 Update watch script path in Pipfile
- Fix watch script to use relative paths
2026-01-17 21:40:08 +13:00
cacaa3505e Add pattern definitions endpoint
- Add /definitions endpoint to pattern controller
- Load pattern.json with fallback paths for local dev and MicroPython
2026-01-17 21:40:07 +13:00
97ffc69b12 Add drag-and-drop for presets and colors, max_colors validation, and 2D grid layout
- Add drag-and-drop to reorder presets in tabs (2D grid layout)
- Add drag-and-drop to reorder colors within presets
- Add max_colors field to pattern definitions
- Hide color section when max_colors is 0
- Validate color count against pattern max_colors limit
- Store presets in 2D grid format (3 columns)
- Remove left panel from tab content, show only presets
- Update color palette to show swatches instead of hex codes
- Improve preset editor UI with visual color swatches
2026-01-17 00:58:50 +13:00
9f37dbbff0 Add data files and local tooling 2026-01-16 22:31:47 +13:00
df37f15f73 Update UI for palettes, presets, and patterns 2026-01-16 22:31:36 +13:00
9c43a0a22b Update backend models, controllers, and session 2026-01-16 22:31:24 +13:00
d41faddfca Update static files and templates
- Add htmx library
- Update main.js and styles.css
- Update index.html template
2026-01-11 21:34:19 +13:00
9e2409430c Add documentation and utility modules
- Add API specification documentation
- Add system specification document
- Add UI mockups and documentation
- Add utility modules (wifi)
2026-01-11 21:34:18 +13:00
5f6e45af09 Clean up obsolete files
- Remove old web.py, wifi.py, patterns.py
- Remove old static files from root
- Remove unused component files
2026-01-11 21:34:17 +13:00
cccda24448 Add comprehensive model tests
- Add tests for all model CRUD operations
- Add test for base Model class
- Add test runner for all model tests
- Tests include cleanup and validation
2026-01-11 21:34:17 +13:00
5cca60d830 Update main.py with controllers and static route
- Mount model controllers as subroutes
- Add static file serving route
- Integrate controllers into main application
2026-01-11 21:34:16 +13:00
ac750a36e7 Add controllers for models
- Add REST API controllers for all models
- Controllers use Microdot subroutes
- Support CRUD operations via HTTP endpoints
- Controllers: preset, profile, group, sequence, tab, palette
2026-01-11 21:34:15 +13:00
01f373f0bd Add model base class and models
- Add Model base class with get_next_id() method
- Add Preset model with CRUD operations
- Add Profile model with tabs and palette support
- Add Group model for device grouping
- Add Sequence model for preset sequencing
- Add Tab model for organizing device controls
- Add Palette model for color collections
2026-01-11 21:34:14 +13:00
d00d21e2b6 Split up main.js 2025-07-08 18:24:54 +12:00
deca1b6c37 Add basic draggable item that saves to the server 2025-07-08 17:39:09 +12:00
5c35e68ab2 Rename to styles.css 2025-07-07 18:20:11 +12:00
8b6bbdeb56 Update wifi.py 2025-05-18 21:31:03 +12:00
09bc09cca3 Update web.py 2025-05-18 21:31:00 +12:00
e57feda131 Delete index_html.py 2025-05-18 21:30:57 +12:00
3242aa464b Update index.html 2025-05-18 21:30:50 +12:00
72b7ba39ef Update main.js 2025-05-18 21:30:47 +12:00
c2a0cfaef4 Update main.css 2025-05-18 21:30:44 +12:00
4c3337a232 Update settings.py 2025-05-18 21:30:40 +12:00
825ae1f637 Update main.py 2025-05-18 21:30:37 +12:00
14a70cb024 Update boot.py 2025-05-18 21:30:34 +12:00
425511d41f Create Pipfile.lock 2025-05-18 21:30:30 +12:00
3e5239f3c6 Create patterns.py 2025-05-18 21:30:24 +12:00
207 changed files with 29814 additions and 860 deletions

116
.cursor/debug.log Normal file
View File

@@ -0,0 +1,116 @@
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434706543}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434706552}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434707852}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434707860}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434708466}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434708474}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434709765}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434709787}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434717888}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434717903}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434717904}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434717913}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434738084}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434738093}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434739031}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434739040}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434746453}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434746496}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434748859}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434748866}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434773921}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434773931}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434773931}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434773940}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434810105}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434810119}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434816383}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434816399}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434816400}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434816414}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434944656}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434944756}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434945369}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434945427}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946108}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946162}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946680}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434946736}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768434947640}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768434947656}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768434953064}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":201,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768434953079}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434953080}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768434953093}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435103720}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435103776}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435104593}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435104647}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435105158}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435105253}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435275247}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435275315}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276178}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276278}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276945}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435276998}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768435278150}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768435278162}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768435281966}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":400,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768435281988}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435387623}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435387680}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435388399}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435388454}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/create-form-fragment","targetId":""},"timestamp":1768435389910}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/create-form-fragment","targetId":""},"timestamp":1768435389922}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs","targetId":"tabs-list"},"timestamp":1768435393213}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs","targetId":"tabs-list"},"timestamp":1768435393231}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435393233}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435393245}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435395729}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435395748}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435396771}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435396788}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435398656}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/12/content-fragment","targetId":"tab-content"},"timestamp":1768435398674}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435399748}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/13/content-fragment","targetId":"tab-content"},"timestamp":1768435399774}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435668310}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":false,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435668311}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435668355}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435669841}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":false,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435669842}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435669852}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435672686}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435673713}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435674316}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435674560}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435680419}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":false,"modalActive":null},"timestamp":1768435680897}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435814285}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435814287}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true},"timestamp":1768435814287}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435814350}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815080}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true},"timestamp":1768435815081}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435815082}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815135}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815724}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true},"timestamp":1768435815725}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":false,"hasLightingController":false},"timestamp":1768435815725}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435815778}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:openModal","message":"palette modal opened","data":{"active":true},"timestamp":1768435817104}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":true,"modalActive":true},"timestamp":1768435817105}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:closeModal","message":"palette modal closed","data":{"active":false},"timestamp":1768435820180}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931118}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":true,"hasLightingController":false},"timestamp":1768435931120}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true,"hasContainer":true,"hasAddButton":true},"timestamp":1768435931119}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931173}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:htmx:beforeRequest","message":"htmx request","data":{"path":"/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931791}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H1","location":"src/templates/index.html:DOMContentLoaded","message":"palette elements presence","data":{"hasPaletteButton":true,"hasPaletteModal":true,"hasPaletteContainer":true,"hasLightingController":false},"timestamp":1768435931793}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H3","location":"src/static/color_palette.js:DOMContentLoaded","message":"palette script loaded","data":{"hasButton":true,"hasModal":true,"hasClose":true,"hasContainer":true,"hasAddButton":true},"timestamp":1768435931793}
{"sessionId":"debug-session","runId":"tabs-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:htmx:afterRequest","message":"htmx response","data":{"status":200,"responseURL":"http://localhost:5000/tabs/list-fragment","targetId":"tabs-list"},"timestamp":1768435931895}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H2","location":"src/templates/index.html:color-palette-btn","message":"color palette button clicked","data":{"hasPaletteModal":true,"modalActive":true},"timestamp":1768435933111}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:openModal","message":"palette modal opened","data":{"active":true},"timestamp":1768435933110}
{"sessionId":"debug-session","runId":"palette-pre-fix","hypothesisId":"H4","location":"src/static/color_palette.js:closeModal","message":"palette modal closed","data":{"active":false},"timestamp":1768435943332}

26
.cursor/rules/commit.mdc Normal file
View File

@@ -0,0 +1,26 @@
---
description: Git commit messages and how to split work into commits
alwaysApply: true
---
# Commits
When preparing commits (especially when the user asks to commit):
1. **Prefer multiple commits** over one large commit when changes span distinct concerns (e.g. UI vs docs vs API). One logical unit per commit.
2. **Message format:** `type(scope): short imperative subject` (lowercase subject after the colon; no trailing period).
- **Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf` (use what fits).
- **Scope:** optional but encouraged — e.g. `ui`, `api`, `profiles`, `presets`, `esp32`.
3. **Subject line:** ~50 characters or less; describe *what* changed, not the ticket number alone.
4. **Body:** only when needed (breaking change, non-obvious rationale, or multiple bullets). Otherwise subject is enough.
**Examples**
- `feat(ui): gate profile delete to edit mode`
- `docs: document run vs edit in API`
- `fix(api): resolve preset delete route argument clash`
**Do not**
- Squash unrelated fixes and doc tweaks into one commit unless the user explicitly wants a single commit.
- Use vague messages like `update`, `fixes`, or `wip`.

View File

@@ -0,0 +1,45 @@
---
description: led-driver — MicroPython ESP32: mpremote, imports, layout, I/O, no pycache in src
globs: led-driver/**
alwaysApply: false
---
# led-driver (MicroPython / ESP32)
## Device and tests
1. Validate **MicroPython behaviour** under **`led-driver/`** with **`mpremote connect <PORT> …`** on the chip. Host **`python3`** does **not** prove the firmware build.
2. **Execution target is fixed:** treat **`led-driver/`** code as firmware that runs **only on MicroPython ESP32 devices**. Do **not** run `led-driver/src/main.py` (or other firmware modules) with host CPython as a normal execution path.
3. **Flow:** `mpremote connect <PORT> cp <local> :<on-flash>` then `run <script>.py`. Inline commands only — no **`.sh`** wrappers unless the user asks. Default serial placeholder: **`/dev/ttyACM0`**.
4. Checks that **import and run** code from **`led-driver/src/`** belong in **`led-driver/tests/`** and run with **`mpremote run …`**. **Do not** add **`pytest`** under **`led-controller/tests/`** that **`sys.path`**-loads **`led-driver/src`** and runs those modules on CPython.
## Import layout
4. **No** **`sys.path.insert`**, **`__file__`** path stitching, or other import-path hacks under **`led-driver/`**. Use device flash search path, or host **`PYTHONPATH`** / layout you control.
5. **No** “import fixer” code — fix copy order, flash paths, or env instead.
## Imports (fail loudly)
6. If a dependency does not load, **crash** and fix deployment or filesystem. **Do not** catch **`ImportError`** / **`ModuleNotFoundError`** around **`import`** / **`from … import`** for app/firmware modules (`settings`, `utils`, `network`, `machine`, …).
7. **Allowed — stdlib name pairs only** (MicroPython vs CPython): one **`except ImportError`**, then **one** fallback import, **no** extra logic in **`except`**:
- `uos` → `os`
- `ubinascii` → `binascii`
- `utime` → `time`
Not for “maybe the file exists on flash” — only different **stdlib** names.
8. **No** large inline reimplementations after **`except ImportError`** — deploy the real module.
## I/O
9. Non-blocking **recv** / **accept**: use plain **`except OSError:`** (or **break** on empty). **No** errno / EAGAIN / EWOULDBLOCK tables or **`getattr(errno, …)`** unless fixing a **documented** target bug.
10. Minimal **`try` / `except OSError`** around optional socket options (e.g. **`SO_REUSEADDR`**) is fine.
## Host Python and `src/`
11. **Do not** leave **`__pycache__/`** or **`.pyc`** under **`led-driver/src/`** from host runs. Remove if created; **`.gitignore`** already ignores it. Prefer **`PYTHONDONTWRITEBYTECODE=1`** or **`-B`** when host Python must touch **`led-driver/src/`**.

View File

@@ -0,0 +1,12 @@
---
description: Require test pattern, pattern metadata, and test preset for new patterns
alwaysApply: true
---
# Pattern workflow requirements
1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`.
2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there.
3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern.

View File

@@ -0,0 +1,18 @@
---
description: Fix only the issue or task the user gave; no refactors unless requested
alwaysApply: true
---
# Scoped fixes (no overscoping)
1. **Change only what is needed** to satisfy the users *current* request (bug, error, feature, or explicit follow-up). Prefer the smallest diff that fixes it.
2. **Refactors:** Do **not** refactor (restructure, rename, extract functions, change abstractions, or “make it nicer”) **unless the user explicitly asked for a refactor**. A bug fix may touch nearby lines only as much as required to correct the bug.
3. **Do not** rename, reformat, or “clean up” unrelated code; do not add extra error handling, logging, or features you were not asked for.
4. **Related issues:** If you spot other problems (missing functions, wrong types elsewhere, style), you may **mention them in prose** — do **not** fix them unless the user explicitly asks.
5. **Tests and docs:** Add or change tests or documentation **only** when the user asked for them or they are strictly required to verify the requested fix.
6. **Multiple distinct fixes:** If the user reported one error (e.g. a single `TypeError`), fix **that** cause first. Offer to tackle follow-ups separately rather than bundling.

View File

@@ -0,0 +1,10 @@
---
description: British spelling for user-facing text; technical identifiers stay as-is
alwaysApply: true
---
# Spelling: colour
- **User-facing strings** (Help modal, button labels, README prose, `docs/`, error messages shown in the UI): use **British English** — **colour**, **favour**, **behaviour**, etc., unless quoting existing product names.
- **Do not rename** existing code for spelling: **identifiers**, file names, URL paths, JSON keys, CSS properties (`color`), HTML attributes (`type="color"`), and API field names stay as they are (`color`, `colors`, `palette`, etc.) so nothing breaks.
- **New** UI copy and docs should follow **colour** in prose; new code symbols may still use `color` when matching surrounding APIs or conventions.

View File

@@ -0,0 +1,16 @@
---
description: enforce strict user-scoped changes only
alwaysApply: true
---
# Strict User Scope
1. Only implement exactly what the user asked for in the current message.
2. Do not add extra refactors, cleanups, renames, architecture changes, or behavioural changes unless the user explicitly asked for them.
3. If a potential improvement is noticed, mention it briefly and ask before changing code.
4. For revert/undo requests, perform the narrowest possible revert and do not modify anything else.
5. Keep edits minimal and local to the requested area.

View File

@@ -0,0 +1,18 @@
---
description: Keep led-driver and led-tool git submodules in sync when updating led-controller
alwaysApply: true
---
# Submodule pointers (`led-driver`, `led-tool`)
This repo tracks **`led-driver`** and **`led-tool`** as git submodules (see `.gitmodules`).
When you **update led-controller** work that should ship with matching firmware or CLI behaviour—or when you finish changes **inside** those submodule directories—**record the new submodule commits in the parent repo**:
1. In each submodule, commit and push on its remote if there are local commits (or ensure the checkout is the intended revision).
2. From the **led-controller** root: `git add led-driver led-tool` after their HEADs point at the right commits.
3. Include the parent-repo commit that bumps the gitlinks (so CI and clones get consistent trees).
**Do not** leave submodule directories dirty or forgotten while presenting the parent repo as “done”: either commit the submodule pointer update in led-controller, or leave an explicit note if the user must push submodule remotes first.
If the user only asked for a submodule bump with no code edits, a single `chore(submodules): bump led-driver and led-tool` style commit is appropriate (see commit rule).

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Python
__pycache__/
# led-driver/src is MicroPython source — never keep host __pycache__ there (see .cursor/rules/led-driver.mdc)
led-driver/src/__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Virtual environments
venv/
env/
ENV/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Project specific
scripts/.led-controller-venv
docs/.help-print.html
settings.json
db/
*.log
*.db
*.sqlite
.pytest_cache/
.ropeproject/

9
.gitmodules vendored Normal file
View File

@@ -0,0 +1,9 @@
[submodule "led-driver"]
path = led-driver
url = git@git.technical.kiwi:technicalkiwi/led-driver.git
[submodule "led-tool"]
path = led-tool
url = git@git.technical.kiwi:technicalkiwi/led-tool.git
[submodule "led-simulator"]
path = led-simulator
url = git@git.technical.kiwi:technicalkiwi/led-simulator.git

20
Pipfile
View File

@@ -7,8 +7,26 @@ name = "pypi"
mpremote = "*"
pyserial = "*"
esptool = "*"
pyjwt = "*"
watchfiles = "*"
requests = "*"
selenium = "*"
adafruit-ampy = "*"
microdot = "*"
websockets = "*"
[dev-packages]
pytest = "*"
[requires]
python_version = "3.12"
python_version = "3.11"
[scripts]
web = "python tests/web.py"
watch = "python -m watchfiles \"python tests/web.py\" src tests"
run = "sh -c 'cd src && python main.py'"
dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src"
test = "python -m pytest"
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"

1026
Pipfile.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,43 @@
# led-controller
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices. Outbound paths include:
- **Serial → ESP-NOW bridge**: JSON lines over UART to an ESP32 that forwards ESP-NOW frames (configure `serial_port` and baud in `settings.json` / Settings model).
- **Wi-Fi LED drivers**: TCP JSON lines (default port **8765** on the Pi; drivers discover the controller via **UDP 8766** broadcast).
## Run
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
- Start app: `pipenv run run` (override listen port with the **`PORT`** environment variable)
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
## UI modes
- **Run mode**: focused control view. Select zones/presets and apply profiles. Editing actions are hidden.
- **Edit mode**: management view. Shows **Zones**, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
## Profiles
- Applying a profile updates session scope and refreshes the active zone content.
- In **Run mode**, Profiles supports apply-only behaviour (no create/clone/delete).
- In **Edit mode**, Profiles supports create/clone/delete.
- Creating a profile always creates a populated `default` zone (starter presets).
- Optional **DJ zone** seeding creates:
- `dj` zone bound to device name `dj`
- starter DJ presets (rainbow, single colour, transition)
## Preset colours and palette linking
- In preset editor, selecting a colour picker value auto-adds it when the picker closes.
- Use **From Palette** to add a palette-linked preset colour.
- Linked colours are stored as palette references and shown with a `P` badge.
- When profile palette colours change, linked preset colours update across that profile.
## API docs
- Main API reference: `docs/API.md`
## Driver pattern modules
Pattern **`.py`** sources live under **`led-driver/src/patterns`**. The Pi app resolves that path via `util.driver_patterns.driver_patterns_dir()`. If you deploy without that tree next to the app, set **`LED_CONTROLLER_PATTERNS_DIR`** to the directory that contains those files.

1
db/group.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}

1
db/palette.json Normal file
View File

@@ -0,0 +1 @@
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}

1
db/pattern.json Normal file
View File

@@ -0,0 +1 @@
{"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "has_background": true}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "circle": {"n1": "Head Rate", "n2": "Max Length", "n3": "Tail Rate", "n4": "Min Length", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "has_background": true}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 1030 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1255, higher = more changes)", "n2": "Density (0255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "has_background": true}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}}

1
db/preset.json Normal file

File diff suppressed because one or more lines are too long

BIN
db/presets/1.bin Normal file

Binary file not shown.

3
db/presets/10.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœ%ÎÁ
Â0Ð_ñšCSµJîæ'D$¶«
ÄݦˆˆÿntOovæ²opxz´zޱ ¦P

2
db/presets/11.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xњ%ОAВ …б»<·,J5\Е4
К $84SX4Ж»eхеНШЅ B

1
db/presets/12.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xœ%ÎA л|·, ŠÐK˜ÆP;* 

2
db/presets/13.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœEÎÁ
Â0Ð_9ç`«Qɯˆ”Ô®ˆ»e“RDüwsðô˜™Ë¼ÁñIИx”uS²¬p˜c­¤ü¬»J-çèéþ¨LÅrï½ÃD9¾:¿uˆK„ª 9pg¥Ñ#ØÂ»Æ¾á‡Æ±qú1«ÜR¦!Mö¡Ãç <0B><>1

2
db/presets/14.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ=ÎÝ
!†á[‰¯StK[¼€½‰ˆ°v*ÁTü!"º÷Ü¤Žžá<C5BE>9˜¼¹4bu™VÙ…¢)…ÿåVÎÁ…”¡÷XO“RœãÀpJöz+žr[ R2ÌäÌzäœÁÔ KªÄàE;àKõ´èÓæß¶Ð ²£:»Îø%¦p±ŽŽvn? ¼?<3F>¨2ú

BIN
db/presets/15.bin Normal file

Binary file not shown.

BIN
db/presets/2.bin Normal file

Binary file not shown.

2
db/presets/3.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœUÎÁ
Â0ЙsM5Uò+"²µ«â¦lSDÄwiNž³3‡ý@èɈPJ2fª•Uþn×.ˆ§³Ã¨éþ¨Â‹å>‡‰3½}×9ÐZ bÕ•ÄÛÀè­]cß<08>¡qh7f-·”ù’&ûÁãûF9/.

2
db/presets/30.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœEÎÁ
Â0Ð_9ç`«Qɯˆ”Ô®ˆ»e“RDüwsðô˜™Ë¼ÁñIИx”uS²¬p˜c­¤ü¬»J-çèéþ¨LÅrï½ÃD9¾:¿uˆK„ª 9pg¥Ñ#ØÂ»Æ¾á‡Æ±qú1«ÜR¦!Mö¡Çç <0B>“1

BIN
db/presets/31.bin Normal file

Binary file not shown.

2
db/presets/32.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ%ͽÂ0 àW©Ž5C~•&VÆ
¡@<40>)uª4K…xwR<}ç»Á° —ks <DjÎ)¦ …É•B™ë¸ž¯µža;l¼×Ú{Üž9 ïÂ4×Á­ÐSt l«kæ[a'ì…ƒpN¦œ|ˆô}ýmðý-‰

1
db/presets/33.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xœMÎ1! †á¿b¾[=5ÌNÎnÆô@I°\€Åÿ»Å.²<oÚ¼Aîéa±?,ŽÅQ<C385>-fÂìZó…xÓþÇ·œr©°' !h~<´î-Õg…k‰÷G#_ùØ­0ùä^Ü#7-a;FX ka6ÂVØý˜K1ùKœø_Ÿ/ÐM4y

BIN
db/presets/34.bin Normal file

Binary file not shown.

2
db/presets/35.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ%ͽÂ0 àW©Ž5C~•&VÆ
¡@<40>)uª4K…xwR<}ç»Á° —ks <DjÎ)¦ …É•B™ë¸ž¯µža;l¼×Ú{Üž9 ïÂ4×Á­ÐSt l«kæ[a'ì…ƒpN¦œ|ˆô}ýmðý-‰

1
db/presets/36.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xœMÎ1! †á¿b¾[=5ÌNÎnÆô@I°\€Åÿ»Å.²<oÚ¼Aîéa±?,ŽÅQ<C385>-fÂìZó…xÓþÇ·œr©°' !h~<´î-Õg…k‰÷G#_ùØ­0ùä^Ü#7-a;FX ka6ÂVØý˜K1ùKœø_Ÿ/ÐM4y

BIN
db/presets/37.bin Normal file

Binary file not shown.

BIN
db/presets/38.bin Normal file

Binary file not shown.

3
db/presets/39.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœUÎÁ0„áw¯=¤jú*†<>
[m\[²”ƒ1¾»…ž<}ÉÌåÿ ºÁÂsŸ$P˜]Î$ño'Y`¯88ÒÚ{ô
7 ÷GŽ´”£5Fa"voX£ÜšlbÛè2ÆvãXé*¦rªœ+—<>YLC˜JM³·1•ºAÈo5qeî¿?ªð9±

BIN
db/presets/4.bin Normal file

Binary file not shown.

4
db/presets/40.bin Normal file
View File

@@ -0,0 +1,4 @@
PRST1xśMÎÁ0„áwŻ=$ű*†<>
[%Y[RÚ1ľ»…^<}ÉĚĺ˙Ŕ™7<E284A2>`ĺPa51rpËäŇ
tÇĹÚ©×<1A>Â#,ĎWtĽĺŁŞ{…™Ĺě V+<2B>=(†Ä
®5m¶՝ίk@×B[č

2
db/presets/41.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xśmŹÁ0 †ßĄ\wČ`ŮMQ^Â2ĄčâÜČ1Ćřîn̉—~í—?MűüC™F 0IďŃ™w¶ÚşÄ˛š7Ľm<C4BD>ËĺMęveýuUąo<v[şć:'§.Wop
Ć ¨ĺDN)ąx » <09><H¤)B2r"˘Śá@–Ć*ˇNŕ+&gGĄ±WC8<_ßĐéŽńpłhMţ”îýŹ!I°

2
db/presets/42.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xњUЋ;В0птТєp>°WAQґђ5X2Nд8BЬ;©hv¤·SМЃ_BдЙq(,њДр·Эg?ЗtEЕЅЦЦж­ТZіf
·иПdНJcЊВ$ћЯ “ЮТ Jq…PѓЪјt)ПР‚є] ЁАињњw,q¶ОЛи¦\Wп­^rнЕє°yЇКѕ?Эh>Ў

BIN
db/presets/43.bin Normal file

Binary file not shown.

2
db/presets/44.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœEÎM
Â0à«ÈsEÿ¢ôE$¶£â¤$Ó…ˆww0 góÁ{o1o°„ŠìÊì™)Ã`õ"”Y˜r<CB9C>°ÇFgƒk÷‡0-:k

3
db/presets/45.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœ=ŽA0E¯B>Û.
€KC*ŒØ¤¶¤Æxw<1B>Í{™7y!ØÁ€)s5';9
\å1Eï¡°XfJA~mø·1ú˜ußkÙÕZo^ls\®ÉÍw”å¸mµÂDÞ>a:Q»r„á´Bh¤ Z)aW°/8tÇ‚ÓKŠ7çip“üÙàý)<¡

3
db/presets/46.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xś-ÎÁ0Đ_!õ‡Šdo˝ô'Ś!Ş’”–”ĺ`Ś˙î<˝ÍĚö<>čfű•‹!Ížqs
cö9J·Çý?RHy]QZkŚÖ•Zc-n
÷<=_ý*“Zk…Ń÷µrşŤ<13>óćbę„T

2
db/presets/47.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1x<EFBFBD>5־A0…ב«<D791>ַ¶ @Dׂ- —0ֶT©<54>X[2ֶxwG׳ש&‎»˜yXh°M\₪<>׀<EFBFBD><D780>ֹ8<>0[
’ור/חט#%ט=ֺ¾†q”·r\¹כ<C2B9>ƒMע¥©*…ֹzף„מd5 Gh¦ֵ*„Zz+6b-1l ¿´™m¦ֻל2ֺLסגה"7ֹy5<79>־ד:G

2
db/presets/48.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ-ÎÁ Ð_1ã•ÔZŽúÆ´«’ 4°Õã¿»Š§7;sÙ¢»,˜
/îNP˜3å(í¿8¥<38>r<EFBFBD>Ýa©õ¶ìŽÙ_®©ÈÐh­0RpOØN¢9ÁržI!ˆ<C393>ØËWö{­+]eSéL9<4C>} ƒåƒ÷ªù0¿

2
db/presets/49.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1x<EFBFBD>=ЮA
Т0аЋШw<D0A8>EZ5JаK<14>б<EFBFBD>ZH<5A><48>L"онС<D0BD>Ћ7ќџѓFЄ<46>с!\e<>е<>`<60>I<EFBFBD>KдќнRHЅТ<D085>и<0E>ЕЮсlp-ѓу)<29>ЋНЕzС;=i<>/ee<65>иiІє:Sv<53>=МютЁсЧЦщG.щ>ОЬ<D09E>Овсѓ,<2C>

BIN
db/presets/5.bin Normal file

Binary file not shown.

2
db/presets/50.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ5ÎA0Ы<C390>϶ˆ¦è%Œ!F <20>´ÃÂïîhu6o2ÿ/æ ïVSâ"Ѹ’碟\"(lŽ™¢—ø—tÿ¤Kˆ æÒZ-#·ò£µ¸*Üâ<Nì)I¥ÖZa Å=`ZYÝΆãN
¾i„¦0RðMæ˜i3§ÌùËÃ}^¨›ù­Âë

BIN
db/presets/51.bin Normal file

Binary file not shown.

BIN
db/presets/52.bin Normal file

Binary file not shown.

2
db/presets/53.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ5Î=Â0 †á«Tk†þQ<C3BE>À%*T%Ô@¥TŽ; ÄÝIáå±ôzðÞ¾å¨ET Ž ·JT,V•ŧšÃð·0‰ ‡Ë>¸8™S¨ËÒ`äÙ¾A]Zíª¤²²<C2B2>¯@M¢ÎÉ7 v;÷-hã˜é2§ÌygpŸf¦1ýTáû^
7˜

3
db/presets/54.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1x<EFBFBD>Ν
Β0ΰW)γ5‡ώh­Ήϊ"%ΪU5)νAΔww5xϊ™9μ Α=BI
v>Η%Α`q"ΔA»o<ώγK<CEB3>#'Ψ#6‡²ο'ƒ3ϋΫ]%-κ²4<C2B2>hvOΨVO·J„^Ι T°M­Φ<C2AD><CEA6>ΐκ"l3»LΩgΊ Η«<CE97>iτ“ώSαύ<01><>5%

4
db/presets/55.bin Normal file
View File

@@ -0,0 +1,4 @@
PRST1xœMαÂ0 Ð_A×5CZ ´™Q~!¨ BR%î€ÿŽE¦gÝÝà7¢{ ˜
ofŸiž
ÇL9JõŸÞRH¹ÀœÐX{Ô½–¬µµ£ÆYášýýÁŠL:­&
îÓËéVN0œWRˆ­dB3[Ä]e_é+‡ÊðcÉiö<69>.~¿Z|¾¡ 61

1
db/presets/56.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xœ5ŽAƒ E¯b¾[¨U+WiŒ¡2¶¦ `š¦éÝ nxÌŒ|ùPÌÚÎ<C39A>¿ˆ60l2r&.?ýýlµuâRõ|àCt%Wuß5®n½Ýƒ!OjÎiùN¹ ÜN ¦¨¢35DÑ@¤é”Ñft}ÆùÀæì²jšVÓª#TSL<53>-)ËìZ³ôŒßQ•AÓ

1
db/presets/57.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xњEО1В0 Р« ПљЎiЎ ЂK „5)MЪФвоXНЂ—gщяБD72ВlF—зВ ѓЙ‰pЋьoчR^@glOлаbpЛющИmУ ЬФлкЉ$ђдВС:ҐХљТЃ¬Іi/о+}еP9®L9=|а«ф‹пжg2д

2
db/presets/58.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ=ÎÍ
Â0àWé5‡ô?ìM"} ‰vÕBMJD|wSž¾afû†5O!rˆ;³zç

3
db/presets/59.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1x°Mна
б0 ЮW▒вз╘SzTЯ%D╓╨Lm├┬ЬНfКе\╬ДOЫ ╦'а┌)С"┤ЬЙ°ВP3╔ ⌡©П}LЖ└Й8≈dуNЖр²╝╘©?8P√⌠Zk┘√╪{ц6р╨▒#,╖▒┌≥Жb
k└%Л4╜

2
db/presets/6.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœMÎK
Â0…á½§ÜT£tR$Ú«âMÉc âÞm<C39E>ˆ£þ39Oˆ»3,¦2Car¥p¿rŽ!¦ {ÀЍï‰0(œ¿ÞŠpž‡Î…‘ƒ{À"WK„-©²hXMK•î;Ëú—6° ¦±mìûøèÇù’Æë

4
db/presets/60.bin Normal file
View File

@@ -0,0 +1,4 @@
PRST1xœMÎA0Ы˜ï¶RÉ€KcŠŒBR[Ò c¼»­l\½Éÿùɼáí“ANr˜ÙFÙ
V+ÂÑçê?½b
8ö½éj<EFBFBD>—Ç,žS.ŒÖ
µù´›<04>Ä<EFBFBD>|ªL½¨)

BIN
db/presets/61.bin Normal file

Binary file not shown.

3
db/presets/62.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœ5ŽA0E¯B>Û.
€KCªŒBRÚ¦ c¼»ÅÙ¼7óÿb>ðv"0Í\D눙Š)¤8@!ZÙ—xOºò抲mµŒÜJ­W϶:n
÷4¾ö4K¹ÖZ¡'gß0<C39F>¨]8ÀpZHÁW0ÕVðõÞô˜ÇŒSF“qθlˆ)<GGÝØË«¾?ð¹<

3
db/presets/7.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœMŽ1Â0 Eïò»fp
<EFBFBD>(K/<2F>
­<EFBFBD>H!©Òt@ˆ»cÈÂô¾Ÿ¿%¿<>üƒá0†2F†ÂìkåþÕ˜c. ÜÝ0 ‘¸Î.%Üî5ñ"•Þ…‰£J&RðkÍpµ¬¬<C2AC>´HA§e•6mÜÂÉQ2p_¹kØ7Øæ’¯!ò9LòÆû¼Ã1ó

BIN
db/presets/8.bin Normal file

Binary file not shown.

2
db/presets/9.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ%ÎK
Ã0 Ы”éÖ‹$ýâ«”ÜFn ŽPJï^ÇÖæI£Í|Áf&hlFæÃ6¹HPXLŒ$œãÀù|d…~àhË WxŠ{O<69>®iFòæÝî»I1@GI¤À-tޏ«œ*çÊ¥r­Ü*÷Â"Á:Oƒs<>´ò”{

1
db/profile.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"name": "default", "type": "zones", "zones": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}

22
db/scene.json Normal file
View File

@@ -0,0 +1,22 @@
{
"1": {
"name": "Default Scene",
"sequences": [
"1"
],
"groups": [
"1"
]
},
"2": {
"name": "Party Mode",
"sequences": [
"1",
"2"
],
"groups": [
"1",
"2"
]
}
}

1
db/sequence.json Normal file
View File

@@ -0,0 +1 @@
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}}

33
dev.py
View File

@@ -1,33 +0,0 @@
#!/usr/bin/env python3
import subprocess
import serial
import sys
print(sys.argv)
port = sys.argv[1]
cmd = sys.argv[1]
for cmd in sys.argv[1:]:
print(cmd)
match cmd:
case "src":
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":" ], cwd="src")
case "lib":
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":" ])
case "ls":
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":" ])
case "reset":
with serial.Serial(port, baudrate=115200) as ser:
ser.write(b'\x03\x03\x04')
case "follow":
with serial.Serial(port, baudrate=115200) as ser:
while True:
if ser.in_waiting > 0: # Check if there is data in the buffer
data = ser.readline().decode('utf-8').strip() # Read and decode the data
print(data)

358
docs/API.md Normal file
View File

@@ -0,0 +1,358 @@
# LED Controller API
This document covers:
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
2. **LED driver JSON** — the compact **v1** message format. It is sent over the **serial → ESP-NOW bridge** to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields).
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
**Serial:** UART path and baud come from settings (defaults include `serial_port` such as `/dev/ttyS0` and `serial_baudrate`). **Wi-Fi drivers:** **UDP** on port **8766** is the **discovery** channel: each drivers JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known WiFi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings).
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
---
## UI behavior notes
The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (zone/preset selection and profile apply).
- **Edit mode**: shows editing/management controls (zones, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
Profiles are available in both modes, but behavior differs:
- **Run mode**: profile **apply** only.
- **Edit mode**: profile **create/clone/delete/apply**.
`POST /presets/send` is wired to the **Send Presets** UI action, which is exposed in Edit mode.
---
## Session and scoping
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
---
## Static pages and assets
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Main UI (`templates/index.html`) |
| GET | `/settings/page` | Standalone settings page (`templates/settings.html`) |
| GET | `/favicon.ico` | Empty response (204) |
| GET | `/static/<path>` | Static files under `src/static/` |
---
## WebSocket: `/ws`
Connect to **`ws://<host>:<port>/ws`**.
- Send **JSON**: the object is forwarded through the **serial sender** (6-byte MAC prefix + payload to the ESP-NOW bridge). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used.
- Send **non-JSON text**: forwarded as raw bytes with the default address.
- On send failure, the server may reply with `{"error": "Send failed"}`.
Wi-Fi devices are not targeted by `/ws` directly; use **`POST /presets/send`**, device routes, or **`POST /patterns/<name>/send`** as appropriate.
---
## HTTP API by resource
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
### Settings — `/settings`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
| GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html`. |
### Devices — `/devices`
Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps to an object that always includes:
| Field | Description |
|-------|-------------|
| **`id`** | Same as the storage key (stable handle for URLs). |
| **`name`** | Shown in the UI and used in `select` keys. |
| **`type`** | `led` (only value today; extensible). |
| **`transport`** | `espnow` or `wifi`. |
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/devices` | Map of device id → device object. |
| GET | `/devices/<id>` | One device, 404 if missing. |
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
| DELETE | `/devices/<id>` | Remove device. |
### Profiles — `/profiles`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "<id>": { ... } }` with status 201. |
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
| POST | `/profiles/<id>/clone` | Clone profile (zones, palettes, presets). Body may include `name`. |
| PUT | `/profiles/current` | Update the current profile (from session). |
| PUT | `/profiles/<id>` | Update profile by id. |
| DELETE | `/profiles/<id>` | Delete profile. |
### Presets — `/presets`
Scoped to **current profile** in session (see above).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
| DELETE | `/presets/<id>` | Delete preset. |
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
**`POST /presets/send` body:**
```json
{
"preset_ids": ["1", "2"],
"save": true,
"default": "1",
"destination_mac": "aabbccddeeff"
}
```
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
Stored preset records can include:
- `colors`: resolved hex colours for editor/display.
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
### Zones — `/zones`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/zones` | `zones` (map of zone id → zone object), `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
| GET | `/zones/current` | Current zone from cookie/session. |
| POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profiles zone list. |
| GET | `/zones/<id>` | Zone JSON. |
| PUT | `/zones/<id>` | Update zone. |
| DELETE | `/zones/<id>` | Delete zone; can delete `current` to remove the active zone; updates profile zone list. |
| POST | `/zones/<id>/set-current` | Sets `current_zone` cookie. |
| POST | `/zones/<id>/clone` | Clone zone into current profile. |
### Palettes — `/palettes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/palettes` | Map of id → colour list. |
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
| PUT | `/palettes/<id>` | Update colours (`name` ignored). |
| DELETE | `/palettes/<id>` | Delete palette. |
### Groups — `/groups`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/groups` | All groups. |
| GET | `/groups/<id>` | One group. |
| POST | `/groups` | Create; optional `name` and fields. |
| PUT | `/groups/<id>` | Update. |
| DELETE | `/groups/<id>` | Delete. |
### Scenes — `/scenes`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scenes` | All scenes. |
| GET | `/scenes/<id>` | One scene. |
| POST | `/scenes` | Create (body JSON stored on scene). |
| PUT | `/scenes/<id>` | Update. |
| DELETE | `/scenes/<id>` | Delete. |
### Sequences — `/sequences`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/sequences` | All sequences. |
| GET | `/sequences/<id>` | One sequence. |
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
| PUT | `/sequences/<id>` | Update. |
| DELETE | `/sequences/<id>` | Delete. |
### Patterns — `/patterns`
Pattern metadata lives in **`db/pattern.json`**; driver source files live under **`led-driver/src/patterns/`**. Several routes expose a **runtime map** (metadata merged with on-disk `.py` names so new files appear in menus).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/patterns` | Runtime pattern map (object keyed by pattern id). |
| GET | `/patterns/definitions` | Same runtime map (intended for UI “definitions” clients). |
| GET | `/patterns/ota/manifest` | JSON `{"files":[{"name":"blink.py","url":"http://<Host>/patterns/ota/file/blink.py"},...]}` for OTA pulls. Requires **`Host`** header. |
| GET | `/patterns/ota/file/<name>` | Raw **`.py`** source for one driver pattern (`name` must be a safe filename, e.g. `rainbow.py`). |
| POST | `/patterns/<name>/send` | Push a **manifest** JSON line to **Wi-Fi** devices so they pull one pattern file over HTTP. Body may include **`device_id`** to target one device; otherwise all Wi-Fi devices with an **`address`** are tried. **`<name>`** may be with or without `.py`. |
| POST | `/patterns/upload` | Body JSON: **`name`**, **`code`**, optional **`overwrite`** (default true). Writes **`led-driver/src/patterns/<name>.py`**. |
| POST | `/patterns/driver` | Body JSON: **`name`** (identifier), **`code`**, optional metadata (`min_delay`, `max_delay`, `max_colors`, `n1``n8`, **`overwrite`**). Creates/updates both the **`.py`** file and **`db/pattern.json`** via the Pattern model. |
| GET | `/patterns/<id>` | One pattern record from the Pattern model (metadata only). |
| POST | `/patterns` | Create (`name`, optional `data`). |
| PUT | `/patterns/<id>` | Update. |
| DELETE | `/patterns/<id>` | Delete. |
**Devices — pattern OTA push**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/devices/<id>/patterns/push` | Wi-Fi only. Asks the driver at **`address`** to pull pattern files from this server. Optional body **`manifest`**: either a **URL string** pointing at a manifest JSON document, or a **manifest object** (same shape as in driver messages). If omitted, a default manifest is built from the request **`Host`** header. |
---
## LED driver message format (transport / ESP-NOW / Wi-Fi)
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge, and the same logical object can be sent as a **single JSON text message** to a Wi-Fi driver over the **WebSocket**.
### Top-level fields
```json
{
"v": "1",
"presets": { },
"select": { },
"save": true,
"default": "preset_id",
"b": 255
}
```
- **`v`** (required): Must be `"1"` or the driver ignores the message.
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
- **`default`**: Preset id string to use as startup default on the device.
- **`b`**: Optional **global** brightness 0255 (driver applies this in addition to per-preset brightness).
### Preset object (wire / driver keys)
On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
| Key | Meaning | Notes |
|-----|---------|--------|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
| `c` | Colours | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
| `d` | Delay ms | Default 100 |
| `b` | Preset brightness | 0255; combined with global `b` on the device |
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
| `n1``n6` | Pattern parameters | See below |
The HTTP apps **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
### Pattern-specific parameters (`n1``n6`)
#### Rainbow
- **`n1`**: Step increment on the colour wheel per update (default 1).
#### Pulse
- **`n1`**: Attack (fade in) ms
- **`n2`**: Hold ms
- **`n3`**: Decay (fade out) ms
- **`d`**: Off time between pulses ms
#### Transition
- **`d`**: Transition duration ms
#### Chase
- **`n1`**: LEDs with first colour
- **`n2`**: LEDs with second colour
- **`n3`**: Movement on even steps (may be negative)
- **`n4`**: Movement on odd steps (may be negative)
#### Circle
- **`n1`**: Head speed (LEDs/s)
- **`n2`**: Max length
- **`n3`**: Tail speed (LEDs/s)
- **`n4`**: Min length
### Select messages
```json
{
"select": {
"device_name": ["preset_id"],
"other_device": ["preset_id", 10]
}
}
```
- One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
- Two elements: explicit **step** for sync.
### Beat and sync behavior
- Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
### Example (compact preset map)
```json
{
"v": "1",
"save": true,
"presets": {
"1": {
"name": "Red blink",
"p": "blink",
"c": ["#FF0000"],
"d": 200,
"b": 255,
"a": true,
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
}
},
"select": {
"living-room": ["1"]
}
}
```
---
## Processing summary (driver)
1. Reject if `v != "1"`.
2. Apply optional top-level **`b`** (global brightness).
3. For each entry in **`presets`**, normalize colours and upsert preset by id.
4. If this devices **`name`** appears in **`select`**, run selection (optional step).
5. If **`default`** is set, store startup preset id.
6. If **`save`** is set, persist presets.
---
## Error handling (HTTP)
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
---
## Notes
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).

1846
docs/SPECIFICATION.md Normal file

File diff suppressed because it is too large Load Diff

114
docs/help.md Normal file
View File

@@ -0,0 +1,114 @@
# LED controller — user guide
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **serial → ESP-NOW bridge** or **Wi-Fi** (TCP to drivers on the LAN), depending on each devices transport.
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
Figures below are **schematic** (layout and ideas), not pixel-perfect screenshots.
---
## Run mode and Edit mode
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
![Schematic: zone buttons on the left; Profiles, Zones, Presets, Patterns, and the mode toggle on the right (example shows Edit mode with “Run mode” on the button).](images/help/header-toolbar.svg)
*The active zone is highlighted. Extra management buttons appear only in Edit mode.*
| Mode | Purpose |
|------|--------|
| **Run mode** | Day-to-day control: choose a zone, tap presets, apply profiles. Management buttons are hidden. |
| **Edit mode** | Full setup: zones, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles.
---
## Zones
- **Select a zone**: click its button in the top bar. The main area shows that zones preset strip and controls.
- **Edit mode — open zone settings**: **right-click** a zone button to change its name, **device IDs** (comma-separated), and which presets appear on the zone. Device identifiers are matched to each devices **name** when the app builds `select` messages for the driver.
- **Zones modal** (Edit mode): create new zones from the header **Zones** button. New zones need a name and device ID list (defaults to `1` if you leave a simple placeholder).
- **Brightness slider** (per zone): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link.
---
## Presets on the zone strip
- **Run and Edit mode**: click the **main part** of a preset tile to **select** that preset on all devices assigned to the current zone (same logical action as a `select` in the driver API).
- **Edit mode only**:
- **Edit** beside a tile opens the preset editor for that preset, scoped to the current zone (so you can **Remove from zone** without deleting the preset from the profile).
- **Drag and drop** tiles to reorder them; order is saved for that zone.
![Schematic: zone title, brightness slider, and a row of preset tiles; Edit mode adds an Edit control and drag handles for reordering.](images/help/zone-preset-strip.svg)
*The slider controls global brightness for the zones devices. Click the coloured area of a tile to select that preset.*
The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add** new presets, **Edit**, **Send** (push definition over the transport), and **Delete** (removes the preset from the profile entirely).
---
## Preset editor
- **Pattern**: chosen from the dropdown; optional **n1n8** fields depend on the pattern (see **Pattern-specific parameters** in [API.md](API.md)).
- **Colours**: choosing a value in the colour picker **adds** a swatch when the picker closes. Swatches can be **reordered** by dragging. Changing a swatch with the picker **clears** palette linkage for that slot.
- **From Palette**: inserts a colour **linked** to the current profiles palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
- **Brightness (0255)** and **Delay (ms)**: stored on the preset and sent with the compact preset payload.
- **Try**: sends the current form values to devices on the **current zone**, then selects that preset — **without** `save` on the device (good for auditioning).
- **Default**: updates the zones **default preset** and sends a **default** hint for those devices; it does not force the same live selection behaviour as clicking a tile.
- **Save & Send**: writes the preset to the server, then pushes definitions with **save** so devices may persist them. It does **not** auto-select the preset on devices (use the strip or **Try** if you want that).
- **Remove from zone** (when you opened the editor from a zone): removes the preset from **this zones list only**; the preset remains in the profile for other zones.
![Schematic: preset editor with name, pattern, colour swatches (one with a P badge for palette-linked), and action buttons.](images/help/preset-editor.svg)
*Try previews without persisting on the device; **Save & Send** stores the preset and pushes definitions with save.*
---
## Profiles
- **Apply**: sets the **current profile** in your session. Zones and presets you see are scoped to that profile.
- **Edit mode — Create**: new profiles always get a populated **default** zone. Optionally tick **DJ zone** to also create a `dj` zone (device name `dj`) with starter DJ-oriented presets.
- **Clone** / **Delete**: available in Edit mode from the profile list.
---
## Send Presets (Edit mode)
**Send Presets** walks **every zone** in the **current profile**, collects each zones preset IDs, and calls **`POST /presets/send`** per zone (including each zones **default** preset when set). Use this to bulk-push definitions to hardware after editing, without clicking **Send** on every preset individually.
---
## Patterns
The **Patterns** dialog (Edit mode) lists pattern names and typical **delay** ranges from the pattern definitions. Choosing a pattern still happens inside the preset editor.
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge/serial path you configure for preset traffic.
---
## Colour palette
**Colour Palette** (Edit mode) edits the **current profiles** palette swatches. Those colours are reused by **From Palette** in the preset editor and stay in sync while the **P** link remains.
![Schematic: palette modal with a row of swatches for the current profile.](images/help/colour-palette.svg)
*Add or change swatches here; linked preset colours update automatically.*
---
## Mobile layout
On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Zones, Presets, Help, mode toggle, etc.).
![Schematic: narrow layout with Menu and the same header actions in a dropdown.](images/help/mobile-menu.svg)
*Preset tiles behave the same once a zone is selected.*
---
## Further reading
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys, pattern **manifest**).
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour.

BIN
docs/help.pdf Normal file

Binary file not shown.

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 140" width="480" height="140">
<title>Colour Palette modal (concept)</title>
<rect width="480" height="140" fill="#2a2a2a" stroke="#555" stroke-width="1" rx="6"/>
<text x="20" y="28" fill="#fff" font-family="sans-serif" font-size="15" font-weight="600">Colour Palette</text>
<text x="20" y="48" fill="#888" font-family="sans-serif" font-size="10">Profile: current profile name</text>
<rect x="20" y="58" width="44" height="44" rx="4" fill="#e53935" stroke="#333"/>
<rect x="72" y="58" width="44" height="44" rx="4" fill="#fdd835" stroke="#333"/>
<rect x="124" y="58" width="44" height="44" rx="4" fill="#43a047" stroke="#333"/>
<rect x="176" y="58" width="44" height="44" rx="4" fill="#1e88e5" stroke="#333"/>
<rect x="228" y="58" width="44" height="44" rx="4" fill="#8e24aa" stroke="#333"/>
<rect x="280" y="70" width="36" height="28" rx="3" fill="#1a1a1a" stroke="#666"/>
<text x="288" y="88" fill="#ccc" font-family="sans-serif" font-size="10">+</text>
<text x="20" y="122" fill="#aaa" font-family="sans-serif" font-size="10">Swatches belong to the profile; preset editor uses them via From Palette.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 108" width="820" height="108">
<title>Header: tab buttons and action bar</title>
<rect width="820" height="108" fill="#1a1a1a"/>
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Tabs</text>
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
<text x="108" y="63" fill="#fff" font-family="sans-serif" font-size="13">lounge</text>
<rect x="192" y="40" width="56" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="204" y="63" fill="#ccc" font-family="sans-serif" font-size="13">dj</text>
<text x="380" y="28" fill="#888" font-family="sans-serif" font-size="11">Actions (Edit mode)</text>
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Tabs</text>
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="598" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Patterns</text>
<rect x="670" y="40" width="134" height="30" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
<text x="688" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Run mode</text>
<text x="16" y="98" fill="#aaa" font-family="sans-serif" font-size="10">Active tab highlighted. Mode button shows the mode you switch to next.</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="t">
<title id="t">Narrow screen: Menu aggregates header actions</title>
<rect width="300" height="340" fill="#2e2e2e"/>
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="22" y="30" fill="#eee" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12">Menu <20></text>
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
<text x="86" y="30" fill="#ccc" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
<text x="142" y="30" fill="#fff" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
<text x="24" y="84" fill="#888" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
<g font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12" fill="#e8e8e8">
<text x="24" y="108">Run mode</text>
<text x="24" y="132">Profiles</text>
<text x="24" y="156">Tabs</text>
<text x="24" y="180">Presets</text>
<text x="24" y="204">Help</text>
</g>
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
<text x="24" y="268" fill="#aaa" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Content area  presets as on desktop</text>
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="36" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
<text x="124" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 400" width="520" height="400">
<title>Preset editor modal (simplified)</title>
<rect width="520" height="400" fill="#1e1e1e"/>
<rect x="40" y="28" width="440" height="344" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
<text x="60" y="58" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="17" font-weight="600">Preset</text>
<text x="60" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Name</text>
<rect x="60" y="92" width="200" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="72" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">evening glow</text>
<text x="280" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Pattern</text>
<rect x="280" y="92" width="160" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="292" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">pulse</text>
<text x="60" y="148" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Colours</text>
<rect x="60" y="156" width="48" height="48" rx="4" fill="#7e57c2" stroke="#333" stroke-width="1"/>
<circle cx="66" cy="162" r="8" fill="#3f51b5" stroke="#fff" stroke-width="1"/>
<text x="63" y="166" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="7" font-weight="700">P</text>
<rect x="116" y="156" width="48" height="48" rx="4" fill="#26a69a" stroke="#333" stroke-width="1"/>
<text x="176" y="184" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">P = palette-linked</text>
<text x="60" y="232" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Brightness, delay, n1-n8</text>
<rect x="60" y="238" width="120" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
<text x="68" y="254" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">0-255</text>
<text x="60" y="290" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">Actions</text>
<rect x="60" y="298" width="44" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="72" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Try</text>
<rect x="112" y="298" width="56" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="120" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Default</text>
<rect x="176" y="298" width="88" height="26" rx="3" fill="#3d5a80" stroke="#5a7ab8" stroke-width="1"/>
<text x="188" y="315" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Save+Send</text>
<rect x="272" y="298" width="48" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
<text x="284" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Close</text>
<text x="60" y="352" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="9">Try: preview without device save. Save+Send: store and push with save.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 220" width="800" height="220">
<title>Main area: brightness and preset tiles</title>
<defs>
<linearGradient id="rg1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#ffd54f"/><stop offset="100%" style="stop-color:#fff8e1"/>
</linearGradient>
<linearGradient id="rg2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#e53935"/><stop offset="33%" style="stop-color:#fdd835"/>
<stop offset="66%" style="stop-color:#43a047"/><stop offset="100%" style="stop-color:#1e88e5"/>
</linearGradient>
<linearGradient id="rg3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00897b"/><stop offset="100%" style="stop-color:#4db6ac"/>
</linearGradient>
</defs>
<rect width="800" height="220" fill="#2e2e2e"/>
<text x="20" y="32" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">lounge</text>
<text x="20" y="56" fill="#aaa" font-family="sans-serif" font-size="11">Brightness (global)</text>
<rect x="20" y="64" width="320" height="8" rx="4" fill="#444"/>
<rect x="20" y="64" width="200" height="8" rx="4" fill="#6a9ee2"/>
<circle cx="220" cy="68" r="10" fill="#ccc" stroke="#333" stroke-width="1"/>
<text x="360" y="74" fill="#888" font-family="sans-serif" font-size="11">drag to adjust</text>
<text x="20" y="108" fill="#aaa" font-family="sans-serif" font-size="11">Click tile body to select on tab devices</text>
<rect x="20" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
<rect x="28" y="128" width="184" height="36" rx="4" fill="url(#rg1)"/>
<text x="32" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">warm white</text>
<rect x="232" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#6a9ee2" stroke-width="2"/>
<rect x="240" y="128" width="184" height="36" rx="4" fill="url(#rg2)"/>
<text x="244" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">rainbow</text>
<rect x="444" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
<rect x="452" y="128" width="184" height="36" rx="4" fill="url(#rg3)"/>
<text x="456" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">chase</text>
<rect x="656" y="130" width="56" height="48" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
<text x="670" y="158" fill="#ddd" font-family="sans-serif" font-size="11">Edit</text>
<text x="656" y="198" fill="#888" font-family="sans-serif" font-size="10">Edit mode: drag tiles to reorder</text>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,239 @@
# Custom Colour Picker Component
A cross-platform, cross-browser colour picker component that provides a consistent user experience across all operating systems and browsers.
## Features
**Consistent UI** - Same appearance and behavior on Windows, macOS, Linux, iOS, and Android
**Browser Support** - Works in Chrome, Firefox, Safari, Edge, Opera, and mobile browsers
**Touch Support** - Full touch/gesture support for mobile devices
**HSB Colour Model** - Uses Hue, Saturation, Brightness for intuitive colour selection
**Multiple Input Methods** - Hex input, RGB inputs, and visual picker
**Accessible** - Keyboard accessible and screen reader friendly
**Customizable** - Easy to style and integrate
## Files
- `color-picker.js` - Main JavaScript component (14KB)
- `color-picker.css` - Stylesheet (4KB)
- `color-picker-demo.html` - Demo page showing usage examples
## Quick Start
### 1. Include the files
```html
<link rel="stylesheet" href="color-picker.css">
<script src="color-picker.js"></script>
```
### 2. Create a container element
```html
<div id="my-color-picker"></div>
```
### 3. Initialize the colour picker
```javascript
const picker = new ColorPicker('#my-color-picker', {
initialColor: '#FF0000',
onColorChange: (color) => {
console.log('Color changed to:', color);
}
});
```
## API
### Constructor
```javascript
new ColorPicker(container, options)
```
**Parameters:**
- `container` (string|HTMLElement) - CSS selector or DOM element
- `options` (object) - Configuration options
**Options:**
- `initialColor` (string) - Initial colour in hex format (default: '#FF0000')
- `onColorChange` (function) - Callback when colour changes (receives hex colour string)
- `showHexInput` (boolean) - Show hex input field (default: true)
### Methods
```javascript
// Get current color
const color = picker.getColor(); // Returns hex string like '#FF0000'
// Set color programmatically
picker.setColor('#00FF00');
// Open the picker panel
picker.open();
// Close the picker panel
picker.close();
// Toggle the picker panel
picker.toggle();
```
## Usage Examples
### Basic Usage
```javascript
const picker = new ColorPicker('#picker1', {
initialColor: '#FF0000'
});
```
### With Callback
```javascript
const picker = new ColorPicker('#picker1', {
initialColor: '#FF0000',
onColorChange: (color) => {
document.body.style.backgroundColor = color;
}
});
```
### Multiple Colour Pickers
```javascript
const colors = ['#FF0000', '#00FF00', '#0000FF'];
const pickers = colors.map((color, index) => {
return new ColorPicker(`#picker-${index}`, {
initialColor: color,
onColorChange: (newColor) => {
colors[index] = newColor;
updateLEDColors(colors);
}
});
});
```
### Dynamic Colour Picker Creation
```javascript
function addColorPicker(containerId, initialColor = '#000000') {
const container = document.createElement('div');
container.id = containerId;
document.getElementById('color-list').appendChild(container);
return new ColorPicker(container, {
initialColor: initialColor,
onColorChange: (color) => {
console.log(`Color ${containerId} changed to ${color}`);
}
});
}
// Add multiple pickers
addColorPicker('color-1', '#FF0000');
addColorPicker('color-2', '#00FF00');
```
## Styling
The colour picker uses CSS classes that can be customized:
- `.color-picker-container` - Main container
- `.color-picker-preview` - Colour preview button
- `.color-picker-panel` - Dropdown panel
- `.color-picker-main` - Main colour area
- `.color-picker-hue` - Hue slider
- `.color-picker-controls` - Controls section
### Custom Styling Example
```css
.color-picker-preview {
width: 80px;
height: 80px;
border-radius: 12px;
}
.color-picker-panel {
background: #2d3748;
border-color: #4a5568;
}
```
## Browser Compatibility
| Browser | Version | Status |
|---------|---------|--------|
| Chrome | 60+ | ✅ Full support |
| Firefox | 55+ | ✅ Full support |
| Safari | 12+ | ✅ Full support |
| Edge | 79+ | ✅ Full support |
| Opera | 47+ | ✅ Full support |
| Mobile Safari | iOS 12+ | ✅ Full support |
| Chrome Mobile | Android 7+ | ✅ Full support |
## Operating System Compatibility
- ✅ Windows 10/11
- ✅ macOS 10.14+
- ✅ Linux (all major distributions)
- ✅ iOS 12+
- ✅ Android 7+
## Colour Format
The colour picker uses **hex colour format** (`#RRGGBB`):
- Always returns uppercase hex strings (e.g., `#FF0000`)
- Accepts both uppercase and lowercase input
- Automatically validates hex format
## Integration with LED Driver Mockups
The colour picker is integrated into:
- `dashboard.html` - Colour selection for patterns
- `presets.html` - Colour selection when creating/editing presets
### Example: Getting Colours from Multiple Pickers
```javascript
const colorPickers = [];
function getSelectedColors() {
return colorPickers.map(picker => picker.getColor());
}
function sendColorsToDevice() {
const colors = getSelectedColors();
// Send to LED device via API
fetch('/api/colors', {
method: 'POST',
body: JSON.stringify({ colors: colors })
});
}
```
## Performance
- Lightweight: ~14KB JavaScript, ~4KB CSS
- Fast rendering: Uses Canvas API for colour gradients
- Smooth interactions: Optimized event handling
- Memory efficient: No external dependencies
## Accessibility
- Keyboard navigation support
- ARIA labels on interactive elements
- High contrast cursor indicators
- Screen reader compatible
## License
Part of the LED Driver project. Use freely in your projects.
## Demo
See `color-picker-demo.html` for a live demonstration of the colour picker component.

56
docs/mockups/README.md Normal file
View File

@@ -0,0 +1,56 @@
# UI Mockups
This directory contains HTML mockups and generated images for the LED Driver user interface.
## Files
### HTML Mockups
- **index.html** - Navigation page linking to all mockups
- **dashboard.html** - Main control panel for managing LED patterns and devices
- **pattern-selector.html** - Visual pattern selection interface
- **device-management.html** - Device and group management interface
- **settings.html** - Comprehensive settings configuration panel
### Generated Images
Images are automatically generated in the `images/` directory:
- `dashboard.png`
- `pattern-selector.png`
- `device-management.png`
- `settings.png`
- `index.png`
## Generating Images
To generate images from the HTML files, use the provided script:
```bash
# Install dependencies (if not already installed)
pipenv install playwright
pipenv run playwright install chromium
# Generate images
pipenv run python generate_images.py
```
The script will:
1. Check for available screenshot libraries (Playwright, Selenium, or html2image)
2. Generate PNG images from all HTML files
3. Save images to the `images/` directory
### Requirements
The script supports multiple screenshot libraries (in order of preference):
1. **Playwright** (recommended) - `pip install playwright && playwright install chromium`
2. **Selenium** - `pip install selenium` (requires ChromeDriver)
3. **html2image** - `pip install html2image`
## Viewing Mockups
Simply open any HTML file in a web browser to view the mockup. Start with `index.html` for navigation to all mockups.
## Notes
- All mockups are responsive and work on desktop and mobile devices
- The mockups use modern CSS with gradients and smooth animations
- Interactive elements (buttons, sliders, etc.) are functional in the HTML but are mockups (no backend connection)

View File

@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chromium Color Picker Demo</title>
<link rel="stylesheet" href="color-picker-chromium.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
h1 {
color: #202124;
margin-bottom: 8px;
font-weight: 400;
font-size: 24px;
}
p {
color: #5f6368;
margin-bottom: 32px;
font-size: 14px;
}
.demo-section {
margin-bottom: 40px;
padding: 24px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e8eaed;
}
.demo-section h2 {
color: #202124;
margin-bottom: 16px;
font-size: 16px;
font-weight: 500;
}
.color-pickers {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.color-display {
margin-top: 16px;
padding: 12px;
background: white;
border-radius: 6px;
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
border: 1px solid #e8eaed;
}
.color-display strong {
color: #4285f4;
}
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 24px;
}
.comparison-item {
padding: 16px;
background: white;
border-radius: 6px;
border: 1px solid #e8eaed;
}
.comparison-item h3 {
font-size: 14px;
font-weight: 500;
color: #202124;
margin-bottom: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>Chromium-style Color Picker</h1>
<p>Color picker that matches the native Chromium browser color picker design</p>
<div class="demo-section">
<h2>Single Color Picker</h2>
<div class="color-pickers">
<div id="picker1"></div>
</div>
<div class="color-display">
Selected color: <strong id="color1-display">#FF0000</strong>
</div>
</div>
<div class="demo-section">
<h2>Multiple Color Pickers</h2>
<p style="margin-bottom: 16px; color: #5f6368; font-size: 14px;">Example: Multiple colors for LED patterns</p>
<div class="color-pickers">
<div id="picker2"></div>
<div id="picker3"></div>
<div id="picker4"></div>
</div>
<div class="color-display">
Colors: <strong id="colors-display">#FF0000, #00FF00, #0000FF</strong>
</div>
</div>
<div class="demo-section">
<h2>Features</h2>
<ul style="color: #5f6368; line-height: 1.8; font-size: 14px;">
<li>✅ Matches native Chromium browser color picker design</li>
<li>✅ Clean, minimal interface with native system fonts</li>
<li>✅ RGB number inputs (no sliders) - Chromium style</li>
<li>✅ Hex input with uppercase formatting</li>
<li>✅ HSB (Hue, Saturation, Brightness) color model</li>
<li>✅ Touch support for mobile devices</li>
<li>✅ Keyboard accessible</li>
<li>✅ Dark mode support</li>
</ul>
</div>
<div class="demo-section">
<h2>Design Notes</h2>
<div class="comparison">
<div class="comparison-item">
<h3>Chromium Style</h3>
<ul style="color: #5f6368; line-height: 1.8; font-size: 13px; list-style: none; padding-left: 0;">
<li>• RGB number inputs only</li>
<li>• Compact preview button</li>
<li>• Native system fonts</li>
<li>• Minimal borders and shadows</li>
<li>• Chromium color scheme</li>
</ul>
</div>
<div class="comparison-item">
<h3>Standard Style</h3>
<ul style="color: #5f6368; line-height: 1.8; font-size: 13px; list-style: none; padding-left: 0;">
<li>• RGB sliders + inputs</li>
<li>• Larger preview button</li>
<li>• Custom styling</li>
<li>• Enhanced shadows</li>
<li>• Custom color scheme</li>
</ul>
</div>
</div>
</div>
</div>
<script src="color-picker-chromium.js"></script>
<script>
// Initialize Chromium-style color pickers
const picker1 = new ColorPickerChromium('#picker1', {
initialColor: '#FF0000',
onColorChange: (color) => {
document.getElementById('color1-display').textContent = color;
}
});
const picker2 = new ColorPickerChromium('#picker2', {
initialColor: '#FF0000',
onColorChange: updateColors
});
const picker3 = new ColorPickerChromium('#picker3', {
initialColor: '#00FF00',
onColorChange: updateColors
});
const picker4 = new ColorPickerChromium('#picker4', {
initialColor: '#0000FF',
onColorChange: updateColors
});
function updateColors() {
const colors = [
picker2.getColor(),
picker3.getColor(),
picker4.getColor()
];
document.getElementById('colors-display').textContent = colors.join(', ');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,253 @@
/* Chromium-style Color Picker - Matches native browser color picker dialog */
.color-picker-container {
position: relative;
display: inline-block;
}
/* Preview button - opens the picker */
.color-picker-preview {
width: 40px;
height: 32px;
border: 1px solid #d0d0d0;
border-radius: 4px;
cursor: pointer;
padding: 0;
background: none;
transition: border-color 0.15s;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.color-picker-preview:hover {
border-color: #8ab4f8;
}
.color-picker-preview:active {
border-color: #4285f4;
}
/* Main picker panel - always visible when open, styled like Chromium dialog */
.color-picker-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 1000;
background: #ffffff;
border: 1px solid #dadce0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.1);
padding: 16px;
min-width: 260px;
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
}
/* Color area - main saturation/brightness square + hue slider */
.color-picker-area {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
/* Main color square - saturation (left-right) and brightness (top-bottom) */
.color-picker-main {
position: relative;
width: 200px;
height: 200px;
border: 1px solid #dadce0;
border-radius: 4px;
overflow: hidden;
cursor: crosshair;
touch-action: none;
background: #ffffff;
flex-shrink: 0;
}
.color-picker-canvas {
display: block;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Cursor for main color area */
.color-picker-cursor {
position: absolute;
width: 18px;
height: 18px;
border: 2px solid #ffffff;
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2), 0 2px 4px rgba(0, 0, 0, 0.1);
pointer-events: none;
transform: translate(-50%, -50%);
top: 0;
left: 0;
}
/* Hue slider - vertical strip on the right */
.color-picker-hue {
position: relative;
width: 24px;
height: 200px;
border: 1px solid #dadce0;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
touch-action: none;
background: #ffffff;
flex-shrink: 0;
}
/* Hue slider cursor/indicator */
.color-picker-hue-cursor {
position: absolute;
left: 0;
right: 0;
width: 100%;
height: 6px;
border: 2px solid #ffffff;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.1);
pointer-events: none;
transform: translateY(-50%);
top: 0;
}
/* Controls section - hex and RGB inputs */
.color-picker-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Hex input field */
.color-picker-hex {
width: 100%;
padding: 7px 10px;
border: 1px solid #dadce0;
border-radius: 4px;
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
text-transform: uppercase;
transition: border-color 0.15s, box-shadow 0.15s;
background: #ffffff;
}
.color-picker-hex:focus {
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}
/* RGB inputs container */
.color-picker-rgb {
display: flex;
gap: 12px;
align-items: flex-end;
}
.color-picker-rgb-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.color-picker-rgb-item label {
font-size: 11px;
font-weight: 500;
color: #5f6368;
text-align: left;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* RGB number input fields */
.color-picker-rgb-input {
width: 100%;
padding: 7px 10px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 13px;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
background: #ffffff;
font-family: 'Roboto', sans-serif;
-moz-appearance: textfield;
}
.color-picker-rgb-input::-webkit-outer-spin-button,
.color-picker-rgb-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.color-picker-rgb-input:focus {
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}
/* Hide RGB sliders - Chromium uses only number inputs */
.color-picker-rgb-slider {
display: none;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.color-picker-panel {
left: auto;
right: 0;
}
.color-picker-main {
width: 180px;
height: 180px;
}
.color-picker-hue {
height: 180px;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.color-picker-panel {
background: #202124;
border-color: #5f6368;
}
.color-picker-preview {
border-color: #5f6368;
}
.color-picker-main,
.color-picker-hue {
border-color: #5f6368;
background: #202124;
}
.color-picker-hex,
.color-picker-rgb-input {
background: #303134;
border-color: #5f6368;
color: #e8eaed;
}
.color-picker-rgb-item label {
color: #9aa0a6;
}
.color-picker-hex:focus,
.color-picker-rgb-input:focus {
border-color: #8ab4f8;
box-shadow: 0 0 0 2px rgba(138, 180, 248, 0.2);
}
.color-picker-preview:hover {
border-color: #8ab4f8;
}
}

View File

@@ -0,0 +1,452 @@
/**
* Chromium-style Color Picker Component
* Matches native Chromium browser color picker design
*/
class ColorPickerChromium {
constructor(container, options = {}) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
this.options = {
initialColor: options.initialColor || '#FF0000',
onColorChange: options.onColorChange || null,
showHexInput: options.showHexInput !== false,
...options
};
this.currentColor = this.options.initialColor;
this.isOpen = false;
this.init();
}
init() {
this.createPicker();
this.setupEventListeners();
this.updateColor(this.options.initialColor);
}
createPicker() {
this.container.innerHTML = '';
this.container.className = 'color-picker-container';
// Color preview button
this.previewBtn = document.createElement('button');
this.previewBtn.className = 'color-picker-preview';
this.previewBtn.type = 'button';
this.previewBtn.style.backgroundColor = this.currentColor;
this.previewBtn.setAttribute('aria-label', 'Open color picker');
// Dropdown panel
this.panel = document.createElement('div');
this.panel.className = 'color-picker-panel';
this.panel.style.display = 'none';
// Main color area (hue/saturation)
this.mainArea = document.createElement('div');
this.mainArea.className = 'color-picker-main';
this.mainCanvas = document.createElement('canvas');
this.mainCanvas.width = 200;
this.mainCanvas.height = 200;
this.mainCanvas.className = 'color-picker-canvas';
this.mainArea.appendChild(this.mainCanvas);
// Main area cursor
this.mainCursor = document.createElement('div');
this.mainCursor.className = 'color-picker-cursor';
this.mainArea.appendChild(this.mainCursor);
// Hue slider
this.hueArea = document.createElement('div');
this.hueArea.className = 'color-picker-hue';
this.hueCanvas = document.createElement('canvas');
this.hueCanvas.width = 24;
this.hueCanvas.height = 200;
this.hueCanvas.className = 'color-picker-canvas';
this.hueArea.appendChild(this.hueCanvas);
// Hue slider cursor
this.hueCursor = document.createElement('div');
this.hueCursor.className = 'color-picker-hue-cursor';
this.hueArea.appendChild(this.hueCursor);
// Controls section
this.controls = document.createElement('div');
this.controls.className = 'color-picker-controls';
// Hex input
if (this.options.showHexInput) {
this.hexInput = document.createElement('input');
this.hexInput.type = 'text';
this.hexInput.className = 'color-picker-hex';
this.hexInput.placeholder = '#000000';
this.hexInput.maxLength = 7;
this.controls.appendChild(this.hexInput);
}
// RGB inputs (Chromium style - no sliders, just number inputs)
this.rgbContainer = document.createElement('div');
this.rgbContainer.className = 'color-picker-rgb';
['R', 'G', 'B'].forEach((label) => {
const wrapper = document.createElement('div');
wrapper.className = 'color-picker-rgb-item';
wrapper.dataset.channel = label.toLowerCase();
const labelEl = document.createElement('label');
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'number';
input.className = 'color-picker-rgb-input';
input.min = 0;
input.max = 255;
input.value = 0;
input.dataset.channel = label.toLowerCase();
wrapper.appendChild(labelEl);
wrapper.appendChild(input);
this.rgbContainer.appendChild(wrapper);
this[`rgb${label}`] = input;
});
this.controls.appendChild(this.rgbContainer);
// Assemble panel
const pickerArea = document.createElement('div');
pickerArea.className = 'color-picker-area';
pickerArea.appendChild(this.mainArea);
pickerArea.appendChild(this.hueArea);
this.panel.appendChild(pickerArea);
this.panel.appendChild(this.controls);
// Assemble container
this.container.appendChild(this.previewBtn);
this.container.appendChild(this.panel);
// Draw canvases
this.drawHueCanvas();
this.drawMainCanvas(1.0); // Start with full saturation
}
setupEventListeners() {
// Toggle panel
this.previewBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!this.container.contains(e.target) && this.isOpen) {
this.close();
}
});
// Main area interaction
let isMainDragging = false;
this.mainCanvas.addEventListener('mousedown', (e) => {
isMainDragging = true;
this.handleMainAreaClick(e);
});
this.mainCanvas.addEventListener('mousemove', (e) => {
if (isMainDragging) {
this.handleMainAreaClick(e);
}
});
document.addEventListener('mouseup', () => {
isMainDragging = false;
});
// Touch support for main area
this.mainCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
isMainDragging = true;
this.handleMainAreaClick(e.touches[0]);
});
this.mainCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (isMainDragging) {
this.handleMainAreaClick(e.touches[0]);
}
});
this.mainCanvas.addEventListener('touchend', () => {
isMainDragging = false;
});
// Hue slider interaction
let isHueDragging = false;
this.hueCanvas.addEventListener('mousedown', (e) => {
isHueDragging = true;
this.handleHueClick(e);
});
this.hueCanvas.addEventListener('mousemove', (e) => {
if (isHueDragging) {
this.handleHueClick(e);
}
});
document.addEventListener('mouseup', () => {
isHueDragging = false;
});
// Touch support for hue slider
this.hueCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
isHueDragging = true;
this.handleHueClick(e.touches[0]);
});
this.hueCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (isHueDragging) {
this.handleHueClick(e.touches[0]);
}
});
this.hueCanvas.addEventListener('touchend', () => {
isHueDragging = false;
});
// Hex input
if (this.hexInput) {
this.hexInput.addEventListener('input', (e) => {
const value = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
this.updateColor(value);
}
});
this.hexInput.addEventListener('blur', (e) => {
const value = e.target.value;
if (!/^#[0-9A-Fa-f]{6}$/.test(value) && value.length > 0) {
e.target.value = this.currentColor;
}
});
}
// RGB inputs (Chromium style - only number inputs)
['R', 'G', 'B'].forEach(label => {
this[`rgb${label}`].addEventListener('input', (e) => {
let value = parseInt(e.target.value) || 0;
value = Math.max(0, Math.min(255, value)); // Clamp to 0-255
e.target.value = value;
const r = parseInt(this.rgbR.value) || 0;
const g = parseInt(this.rgbG.value) || 0;
const b = parseInt(this.rgbB.value) || 0;
const hex = this.rgbToHex(r, g, b);
this.updateColor(hex, false); // Don't update RGB inputs to avoid loop
});
this[`rgb${label}`].addEventListener('blur', (e) => {
let value = parseInt(e.target.value) || 0;
value = Math.max(0, Math.min(255, value));
e.target.value = value;
});
});
}
drawHueCanvas() {
const ctx = this.hueCanvas.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
for (let i = 0; i <= 6; i++) {
const hue = i * 60;
gradient.addColorStop(i / 6, `hsl(${hue}, 100%, 50%)`);
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 24, 200);
}
drawMainCanvas(hue) {
const ctx = this.mainCanvas.getContext('2d');
// Saturation gradient (left to right)
const satGradient = ctx.createLinearGradient(0, 0, 200, 0);
satGradient.addColorStop(0, `hsl(${hue}, 0%, 50%)`);
satGradient.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
ctx.fillStyle = satGradient;
ctx.fillRect(0, 0, 200, 200);
// Brightness gradient (top to bottom)
const brightGradient = ctx.createLinearGradient(0, 0, 0, 200);
brightGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
brightGradient.addColorStop(1, 'rgba(0, 0, 0, 1)');
ctx.fillStyle = brightGradient;
ctx.fillRect(0, 0, 200, 200);
}
handleMainAreaClick(e) {
const rect = this.mainCanvas.getBoundingClientRect();
const x = Math.max(0, Math.min(200, e.clientX - rect.left));
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
const saturation = x / 200;
const brightness = 1 - (y / 200);
this.updateColorFromHSB(this.hue, saturation, brightness);
this.updateCursor(x, y);
}
handleHueClick(e) {
const rect = this.hueCanvas.getBoundingClientRect();
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
const hue = (y / 200) * 360;
this.hue = hue;
this.drawMainCanvas(hue);
this.updateHueCursor(y);
// Recalculate color with new hue
const rect2 = this.mainCanvas.getBoundingClientRect();
const x = parseFloat(this.mainCursor.style.left) || 0;
const y2 = parseFloat(this.mainCursor.style.top) || 0;
const saturation = x / 200;
const brightness = 1 - (y2 / 200);
this.updateColorFromHSB(hue, saturation, brightness);
}
updateColorFromHSB(h, s, v) {
const rgb = this.hsbToRgb(h, s, v);
const hex = this.rgbToHex(rgb.r, rgb.g, rgb.b);
this.updateColor(hex);
}
hsbToRgb(h, s, v) {
h = h / 360;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
rgbToHex(r, g, b) {
return '#' + [r, g, b].map(x => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
}).join('').toUpperCase();
}
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
rgbToHsb(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const diff = max - min;
let h = 0;
if (diff !== 0) {
if (max === r) {
h = ((g - b) / diff) % 6) * 60;
} else if (max === g) {
h = ((b - r) / diff + 2) * 60;
} else {
h = ((r - g) / diff + 4) * 60;
}
}
if (h < 0) h += 360;
const s = max === 0 ? 0 : diff / max;
const v = max;
return { h, s, v };
}
updateColor(hex, updateInputs = true) {
this.currentColor = hex.toUpperCase();
this.previewBtn.style.backgroundColor = this.currentColor;
const rgb = this.hexToRgb(this.currentColor);
if (!rgb) return;
const hsb = this.rgbToHsb(rgb.r, rgb.g, rgb.b);
this.hue = hsb.h;
// Update main canvas
this.drawMainCanvas(this.hue);
// Update cursors
const x = hsb.s * 200;
const y = (1 - hsb.v) * 200;
this.updateCursor(x, y);
this.updateHueCursor((this.hue / 360) * 200);
// Update inputs
if (updateInputs) {
if (this.hexInput) {
this.hexInput.value = this.currentColor;
}
if (this.rgbR) {
this.rgbR.value = rgb.r;
this.rgbG.value = rgb.g;
this.rgbB.value = rgb.b;
}
}
// Callback
if (this.options.onColorChange) {
this.options.onColorChange(this.currentColor);
}
}
updateCursor(x, y) {
this.mainCursor.style.left = `${x}px`;
this.mainCursor.style.top = `${y}px`;
}
updateHueCursor(y) {
this.hueCursor.style.top = `${y}px`;
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.panel.style.display = 'block';
this.isOpen = true;
}
close() {
this.panel.style.display = 'none';
this.isOpen = false;
}
getColor() {
return this.currentColor;
}
setColor(color) {
this.updateColor(color);
}
}
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = ColorPickerChromium;
}

View File

@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Color Picker Demo - Cross-Platform</title>
<link rel="stylesheet" href="color-picker.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
h1 {
color: #667eea;
margin-bottom: 8px;
}
p {
color: #666;
margin-bottom: 32px;
}
.demo-section {
margin-bottom: 40px;
padding: 24px;
background: #f7fafc;
border-radius: 8px;
}
.demo-section h2 {
color: #333;
margin-bottom: 16px;
font-size: 1.25rem;
}
.color-pickers {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.color-display {
margin-top: 16px;
padding: 12px;
background: white;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
}
.color-display strong {
color: #667eea;
}
</style>
</head>
<body>
<div class="container">
<h1>Custom Color Picker</h1>
<p>Consistent color picker that works the same across all operating systems and browsers</p>
<div class="demo-section">
<h2>Single Color Picker</h2>
<div class="color-pickers">
<div id="picker1"></div>
</div>
<div class="color-display">
Selected color: <strong id="color1-display">#FF0000</strong>
</div>
</div>
<div class="demo-section">
<h2>Multiple Color Pickers</h2>
<p style="margin-bottom: 16px;">Example: Multiple colors for LED patterns</p>
<div class="color-pickers">
<div id="picker2"></div>
<div id="picker3"></div>
<div id="picker4"></div>
</div>
<div class="color-display">
Colors: <strong id="colors-display">#FF0000, #00FF00, #0000FF</strong>
</div>
</div>
<div class="demo-section">
<h2>Features</h2>
<ul style="color: #666; line-height: 1.8;">
<li>✅ Consistent UI across Windows, macOS, Linux, iOS, Android</li>
<li>✅ Works in Chrome, Firefox, Safari, Edge, Opera</li>
<li>✅ Touch support for mobile devices</li>
<li>✅ HSB (Hue, Saturation, Brightness) color model</li>
<li>✅ Hex and RGB input support</li>
<li>✅ Keyboard accessible</li>
<li>✅ Customizable styling</li>
</ul>
</div>
</div>
<script src="color-picker.js"></script>
<script>
// Initialize color pickers
const picker1 = new ColorPicker('#picker1', {
initialColor: '#FF0000',
onColorChange: (color) => {
document.getElementById('color1-display').textContent = color;
}
});
const picker2 = new ColorPicker('#picker2', {
initialColor: '#FF0000',
onColorChange: updateColors
});
const picker3 = new ColorPicker('#picker3', {
initialColor: '#00FF00',
onColorChange: updateColors
});
const picker4 = new ColorPicker('#picker4', {
initialColor: '#0000FF',
onColorChange: updateColors
});
function updateColors() {
const colors = [
picker2.getColor(),
picker3.getColor(),
picker4.getColor()
];
document.getElementById('colors-display').textContent = colors.join(', ');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,282 @@
/* Color Picker Styles - Consistent across all browsers and OS */
.color-picker-container {
position: relative;
display: inline-block;
}
.color-picker-preview {
width: 60px;
height: 60px;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
padding: 0;
background: none;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.color-picker-preview:hover {
border-color: #667eea;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.color-picker-preview:active {
transform: translateY(0);
}
.color-picker-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 1000;
background: white;
border: 1px solid #e0e0e0;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 16px;
min-width: 280px;
}
.color-picker-area {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.color-picker-main {
position: relative;
width: 200px;
height: 200px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
cursor: crosshair;
touch-action: none;
}
.color-picker-canvas {
display: block;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.color-picker-cursor {
position: absolute;
width: 16px;
height: 16px;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
pointer-events: none;
transform: translate(-50%, -50%);
top: 0;
left: 0;
}
.color-picker-hue {
position: relative;
width: 20px;
height: 200px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
touch-action: none;
}
.color-picker-hue-cursor {
position: absolute;
left: 0;
right: 0;
width: 100%;
height: 4px;
border: 2px solid white;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
pointer-events: none;
transform: translateY(-50%);
top: 0;
}
.color-picker-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.color-picker-hex {
width: 100%;
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
text-transform: uppercase;
transition: border-color 0.2s;
}
.color-picker-hex:focus {
outline: none;
border-color: #667eea;
}
.color-picker-rgb {
display: flex;
gap: 8px;
}
.color-picker-rgb-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.color-picker-rgb-item label {
font-size: 0.75rem;
font-weight: 600;
color: #666;
text-align: center;
}
.color-picker-rgb-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
cursor: pointer;
}
.color-picker-rgb-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
.color-picker-rgb-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
}
.color-picker-rgb-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
.color-picker-rgb-slider::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.3);
}
/* Color-specific slider backgrounds */
.color-picker-rgb-item[data-channel="r"] .color-picker-rgb-slider {
background: linear-gradient(to right, #000000, #ff0000);
}
.color-picker-rgb-item[data-channel="g"] .color-picker-rgb-slider {
background: linear-gradient(to right, #000000, #00ff00);
}
.color-picker-rgb-item[data-channel="b"] .color-picker-rgb-slider {
background: linear-gradient(to right, #000000, #0000ff);
}
.color-picker-rgb-input {
width: 100%;
padding: 6px 8px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 0.875rem;
text-align: center;
transition: border-color 0.2s;
-moz-appearance: textfield;
}
.color-picker-rgb-input::-webkit-outer-spin-button,
.color-picker-rgb-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.color-picker-rgb-input:focus {
outline: none;
border-color: #667eea;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.color-picker-panel {
left: auto;
right: 0;
}
.color-picker-main {
width: 180px;
height: 180px;
}
.color-picker-hue {
height: 180px;
}
}
/* Dark mode support (optional) */
@media (prefers-color-scheme: dark) {
.color-picker-panel {
background: #2d3748;
border-color: #4a5568;
}
.color-picker-preview {
border-color: #4a5568;
}
.color-picker-main,
.color-picker-hue {
border-color: #4a5568;
}
.color-picker-hex,
.color-picker-rgb-input {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
.color-picker-rgb-item label {
color: #a0aec0;
}
.color-picker-rgb-slider {
background: #4a5568 !important;
}
.color-picker-rgb-slider::-webkit-slider-thumb,
.color-picker-rgb-slider::-moz-range-thumb {
background: #667eea;
border-color: #2d3748;
}
}

View File

@@ -0,0 +1,474 @@
/**
* Custom Color Picker Component
* Consistent across all operating systems and browsers
*/
class ColorPicker {
constructor(container, options = {}) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
this.options = {
initialColor: options.initialColor || '#FF0000',
onColorChange: options.onColorChange || null,
showHexInput: options.showHexInput !== false,
...options
};
this.currentColor = this.options.initialColor;
this.isOpen = false;
this.init();
}
init() {
this.createPicker();
this.setupEventListeners();
this.updateColor(this.options.initialColor);
}
createPicker() {
this.container.innerHTML = '';
this.container.className = 'color-picker-container';
// Color preview button
this.previewBtn = document.createElement('button');
this.previewBtn.className = 'color-picker-preview';
this.previewBtn.type = 'button';
this.previewBtn.style.backgroundColor = this.currentColor;
this.previewBtn.setAttribute('aria-label', 'Open color picker');
// Dropdown panel
this.panel = document.createElement('div');
this.panel.className = 'color-picker-panel';
this.panel.style.display = 'none';
// Main color area (hue/saturation)
this.mainArea = document.createElement('div');
this.mainArea.className = 'color-picker-main';
this.mainCanvas = document.createElement('canvas');
this.mainCanvas.width = 200;
this.mainCanvas.height = 200;
this.mainCanvas.className = 'color-picker-canvas';
this.mainArea.appendChild(this.mainCanvas);
// Main area cursor
this.mainCursor = document.createElement('div');
this.mainCursor.className = 'color-picker-cursor';
this.mainArea.appendChild(this.mainCursor);
// Hue slider
this.hueArea = document.createElement('div');
this.hueArea.className = 'color-picker-hue';
this.hueCanvas = document.createElement('canvas');
this.hueCanvas.width = 20;
this.hueCanvas.height = 200;
this.hueCanvas.className = 'color-picker-canvas';
this.hueArea.appendChild(this.hueCanvas);
// Hue slider cursor
this.hueCursor = document.createElement('div');
this.hueCursor.className = 'color-picker-hue-cursor';
this.hueArea.appendChild(this.hueCursor);
// Controls section
this.controls = document.createElement('div');
this.controls.className = 'color-picker-controls';
// Hex input
if (this.options.showHexInput) {
this.hexInput = document.createElement('input');
this.hexInput.type = 'text';
this.hexInput.className = 'color-picker-hex';
this.hexInput.placeholder = '#000000';
this.hexInput.maxLength = 7;
this.controls.appendChild(this.hexInput);
}
// RGB inputs and sliders
this.rgbContainer = document.createElement('div');
this.rgbContainer.className = 'color-picker-rgb';
['R', 'G', 'B'].forEach((label, index) => {
const wrapper = document.createElement('div');
wrapper.className = 'color-picker-rgb-item';
wrapper.dataset.channel = label.toLowerCase();
const labelEl = document.createElement('label');
labelEl.textContent = label;
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'color-picker-rgb-slider';
slider.min = 0;
slider.max = 255;
slider.value = 0;
slider.dataset.channel = label.toLowerCase();
const input = document.createElement('input');
input.type = 'number';
input.className = 'color-picker-rgb-input';
input.min = 0;
input.max = 255;
input.value = 0;
input.dataset.channel = label.toLowerCase();
wrapper.appendChild(labelEl);
wrapper.appendChild(slider);
wrapper.appendChild(input);
this.rgbContainer.appendChild(wrapper);
this[`rgb${label}Slider`] = slider;
this[`rgb${label}`] = input;
});
this.controls.appendChild(this.rgbContainer);
// Assemble panel
const pickerArea = document.createElement('div');
pickerArea.className = 'color-picker-area';
pickerArea.appendChild(this.mainArea);
pickerArea.appendChild(this.hueArea);
this.panel.appendChild(pickerArea);
this.panel.appendChild(this.controls);
// Assemble container
this.container.appendChild(this.previewBtn);
this.container.appendChild(this.panel);
// Draw canvases
this.drawHueCanvas();
this.drawMainCanvas(1.0); // Start with full saturation
}
setupEventListeners() {
// Toggle panel
this.previewBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!this.container.contains(e.target) && this.isOpen) {
this.close();
}
});
// Main area interaction
let isMainDragging = false;
this.mainCanvas.addEventListener('mousedown', (e) => {
isMainDragging = true;
this.handleMainAreaClick(e);
});
this.mainCanvas.addEventListener('mousemove', (e) => {
if (isMainDragging) {
this.handleMainAreaClick(e);
}
});
document.addEventListener('mouseup', () => {
isMainDragging = false;
});
// Touch support for main area
this.mainCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
isMainDragging = true;
this.handleMainAreaClick(e.touches[0]);
});
this.mainCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (isMainDragging) {
this.handleMainAreaClick(e.touches[0]);
}
});
this.mainCanvas.addEventListener('touchend', () => {
isMainDragging = false;
});
// Hue slider interaction
let isHueDragging = false;
this.hueCanvas.addEventListener('mousedown', (e) => {
isHueDragging = true;
this.handleHueClick(e);
});
this.hueCanvas.addEventListener('mousemove', (e) => {
if (isHueDragging) {
this.handleHueClick(e);
}
});
document.addEventListener('mouseup', () => {
isHueDragging = false;
});
// Touch support for hue slider
this.hueCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
isHueDragging = true;
this.handleHueClick(e.touches[0]);
});
this.hueCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (isHueDragging) {
this.handleHueClick(e.touches[0]);
}
});
this.hueCanvas.addEventListener('touchend', () => {
isHueDragging = false;
});
// Hex input
if (this.hexInput) {
this.hexInput.addEventListener('input', (e) => {
const value = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
this.updateColor(value);
}
});
this.hexInput.addEventListener('blur', (e) => {
const value = e.target.value;
if (!/^#[0-9A-Fa-f]{6}$/.test(value) && value.length > 0) {
e.target.value = this.currentColor;
}
});
}
// RGB inputs and sliders
['R', 'G', 'B'].forEach(label => {
// Slider change
this[`rgb${label}Slider`].addEventListener('input', (e) => {
const value = parseInt(e.target.value) || 0;
this[`rgb${label}`].value = value;
const r = parseInt(this.rgbR.value) || 0;
const g = parseInt(this.rgbG.value) || 0;
const b = parseInt(this.rgbB.value) || 0;
const hex = this.rgbToHex(r, g, b);
this.updateColor(hex, false); // Don't update RGB inputs/sliders to avoid loop
});
// Input change
this[`rgb${label}`].addEventListener('input', (e) => {
let value = parseInt(e.target.value) || 0;
value = Math.max(0, Math.min(255, value)); // Clamp to 0-255
e.target.value = value;
this[`rgb${label}Slider`].value = value;
const r = parseInt(this.rgbR.value) || 0;
const g = parseInt(this.rgbG.value) || 0;
const b = parseInt(this.rgbB.value) || 0;
const hex = this.rgbToHex(r, g, b);
this.updateColor(hex, false); // Don't update RGB inputs/sliders to avoid loop
});
});
}
drawHueCanvas() {
const ctx = this.hueCanvas.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
for (let i = 0; i <= 6; i++) {
const hue = i * 60;
gradient.addColorStop(i / 6, `hsl(${hue}, 100%, 50%)`);
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 20, 200);
}
drawMainCanvas(hue) {
const ctx = this.mainCanvas.getContext('2d');
// Saturation gradient (left to right)
const satGradient = ctx.createLinearGradient(0, 0, 200, 0);
satGradient.addColorStop(0, `hsl(${hue}, 0%, 50%)`);
satGradient.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
ctx.fillStyle = satGradient;
ctx.fillRect(0, 0, 200, 200);
// Brightness gradient (top to bottom)
const brightGradient = ctx.createLinearGradient(0, 0, 0, 200);
brightGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
brightGradient.addColorStop(1, 'rgba(0, 0, 0, 1)');
ctx.fillStyle = brightGradient;
ctx.fillRect(0, 0, 200, 200);
}
handleMainAreaClick(e) {
const rect = this.mainCanvas.getBoundingClientRect();
const x = Math.max(0, Math.min(200, e.clientX - rect.left));
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
const saturation = x / 200;
const brightness = 1 - (y / 200);
this.updateColorFromHSB(this.hue, saturation, brightness);
this.updateCursor(x, y);
}
handleHueClick(e) {
const rect = this.hueCanvas.getBoundingClientRect();
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
const hue = (y / 200) * 360;
this.hue = hue;
this.drawMainCanvas(hue);
this.updateHueCursor(y);
// Recalculate color with new hue
const rect2 = this.mainCanvas.getBoundingClientRect();
const x = parseFloat(this.mainCursor.style.left) || 0;
const y2 = parseFloat(this.mainCursor.style.top) || 0;
const saturation = x / 200;
const brightness = 1 - (y2 / 200);
this.updateColorFromHSB(hue, saturation, brightness);
}
updateColorFromHSB(h, s, v) {
const rgb = this.hsbToRgb(h, s, v);
const hex = this.rgbToHex(rgb.r, rgb.g, rgb.b);
this.updateColor(hex);
}
hsbToRgb(h, s, v) {
h = h / 360;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
rgbToHex(r, g, b) {
return '#' + [r, g, b].map(x => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
}).join('').toUpperCase();
}
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
rgbToHsb(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const diff = max - min;
let h = 0;
if (diff !== 0) {
if (max === r) {
h = ((g - b) / diff) % 6) * 60;
} else if (max === g) {
h = ((b - r) / diff + 2) * 60;
} else {
h = ((r - g) / diff + 4) * 60;
}
}
if (h < 0) h += 360;
const s = max === 0 ? 0 : diff / max;
const v = max;
return { h, s, v };
}
updateColor(hex, updateInputs = true) {
this.currentColor = hex.toUpperCase();
this.previewBtn.style.backgroundColor = this.currentColor;
const rgb = this.hexToRgb(this.currentColor);
if (!rgb) return;
const hsb = this.rgbToHsb(rgb.r, rgb.g, rgb.b);
this.hue = hsb.h;
// Update main canvas
this.drawMainCanvas(this.hue);
// Update cursors
const x = hsb.s * 200;
const y = (1 - hsb.v) * 200;
this.updateCursor(x, y);
this.updateHueCursor((this.hue / 360) * 200);
// Update inputs
if (updateInputs) {
if (this.hexInput) {
this.hexInput.value = this.currentColor;
}
if (this.rgbR) {
this.rgbR.value = rgb.r;
this.rgbG.value = rgb.g;
this.rgbB.value = rgb.b;
}
if (this.rgbRSlider) {
this.rgbRSlider.value = rgb.r;
this.rgbGSlider.value = rgb.g;
this.rgbBSlider.value = rgb.b;
}
}
// Callback
if (this.options.onColorChange) {
this.options.onColorChange(this.currentColor);
}
}
updateCursor(x, y) {
this.mainCursor.style.left = `${x}px`;
this.mainCursor.style.top = `${y}px`;
}
updateHueCursor(y) {
this.hueCursor.style.top = `${y}px`;
}
toggle() {
this.isOpen ? this.close() : this.open();
}
open() {
this.panel.style.display = 'block';
this.isOpen = true;
}
close() {
this.panel.style.display = 'none';
this.isOpen = false;
}
getColor() {
return this.currentColor;
}
setColor(color) {
this.updateColor(color);
}
}
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = ColorPicker;
}

359
docs/mockups/dashboard.html Normal file
View File

@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Dashboard</title>
<link rel="stylesheet" href="color-picker.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header h1 {
color: #667eea;
margin-bottom: 8px;
}
.header p {
color: #666;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.15);
}
.card h2 {
color: #667eea;
margin-bottom: 16px;
font-size: 1.25rem;
}
.pattern-selector {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
}
.pattern-btn {
padding: 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.2s;
text-align: center;
font-weight: 500;
}
.pattern-btn:hover {
border-color: #667eea;
background: #f0f0ff;
}
.pattern-btn.active {
border-color: #667eea;
background: #667eea;
color: white;
}
.slider-group {
margin-bottom: 20px;
}
.slider-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.slider {
width: 100%;
height: 8px;
border-radius: 4px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
}
.value-display {
display: inline-block;
margin-left: 12px;
font-weight: 600;
color: #667eea;
}
.color-picker-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.color-picker-wrapper {
display: inline-block;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.device-list {
list-style: none;
}
.device-item {
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.device-status {
width: 12px;
height: 12px;
border-radius: 50%;
background: #4caf50;
}
.device-status.offline {
background: #f44336;
}
.actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.btn-full {
width: 100%;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>LED Driver Control Panel</h1>
<p>Manage your LED devices and patterns</p>
</div>
<div class="grid">
<!-- Pattern Selection -->
<div class="card">
<h2>Pattern Selection</h2>
<div class="pattern-selector">
<div class="pattern-btn active">On</div>
<div class="pattern-btn">Off</div>
<div class="pattern-btn">Blink</div>
<div class="pattern-btn">Chase</div>
<div class="pattern-btn">Circle</div>
<div class="pattern-btn">Pulse</div>
<div class="pattern-btn">Rainbow</div>
<div class="pattern-btn">Transition</div>
</div>
</div>
<!-- Brightness & Speed -->
<div class="card">
<h2>Brightness & Speed</h2>
<div class="slider-group">
<label>
Brightness
<span class="value-display" id="brightness-value">100</span>%
</label>
<input type="range" class="slider" id="brightness" min="0" max="100" value="100">
</div>
<div class="slider-group">
<label>
Delay
<span class="value-display" id="delay-value">100</span>ms
</label>
<input type="range" class="slider" id="delay" min="10" max="1000" value="100" step="10">
</div>
</div>
<!-- Color Selection -->
<div class="card">
<h2>Colors</h2>
<div class="color-picker-group">
<input type="color" class="color-input" value="#000000">
<input type="color" class="color-input" value="#FF0000">
<input type="color" class="color-input" value="#00FF00">
<input type="color" class="color-input" value="#0000FF">
</div>
<div class="actions">
<button class="btn btn-secondary btn-full">Add Color</button>
</div>
</div>
<!-- Device Status -->
<div class="card">
<h2>Connected Devices</h2>
<ul class="device-list">
<li class="device-item">
<div>
<strong>led-device1</strong>
<div style="font-size: 0.875rem; color: #666;">Group: group1</div>
</div>
<div class="device-status"></div>
</li>
<li class="device-item">
<div>
<strong>led-device2</strong>
<div style="font-size: 0.875rem; color: #666;">Group: group2</div>
</div>
<div class="device-status"></div>
</li>
<li class="device-item">
<div>
<strong>led-device3</strong>
<div style="font-size: 0.875rem; color: #666;">No group</div>
</div>
<div class="device-status offline"></div>
</li>
</ul>
</div>
</div>
<!-- Action Buttons -->
<div class="card">
<div class="actions">
<button class="btn btn-primary btn-full">Apply Settings</button>
<button class="btn btn-secondary btn-full">Save to Device</button>
</div>
</div>
</div>
<script>
// Brightness slider
document.getElementById('brightness').addEventListener('input', function(e) {
document.getElementById('brightness-value').textContent = e.target.value;
});
// Delay slider
document.getElementById('delay').addEventListener('input', function(e) {
document.getElementById('delay-value').textContent = e.target.value;
});
// Pattern selection
document.querySelectorAll('.pattern-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.pattern-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
});
});
// Initialize color pickers
const colorPickers = [];
const initialColors = ['#000000', '#FF0000'];
function addColorPicker(color = '#000000') {
const container = document.createElement('div');
container.className = 'color-picker-wrapper';
document.getElementById('color-pickers').appendChild(container);
const picker = new ColorPicker(container, {
initialColor: color,
onColorChange: (newColor) => {
console.log('Color changed:', newColor);
// Update device colors
}
});
colorPickers.push(picker);
return picker;
}
// Add initial color pickers
initialColors.forEach(color => addColorPicker(color));
</script>
<script src="color-picker.js"></script>
</body>
</html>

View File

@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Device Management</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
color: #667eea;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
background: white;
padding: 8px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.zone {
flex: 1;
padding: 12px 24px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.zone.active {
background: #667eea;
color: white;
}
.zone-content {
display: none;
}
.zone-content.active {
display: block;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.card h2 {
color: #667eea;
margin-bottom: 20px;
}
.device-item, .group-item {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.device-item:hover, .group-item:hover {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.device-info, .group-info {
flex: 1;
}
.device-name, .group-name {
font-weight: 600;
font-size: 1.125rem;
margin-bottom: 4px;
}
.device-details, .group-details {
font-size: 0.875rem;
color: #666;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-right: 12px;
}
.status-online {
background: #d4edda;
color: #155724;
}
.status-offline {
background: #f8d7da;
color: #721c24;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: #4caf50;
margin-right: 8px;
}
.status-indicator.offline {
background: #f44336;
}
.device-actions, .group-actions {
display: flex;
gap: 8px;
}
.btn-icon {
padding: 8px 12px;
border: 1px solid #e0e0e0;
background: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.btn-icon:hover {
border-color: #667eea;
color: #667eea;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-group input, .form-group select {
width: 100%;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
}
.form-group input:focus, .form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.group-devices {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
}
.group-device-tag {
display: inline-block;
padding: 4px 8px;
background: #f0f0f0;
border-radius: 4px;
font-size: 0.75rem;
margin-right: 8px;
margin-top: 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Device & Group Management</h1>
<button class="btn btn-primary" onclick="showAddDeviceModal()">+ Add Device</button>
</div>
<div class="tabs">
<button class="zone active" onclick="switchTab('devices')">Devices</button>
<button class="zone" onclick="switchTab('groups')">Groups</button>
</div>
<!-- Devices Zone -->
<div id="devices-zone" class="zone-content active">
<div class="card">
<h2>Connected Devices</h2>
<div class="device-item">
<div class="device-info">
<div class="device-name">
<span class="status-indicator"></span>
led-device1
</div>
<div class="device-details">
<span class="status-badge status-online">Online</span>
MAC: AA:BB:CC:DD:EE:01 | Group: group1 | Pattern: Rainbow
</div>
</div>
<div class="device-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Settings">⚙️</button>
<button class="btn-icon" title="Remove">🗑️</button>
</div>
</div>
<div class="device-item">
<div class="device-info">
<div class="device-name">
<span class="status-indicator"></span>
led-device2
</div>
<div class="device-details">
<span class="status-badge status-online">Online</span>
MAC: AA:BB:CC:DD:EE:02 | Group: group2 | Pattern: Chase
</div>
</div>
<div class="device-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Settings">⚙️</button>
<button class="btn-icon" title="Remove">🗑️</button>
</div>
</div>
<div class="device-item">
<div class="device-info">
<div class="device-name">
<span class="status-indicator offline"></span>
led-device3
</div>
<div class="device-details">
<span class="status-badge status-offline">Offline</span>
MAC: AA:BB:CC:DD:EE:03 | No group | Pattern: On
</div>
</div>
<div class="device-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Settings">⚙️</button>
<button class="btn-icon" title="Remove">🗑️</button>
</div>
</div>
</div>
</div>
<!-- Groups Zone -->
<div id="groups-zone" class="zone-content">
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>Groups</h2>
<button class="btn btn-primary" onclick="showAddGroupModal()">+ Create Group</button>
</div>
<div class="group-item">
<div class="group-info">
<div class="group-name">group1</div>
<div class="group-details">
Pattern: On | Brightness: 100% | Delay: 100ms
</div>
<div class="group-devices">
<span class="group-device-tag">led-device1</span>
</div>
</div>
<div class="group-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Apply">▶️</button>
<button class="btn-icon" title="Delete">🗑️</button>
</div>
</div>
<div class="group-item">
<div class="group-info">
<div class="group-name">group2</div>
<div class="group-details">
Pattern: Chase | Brightness: 75% | Delay: 200ms
</div>
<div class="group-devices">
<span class="group-device-tag">led-device2</span>
</div>
</div>
<div class="group-actions">
<button class="btn-icon" title="Edit">✏️</button>
<button class="btn-icon" title="Apply">▶️</button>
<button class="btn-icon" title="Delete">🗑️</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal (simplified) -->
<div id="modal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 500px; margin: 20px;">
<h2 id="modal-title">Add Device</h2>
<div class="form-group">
<label>Device Name</label>
<input type="text" id="device-name" placeholder="led-device4">
</div>
<div class="form-group">
<label>MAC Address</label>
<input type="text" id="device-mac" placeholder="AA:BB:CC:DD:EE:04">
</div>
<div class="form-group">
<label>Group</label>
<select id="device-group">
<option value="">No group</option>
<option value="group1">group1</option>
<option value="group2">group2</option>
</select>
</div>
<div class="form-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveDevice()">Save</button>
</div>
</div>
</div>
<script>
function switchTab(zone) {
document.querySelectorAll('.zone').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.zone-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(zone + '-zone').classList.add('active');
}
function showAddDeviceModal() {
document.getElementById('modal').style.display = 'flex';
document.getElementById('modal-title').textContent = 'Add Device';
}
function showAddGroupModal() {
document.getElementById('modal').style.display = 'flex';
document.getElementById('modal-title').textContent = 'Create Group';
}
function closeModal() {
document.getElementById('modal').style.display = 'none';
}
function saveDevice() {
alert('Device saved! (This is a mockup)');
closeModal();
}
</script>
</body>
</html>

155
docs/mockups/generate_images.py Executable file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Generate images from HTML mockup files
Uses Playwright to render HTML and take screenshots
"""
import os
import sys
from pathlib import Path
try:
from playwright.sync_api import sync_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
try:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
SELENIUM_AVAILABLE = True
except ImportError:
SELENIUM_AVAILABLE = False
try:
from html2image import Html2Image
HTML2IMAGE_AVAILABLE = True
except ImportError:
HTML2IMAGE_AVAILABLE = False
def generate_with_playwright(html_file, output_file, width=1920, height=1080):
"""Generate image using Playwright"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': width, 'height': height})
page.goto(f'file://{html_file.absolute()}')
# Wait for page to load
page.wait_for_timeout(1000)
page.screenshot(path=str(output_file), full_page=True)
browser.close()
print(f"✓ Generated {output_file.name} using Playwright")
def generate_with_selenium(html_file, output_file, width=1920, height=1080):
"""Generate image using Selenium"""
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument(f'--window-size={width},{height}')
driver = webdriver.Chrome(options=chrome_options)
try:
driver.get(f'file://{html_file.absolute()}')
# Wait for page to load
import time
time.sleep(2)
driver.save_screenshot(str(output_file))
print(f"✓ Generated {output_file.name} using Selenium")
finally:
driver.quit()
def generate_with_html2image(html_file, output_file, width=1920, height=1080):
"""Generate image using html2image"""
hti = Html2Image(size=(width, height))
hti.screenshot(
html_file=str(html_file),
save_as=output_file.name,
size=(width, height)
)
print(f"✓ Generated {output_file.name} using html2image")
def generate_image(html_file, output_dir, width=1920, height=1080):
"""Generate image from HTML file using available method"""
html_path = Path(html_file)
output_path = output_dir / f"{html_path.stem}.png"
if PLAYWRIGHT_AVAILABLE:
try:
generate_with_playwright(html_path, output_path, width, height)
return True
except Exception as e:
print(f"Playwright failed: {e}, trying alternatives...")
if SELENIUM_AVAILABLE:
try:
generate_with_selenium(html_path, output_path, width, height)
return True
except Exception as e:
print(f"Selenium failed: {e}, trying alternatives...")
if HTML2IMAGE_AVAILABLE:
try:
generate_with_html2image(html_path, output_path, width, height)
return True
except Exception as e:
print(f"html2image failed: {e}")
return False
def main():
"""Main function to generate images from all HTML files"""
script_dir = Path(__file__).parent
output_dir = script_dir / "images"
output_dir.mkdir(exist_ok=True)
html_files = list(script_dir.glob("*.html"))
if not html_files:
print("No HTML files found in mockups directory")
return
print(f"Found {len(html_files)} HTML file(s)")
print(f"Output directory: {output_dir}")
print()
# Check available libraries
if not any([PLAYWRIGHT_AVAILABLE, SELENIUM_AVAILABLE, HTML2IMAGE_AVAILABLE]):
print("ERROR: No screenshot library available!")
print("\nPlease install one of the following:")
print(" pip install playwright && playwright install chromium")
print(" pip install selenium")
print(" pip install html2image")
sys.exit(1)
print("Available screenshot libraries:")
if PLAYWRIGHT_AVAILABLE:
print(" ✓ Playwright")
if SELENIUM_AVAILABLE:
print(" ✓ Selenium")
if HTML2IMAGE_AVAILABLE:
print(" ✓ html2image")
print()
# Generate images
success_count = 0
for html_file in html_files:
print(f"Generating image from {html_file.name}...")
if generate_image(html_file, output_dir):
success_count += 1
else:
print(f"✗ Failed to generate image from {html_file.name}")
print()
print(f"Successfully generated {success_count}/{len(html_files)} images")
print(f"Images saved to: {output_dir}")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

136
docs/mockups/index.html Normal file
View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - UI Mockups</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
display: flex;
align-items: center;
justify-content: center;
}
.container {
max-width: 800px;
width: 100%;
}
.header {
text-align: center;
color: white;
margin-bottom: 40px;
}
.header h1 {
font-size: 3rem;
margin-bottom: 12px;
}
.header p {
font-size: 1.25rem;
opacity: 0.9;
}
.mockups-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.mockup-card {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
transition: all 0.3s;
text-decoration: none;
color: inherit;
display: block;
}
.mockup-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
}
.mockup-icon {
font-size: 3rem;
margin-bottom: 16px;
}
.mockup-title {
font-size: 1.5rem;
font-weight: 600;
color: #667eea;
margin-bottom: 8px;
}
.mockup-description {
color: #666;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>LED Driver UI Mockups</h1>
<p>Example user interfaces for the LED driver system</p>
</div>
<div class="mockups-grid">
<a href="dashboard.html" class="mockup-card">
<div class="mockup-icon">📊</div>
<div class="mockup-title">Dashboard</div>
<div class="mockup-description">
Main control panel for managing LED patterns, brightness, colors, and device status.
</div>
</a>
<a href="pattern-selector.html" class="mockup-card">
<div class="mockup-icon">🎨</div>
<div class="mockup-title">Pattern Selector</div>
<div class="mockup-description">
Visual interface for selecting from available LED patterns: On, Off, Blink, Chase, Circle, Pulse, Rainbow, and Transition.
</div>
</a>
<a href="device-management.html" class="mockup-card">
<div class="mockup-icon">🔧</div>
<div class="mockup-title">Device Management</div>
<div class="mockup-description">
Manage connected LED devices and groups. View device status, assign groups, and configure device settings.
</div>
</a>
<a href="settings.html" class="mockup-card">
<div class="mockup-icon">⚙️</div>
<div class="mockup-title">Settings</div>
<div class="mockup-description">
Comprehensive settings panel for configuring LED pin, color order, pattern parameters, and network settings.
</div>
</a>
<a href="presets.html" class="mockup-card">
<div class="mockup-icon">💾</div>
<div class="mockup-title">Presets</div>
<div class="mockup-description">
Save, load, and manage preset configurations with pattern, colors, delay, and all N1-N8 parameters for quick pattern switching.
</div>
</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Pattern Selector</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 40px;
color: #2d3748;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 8px;
}
.header p {
font-size: 1.125rem;
color: #718096;
}
.patterns-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.pattern-card {
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s;
border: 3px solid transparent;
}
.pattern-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.pattern-card.selected {
border-color: #667eea;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.pattern-icon {
width: 100%;
height: 180px;
border-radius: 12px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
background: #f7fafc;
position: relative;
overflow: hidden;
}
.pattern-card.selected .pattern-icon {
background: rgba(255, 255, 255, 0.2);
}
.pattern-name {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 8px;
}
.pattern-description {
font-size: 0.875rem;
color: #718096;
line-height: 1.5;
}
.pattern-card.selected .pattern-description {
color: rgba(255, 255, 255, 0.9);
}
.pattern-preview {
display: flex;
gap: 4px;
margin-top: 12px;
}
.preview-dot {
flex: 1;
height: 8px;
border-radius: 4px;
background: #e2e8f0;
}
.pattern-card.selected .preview-dot {
background: rgba(255, 255, 255, 0.5);
}
.actions {
margin-top: 40px;
text-align: center;
}
.btn {
padding: 16px 48px;
border: none;
border-radius: 12px;
font-size: 1.125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin: 0 12px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid #667eea;
}
.btn-secondary:hover {
background: #f0f0ff;
}
/* Pattern-specific icons */
.icon-on { background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%); }
.icon-off { background: #2d3748; }
.icon-blink { background: linear-gradient(90deg, #ffd700 25%, #2d3748 25%, #2d3748 50%, #ffd700 50%); }
.icon-chase { background: linear-gradient(90deg, #ff0000 0%, #ff6666 50%, #ff0000 100%); }
.icon-circle { background: radial-gradient(circle, #00ff00 0%, #66ff66 50%, #00ff00 100%); }
.icon-pulse { background: radial-gradient(circle, #0000ff 0%, #6666ff 50%, #0000ff 100%); }
.icon-rainbow { background: linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3); }
.icon-transition { background: linear-gradient(135deg, #ff0000 0%, #0000ff 100%); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Select LED Pattern</h1>
<p>Choose a pattern to display on your LED devices</p>
</div>
<div class="patterns-grid">
<div class="pattern-card" data-pattern="on">
<div class="pattern-icon icon-on">💡</div>
<div class="pattern-name">On</div>
<div class="pattern-description">Solid color display - LEDs stay on with selected color</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="off">
<div class="pattern-icon icon-off"></div>
<div class="pattern-name">Off</div>
<div class="pattern-description">Turn all LEDs off</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="blink">
<div class="pattern-icon icon-blink"></div>
<div class="pattern-name">Blink</div>
<div class="pattern-description">All LEDs blink on and off together</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="chase">
<div class="pattern-icon icon-chase">🏃</div>
<div class="pattern-name">Chase</div>
<div class="pattern-description">Light chases along the LED strip</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="circle">
<div class="pattern-icon icon-circle"></div>
<div class="pattern-name">Circle</div>
<div class="pattern-description">Circular pattern that rotates around the strip</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="pulse">
<div class="pattern-icon icon-pulse">💓</div>
<div class="pattern-name">Pulse</div>
<div class="pattern-description">Pulsing effect that fades in and out</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="rainbow">
<div class="pattern-icon icon-rainbow">🌈</div>
<div class="pattern-name">Rainbow</div>
<div class="pattern-description">Smooth rainbow color transition across LEDs</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
<div class="pattern-card" data-pattern="transition">
<div class="pattern-icon icon-transition">🔄</div>
<div class="pattern-name">Transition</div>
<div class="pattern-description">Smooth color transition between selected colors</div>
<div class="pattern-preview">
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
<div class="preview-dot"></div>
</div>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="window.history.back()">Cancel</button>
<button class="btn btn-primary" id="apply-btn">Apply Pattern</button>
</div>
</div>
<script>
let selectedPattern = null;
document.querySelectorAll('.pattern-card').forEach(card => {
card.addEventListener('click', function() {
document.querySelectorAll('.pattern-card').forEach(c => c.classList.remove('selected'));
this.classList.add('selected');
selectedPattern = this.dataset.pattern;
});
});
document.getElementById('apply-btn').addEventListener('click', function() {
if (selectedPattern) {
alert(`Applying pattern: ${selectedPattern}`);
// In real implementation, this would send the pattern to the device
} else {
alert('Please select a pattern first');
}
});
</script>
</body>
</html>

968
docs/mockups/presets.html Normal file
View File

@@ -0,0 +1,968 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Presets</title>
<link rel="stylesheet" href="color-picker.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
color: #667eea;
margin-bottom: 8px;
}
.header p {
color: #666;
}
.controls {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
.search-box {
flex: 1;
min-width: 200px;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
}
.search-box:focus {
outline: none;
border-color: #667eea;
}
.filter-group {
display: flex;
gap: 12px;
align-items: center;
}
.filter-select {
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
background: white;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: #667eea;
}
.view-toggle {
display: flex;
gap: 8px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.view-btn {
padding: 8px 16px;
border: none;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.view-btn.active {
background: #667eea;
color: white;
}
.presets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.preset-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
border: 3px solid transparent;
}
.preset-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.preset-card.selected {
border-color: #667eea;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 16px;
}
.preset-name {
font-size: 1.5rem;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.pattern-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
background: #667eea;
color: white;
margin-bottom: 12px;
}
.pattern-badge.on { background: #4caf50; }
.pattern-badge.off { background: #757575; }
.pattern-badge.blink { background: #ff9800; }
.pattern-badge.chase { background: #f44336; }
.pattern-badge.circle { background: #00bcd4; }
.pattern-badge.pulse { background: #e91e63; }
.pattern-badge.rainbow { background: linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3); }
.pattern-badge.transition { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.color-preview {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.color-swatch {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.preset-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
font-size: 0.875rem;
color: #666;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
}
.info-label {
font-weight: 500;
}
.info-value {
color: #667eea;
font-weight: 600;
}
.preset-actions {
display: flex;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
flex: 1;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.btn-danger {
background: #f44336;
color: white;
}
.btn-danger:hover {
background: #d32f2f;
}
.btn-icon {
padding: 8px;
min-width: 40px;
}
.btn-large {
padding: 16px 32px;
font-size: 1rem;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 32px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-header {
margin-bottom: 24px;
}
.modal-header h2 {
color: #667eea;
margin-bottom: 8px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-group small {
display: block;
margin-top: 4px;
color: #666;
font-size: 0.875rem;
}
.color-inputs {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.color-input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.color-input-wrapper {
display: inline-block;
}
.params-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.n-value-input {
width: 100%;
}
.n-value-input:focus {
border-color: #667eea;
outline: none;
}
.param-input {
display: flex;
flex-direction: column;
}
.param-input label {
font-size: 0.75rem;
margin-bottom: 4px;
}
.param-input input {
padding: 8px;
font-size: 0.875rem;
}
.modal-actions {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
.empty-state {
background: white;
border-radius: 12px;
padding: 60px 40px;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 16px;
}
.empty-state h2 {
color: #667eea;
margin-bottom: 8px;
}
.empty-state p {
color: #666;
margin-bottom: 24px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1>Preset Management</h1>
<p>Save and manage your favorite LED pattern configurations</p>
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<button class="btn btn-secondary btn-large" onclick="syncPresets()" title="Sync all presets to all devices">🔄 Sync Presets to All Devices</button>
<button class="btn btn-primary btn-large" onclick="showCreateModal()">+ Create Preset</button>
</div>
</div>
<div class="controls">
<input type="text" class="search-box" placeholder="Search presets..." id="search-input">
<div class="filter-group">
<select class="filter-select" id="pattern-filter">
<option value="">All Patterns</option>
<option value="on">On</option>
<option value="off">Off</option>
<option value="blink">Blink</option>
<option value="chase">Chase</option>
<option value="circle">Circle</option>
<option value="pulse">Pulse</option>
<option value="rainbow">Rainbow</option>
<option value="transition">Transition</option>
</select>
<select class="filter-select" id="sort-select">
<option value="name">Sort by Name</option>
<option value="recent">Recently Used</option>
<option value="created">Recently Created</option>
</select>
<div class="view-toggle">
<button class="view-btn active" onclick="setView('grid')" id="view-grid">Grid</button>
<button class="view-btn" onclick="setView('list')" id="view-list">List</button>
</div>
</div>
</div>
<div class="presets-grid" id="presets-container">
<!-- Preset Card 1 -->
<div class="preset-card" data-pattern="rainbow" data-name="Fast Rainbow">
<div class="preset-header">
<div>
<div class="preset-name">Fast Rainbow</div>
<span class="pattern-badge rainbow">Rainbow</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF0000;"></div>
<div class="color-swatch" style="background: #00FF00;"></div>
<div class="color-swatch" style="background: #0000FF;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">30ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">10</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Fast Rainbow')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Fast Rainbow')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Fast Rainbow')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 2 -->
<div class="preset-card" data-pattern="pulse" data-name="Slow Pulse">
<div class="preset-header">
<div>
<div class="preset-name">Slow Pulse</div>
<span class="pattern-badge pulse">Pulse</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF0000;"></div>
<div class="color-swatch" style="background: #0000FF;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">200ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">500</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Slow Pulse')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Slow Pulse')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Slow Pulse')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 3 -->
<div class="preset-card" data-pattern="chase" data-name="Red Blue Chase">
<div class="preset-header">
<div>
<div class="preset-name">Red Blue Chase</div>
<span class="pattern-badge chase">Chase</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF0000;"></div>
<div class="color-swatch" style="background: #0000FF;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">100ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">5</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Red Blue Chase')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Red Blue Chase')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Red Blue Chase')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 4 -->
<div class="preset-card" data-pattern="circle" data-name="Loading Circle">
<div class="preset-header">
<div>
<div class="preset-name">Loading Circle</div>
<span class="pattern-badge circle">Circle</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #00FF00;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">50ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">50</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Loading Circle')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Loading Circle')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Loading Circle')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 5 -->
<div class="preset-card" data-pattern="blink" data-name="Party Blink">
<div class="preset-header">
<div>
<div class="preset-name">Party Blink</div>
<span class="pattern-badge blink">Blink</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF00FF;"></div>
<div class="color-swatch" style="background: #00FFFF;"></div>
<div class="color-swatch" style="background: #FFFF00;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">150ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">0</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Party Blink')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Party Blink')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Party Blink')" title="Delete">🗑️</button>
</div>
</div>
<!-- Preset Card 6 -->
<div class="preset-card" data-pattern="transition" data-name="Smooth Transition">
<div class="preset-header">
<div>
<div class="preset-name">Smooth Transition</div>
<span class="pattern-badge transition">Transition</span>
</div>
</div>
<div class="color-preview">
<div class="color-swatch" style="background: #FF0000;"></div>
<div class="color-swatch" style="background: #00FF00;"></div>
<div class="color-swatch" style="background: #0000FF;"></div>
<div class="color-swatch" style="background: #FFFF00;"></div>
</div>
<div class="preset-info">
<div class="info-item">
<span class="info-label">Delay:</span>
<span class="info-value">100ms</span>
</div>
<div class="info-item">
<span class="info-label">N1:</span>
<span class="info-value">0</span>
</div>
</div>
<div class="preset-actions">
<button class="btn btn-primary" onclick="applyPreset('Smooth Transition')">Apply</button>
<button class="btn btn-secondary btn-icon" onclick="editPreset('Smooth Transition')" title="Edit">✏️</button>
<button class="btn btn-danger btn-icon" onclick="deletePreset('Smooth Transition')" title="Delete">🗑️</button>
</div>
</div>
</div>
</div>
<!-- Create/Edit Preset Modal -->
<div class="modal" id="preset-modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">Create Preset</h2>
<p>Configure your preset settings</p>
</div>
<form id="preset-form">
<div class="form-group">
<label for="preset-name">Preset Name *</label>
<input type="text" id="preset-name" required placeholder="Enter preset name">
<small>Unique identifier for this preset</small>
</div>
<div class="form-group">
<label for="preset-pattern">Pattern *</label>
<select id="preset-pattern" required>
<option value="on">On</option>
<option value="off">Off</option>
<option value="blink">Blink</option>
<option value="chase">Chase</option>
<option value="circle">Circle</option>
<option value="pulse">Pulse</option>
<option value="rainbow">Rainbow</option>
<option value="transition">Transition</option>
</select>
</div>
<div class="form-group">
<label>Colors *</label>
<div class="color-inputs" id="color-inputs">
<!-- Color pickers will be added here -->
</div>
<button type="button" class="btn btn-secondary" onclick="addColorPicker()" style="margin-top: 8px;">+ Add Color</button>
<small>Minimum 2 colors required</small>
</div>
<div class="form-group">
<label for="preset-delay">
Delay (ms) *
<span id="delay-value-display" style="margin-left: 12px; color: #667eea; font-weight: 600;">100</span>
</label>
<input type="range" id="preset-delay" min="10" max="1000" value="100" step="10" required>
<small>Animation speed (10-1000 milliseconds)</small>
</div>
<div class="form-group">
<label for="step-offset">Step Offset</label>
<input type="number" id="step-offset" value="0" min="-1000" max="1000">
<small>Step offset for group synchronization. Applied per device when preset is used in a group.</small>
</div>
<div class="form-group">
<label for="step-increment">Step Increment</label>
<input type="number" id="step-increment" value="1" min="1" max="255">
<small>Amount step counter increments per cycle. Controls pattern advancement speed.</small>
</div>
<div class="form-group">
<label>Pattern Parameters (N1-N8)</label>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<small style="margin: 0;">Pattern-specific parameters (0-255, varies by pattern)</small>
<button type="button" class="btn btn-secondary" onclick="setAllNValues(0)" style="padding: 6px 12px; font-size: 0.75rem;">Reset All to 0</button>
</div>
<div class="params-grid">
<div class="param-input">
<label>N1</label>
<input type="number" id="n1" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N2</label>
<input type="number" id="n2" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N3</label>
<input type="number" id="n3" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N4</label>
<input type="number" id="n4" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N5</label>
<input type="number" id="n5" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N6</label>
<input type="number" id="n6" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N7</label>
<input type="number" id="n7" min="0" max="255" value="0" class="n-value-input">
</div>
<div class="param-input">
<label>N8</label>
<input type="number" id="n8" min="0" max="255" value="0" class="n-value-input">
</div>
</div>
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" onclick="setAllNValues(0)" style="padding: 8px 16px; font-size: 0.875rem;">Set All to 0</button>
<button type="button" class="btn btn-secondary" onclick="copyNValuesFromCurrent()" style="padding: 8px 16px; font-size: 0.875rem;">Copy from Current Settings</button>
<button type="button" class="btn btn-secondary" onclick="showNValueHelp()" style="padding: 8px 16px; font-size: 0.875rem;"> Parameter Help</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Preset</button>
</div>
</form>
</div>
</div>
<script>
let currentView = 'grid';
let editingPreset = null;
// Delay slider update
document.getElementById('preset-delay').addEventListener('input', function(e) {
document.getElementById('delay-value-display').textContent = e.target.value;
});
// Search functionality
document.getElementById('search-input').addEventListener('input', function(e) {
filterPresets();
});
// Filter functionality
document.getElementById('pattern-filter').addEventListener('change', function(e) {
filterPresets();
});
// Sort functionality
document.getElementById('sort-select').addEventListener('change', function(e) {
sortPresets(e.target.value);
});
function filterPresets() {
const search = document.getElementById('search-input').value.toLowerCase();
const patternFilter = document.getElementById('pattern-filter').value;
const cards = document.querySelectorAll('.preset-card');
cards.forEach(card => {
const name = card.dataset.name.toLowerCase();
const pattern = card.dataset.pattern;
const matchesSearch = name.includes(search);
const matchesPattern = !patternFilter || pattern === patternFilter;
if (matchesSearch && matchesPattern) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
}
function sortPresets(sortBy) {
const container = document.getElementById('presets-container');
const cards = Array.from(container.querySelectorAll('.preset-card'));
cards.sort((a, b) => {
if (sortBy === 'name') {
return a.dataset.name.localeCompare(b.dataset.name);
} else if (sortBy === 'recent') {
// In real implementation, would use actual usage data
return 0;
} else if (sortBy === 'created') {
// In real implementation, would use creation timestamps
return 0;
}
return 0;
});
cards.forEach(card => container.appendChild(card));
}
function setView(view) {
currentView = view;
const container = document.getElementById('presets-container');
const gridBtn = document.getElementById('view-grid');
const listBtn = document.getElementById('view-list');
if (view === 'grid') {
container.style.gridTemplateColumns = 'repeat(auto-fill, minmax(300px, 1fr))';
gridBtn.classList.add('active');
listBtn.classList.remove('active');
} else {
container.style.gridTemplateColumns = '1fr';
gridBtn.classList.remove('active');
listBtn.classList.add('active');
}
}
function showCreateModal() {
editingPreset = null;
document.getElementById('modal-title').textContent = 'Create Preset';
document.getElementById('preset-form').reset();
document.getElementById('preset-delay').value = 100;
document.getElementById('delay-value-display').textContent = '100';
// Reset to 2 colors
initializeColorPickers();
document.getElementById('preset-modal').classList.add('active');
}
// Initialize color pickers function (defined before showCreateModal)
const presetColorPickers = [];
function initializeColorPickers() {
const colorInputs = document.getElementById('color-inputs');
colorInputs.innerHTML = '';
presetColorPickers.length = 0;
addColorPicker('#FF0000');
addColorPicker('#0000FF');
}
function addColorPicker(color = '#00FF00') {
const colorInputs = document.getElementById('color-inputs');
const wrapper = document.createElement('div');
wrapper.className = 'color-input-wrapper';
colorInputs.appendChild(wrapper);
const picker = new ColorPicker(wrapper, {
initialColor: color,
onColorChange: (newColor) => {
console.log('Preset color changed:', newColor);
}
});
presetColorPickers.push(picker);
return picker;
}
function editPreset(name) {
editingPreset = name;
document.getElementById('modal-title').textContent = 'Edit Preset';
// In real implementation, would load preset data
document.getElementById('preset-name').value = name;
document.getElementById('preset-modal').classList.add('active');
}
function closeModal() {
document.getElementById('preset-modal').classList.remove('active');
editingPreset = null;
}
function applyPreset(name) {
alert(`Applying preset: ${name}\n(In real implementation, this would send preset configuration to device(s))`);
}
function deletePreset(name) {
if (confirm(`Delete preset "${name}"?`)) {
alert(`Preset "${name}" deleted\n(In real implementation, this would remove the preset from storage)`);
}
}
function syncPresets() {
if (confirm('Sync all presets to all devices?\nThis will send all presets from master to all devices via ESPNow.')) {
alert('Syncing presets to all devices...\n(In real implementation, this would send all presets via ESPNow to all devices)');
}
}
function setAllNValues(value) {
for (let i = 1; i <= 8; i++) {
document.getElementById(`n${i}`).value = value;
}
}
function copyNValuesFromCurrent() {
// In real implementation, this would copy from current device settings
alert('Copying N values from current device settings...\n(In real implementation, this would load current N1-N8 values from the active device)');
// Example: would set values like this:
// document.getElementById('n1').value = currentSettings.n1;
// ... etc
}
function showNValueHelp() {
const helpText = `
Pattern Parameter Guide:
Rainbow:
N1: Step increment (1-255, default: 1)
Pulse:
N1: Attack time in ms (0-255)
N2: Hold time in ms (0-255)
N3: Decay time in ms (0-255)
Chase:
N1: LEDs of color 0 (1-255)
N2: LEDs of color 1 (1-255)
N3: Step movement on odd steps (can be negative)
N4: Step movement on even steps (can be negative)
Circle:
N1: Head moves per second (1-255)
N2: Max length in LEDs (1-255)
N3: Tail moves per second (1-255)
N4: Min length in LEDs (0-255)
Other patterns:
N1-N8: Reserved for future pattern enhancements
All values range from 0-255 unless otherwise specified.
`;
alert(helpText);
}
// Form submission
document.getElementById('preset-form').addEventListener('submit', function(e) {
e.preventDefault();
const name = document.getElementById('preset-name').value;
const action = editingPreset ? 'updated' : 'created';
alert(`Preset "${name}" ${action}!\n(In real implementation, this would save the preset to storage)`);
closeModal();
});
// Close modal on outside click
document.getElementById('preset-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
</script>
<script src="color-picker.js"></script>
</body>
</html>

491
docs/mockups/settings.html Normal file
View File

@@ -0,0 +1,491 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Driver - Settings</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header h1 {
color: #667eea;
margin-bottom: 8px;
}
.header p {
color: #666;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.card h2 {
color: #667eea;
margin-bottom: 20px;
font-size: 1.5rem;
border-bottom: 2px solid #e0e0e0;
padding-bottom: 12px;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="password"],
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-group input[type="range"] {
width: 100%;
height: 8px;
border-radius: 4px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
}
.form-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
}
.form-group input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
}
.value-display {
display: inline-block;
margin-left: 12px;
font-weight: 600;
color: #667eea;
min-width: 60px;
}
.form-group small {
display: block;
margin-top: 4px;
color: #666;
font-size: 0.875rem;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 12px;
}
.checkbox-group input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.color-order {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.color-order-option {
flex: 1;
min-width: 120px;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.color-order-option:hover {
border-color: #667eea;
}
.color-order-option.selected {
border-color: #667eea;
background: #667eea;
color: white;
}
.color-order-option .color-boxes {
display: flex;
justify-content: center;
gap: 4px;
margin-top: 8px;
}
.color-box {
width: 30px;
height: 30px;
border-radius: 4px;
}
.color-box.r { background: #ff0000; }
.color-box.g { background: #00ff00; }
.color-box.b { background: #0000ff; }
.actions {
display: flex;
gap: 12px;
margin-top: 32px;
padding-top: 24px;
border-top: 2px solid #e0e0e0;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.btn-danger {
background: #f44336;
color: white;
}
.btn-danger:hover {
background: #d32f2f;
}
.btn-full {
flex: 1;
}
.section-divider {
height: 1px;
background: #e0e0e0;
margin: 24px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Device Settings</h1>
<p>Configure your LED driver device settings</p>
</div>
<!-- Basic Settings -->
<div class="card">
<h2>Basic Settings</h2>
<div class="form-group">
<label>Device Name</label>
<input type="text" id="device-name" value="led-device1" placeholder="led-device1">
<small>Unique identifier for this device</small>
</div>
<div class="form-group">
<label>LED Pin</label>
<input type="number" id="led-pin" value="10" min="0" max="40">
<small>GPIO pin number connected to LED data line</small>
</div>
<div class="form-group">
<label>Number of LEDs</label>
<input type="number" id="num-leds" value="50" min="1" max="1000">
<small>Total number of LEDs in your strip</small>
</div>
<div class="form-group">
<label>Color Order</label>
<div class="color-order">
<div class="color-order-option selected" data-order="rgb">
RGB
<div class="color-boxes">
<div class="color-box r"></div>
<div class="color-box g"></div>
<div class="color-box b"></div>
</div>
</div>
<div class="color-order-option" data-order="rbg">
RBG
<div class="color-boxes">
<div class="color-box r"></div>
<div class="color-box b"></div>
<div class="color-box g"></div>
</div>
</div>
<div class="color-order-option" data-order="grb">
GRB
<div class="color-boxes">
<div class="color-box g"></div>
<div class="color-box r"></div>
<div class="color-box b"></div>
</div>
</div>
<div class="color-order-option" data-order="gbr">
GBR
<div class="color-boxes">
<div class="color-box g"></div>
<div class="color-box b"></div>
<div class="color-box r"></div>
</div>
</div>
<div class="color-order-option" data-order="brg">
BRG
<div class="color-boxes">
<div class="color-box b"></div>
<div class="color-box r"></div>
<div class="color-box g"></div>
</div>
</div>
<div class="color-order-option" data-order="bgr">
BGR
<div class="color-boxes">
<div class="color-box b"></div>
<div class="color-box g"></div>
<div class="color-box r"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Pattern Settings -->
<div class="card">
<h2>Pattern Settings</h2>
<div class="form-group">
<label>Pattern</label>
<select id="pattern">
<option value="on">On</option>
<option value="off">Off</option>
<option value="blink">Blink</option>
<option value="chase">Chase</option>
<option value="circle">Circle</option>
<option value="pulse">Pulse</option>
<option value="rainbow">Rainbow</option>
<option value="transition">Transition</option>
</select>
</div>
<div class="form-group">
<label>
Brightness
<span class="value-display" id="brightness-value">100</span>%
</label>
<input type="range" id="brightness" min="0" max="100" value="100">
</div>
<div class="form-group">
<label>
Delay
<span class="value-display" id="delay-value">100</span>ms
</label>
<input type="range" id="delay" min="10" max="1000" value="100" step="10">
</div>
</div>
<!-- Advanced Settings -->
<div class="card">
<h2>Advanced Settings</h2>
<div class="form-group">
<label>Step Counter</label>
<input type="text" id="step-counter" value="0" readonly style="background: #f5f5f5; cursor: not-allowed;">
<small>Current step position in pattern (read-only)</small>
</div>
<div class="form-group">
<label for="step-increment">
Step Increment
</label>
<input type="number" id="step-increment" value="1" min="1" max="255">
<small>Amount step counter increments per cycle. Controls pattern advancement speed.</small>
</div>
<div class="form-group">
<label>Pattern Parameters</label>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
<div>
<label style="font-size: 0.875rem;">N1</label>
<input type="number" id="n1" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N2</label>
<input type="number" id="n2" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N3</label>
<input type="number" id="n3" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N4</label>
<input type="number" id="n4" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N5</label>
<input type="number" id="n5" value="0" min="0" max="255">
</div>
<div>
<label style="font-size: 0.875rem;">N6</label>
<input type="number" id="n6" value="0" min="0" max="255">
</div>
</div>
<small>Pattern-specific parameters (varies by pattern)</small>
</div>
<div class="form-group">
<label>Device ID</label>
<input type="number" id="device-id" value="1" min="0">
<small>Unique numeric identifier</small>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="debug" checked>
<label for="debug" style="margin: 0;">Debug Mode</label>
</div>
<small>Enable debug logging</small>
</div>
</div>
<!-- Network Settings -->
<div class="card">
<h2>Network Settings</h2>
<div class="form-group">
<label>Access Point Name</label>
<input type="text" id="ap-name" value="led-AA:BB:CC:DD:EE:01" placeholder="led-device">
<small>WiFi access point name for device configuration</small>
</div>
<div class="form-group">
<label>Access Point Password</label>
<input type="password" id="ap-password" placeholder="Leave empty for open network">
<small>Password for the access point (optional)</small>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="ap-enabled" checked>
<label for="ap-enabled" style="margin: 0;">Enable Access Point</label>
</div>
<small>Allow device to create its own WiFi network</small>
</div>
</div>
<!-- Actions -->
<div class="card">
<div class="actions">
<button class="btn btn-secondary btn-full" onclick="resetSettings()">Reset to Defaults</button>
<button class="btn btn-primary btn-full" onclick="saveSettings()">Save Settings</button>
</div>
</div>
</div>
<script>
// Brightness slider
document.getElementById('brightness').addEventListener('input', function(e) {
document.getElementById('brightness-value').textContent = e.target.value;
});
// Delay slider
document.getElementById('delay').addEventListener('input', function(e) {
document.getElementById('delay-value').textContent = e.target.value;
});
// Color order selection
document.querySelectorAll('.color-order-option').forEach(option => {
option.addEventListener('click', function() {
document.querySelectorAll('.color-order-option').forEach(o => o.classList.remove('selected'));
this.classList.add('selected');
});
});
function saveSettings() {
alert('Settings saved! (This is a mockup)');
}
function resetSettings() {
if (confirm('Reset all settings to defaults?')) {
alert('Settings reset! (This is a mockup)');
}
}
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More