Compare commits
9 Commits
764d918d5b
...
63235c7822
| Author | SHA1 | Date | |
|---|---|---|---|
| 63235c7822 | |||
| 5badf17719 | |||
| 4597573ac5 | |||
| 1550122ced | |||
| b7c45fd72c | |||
| 9479d0d292 | |||
| 3698385af4 | |||
| ef968ebe39 | |||
| a5432db99a |
26
.cursor/rules/commit.mdc
Normal file
26
.cursor/rules/commit.mdc
Normal 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`.
|
||||
10
.cursor/rules/spelling.mdc
Normal file
10
.cursor/rules/spelling.mdc
Normal 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.
|
||||
34
README.md
34
README.md
@@ -1,6 +1,36 @@
|
||||
# led-controller
|
||||
|
||||
## Run on port 80 without root
|
||||
LED controller web app for managing profiles, tabs, presets, and colour palettes, and sending commands to LED devices over the serial -> ESP-NOW bridge.
|
||||
|
||||
Run once: `sudo scripts/setup-port80.sh`. Then start the app with: `pipenv run run`.
|
||||
## Run
|
||||
|
||||
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
|
||||
- Start app: `pipenv run run`
|
||||
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
|
||||
|
||||
## UI modes
|
||||
|
||||
- **Run mode**: focused control view. Select tabs/presets and apply profiles. Editing actions are hidden.
|
||||
- **Edit mode**: management view. Shows Tabs, 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 tab content.
|
||||
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete).
|
||||
- In **Edit mode**, Profiles supports create/clone/delete.
|
||||
- Creating a profile always creates a populated `default` tab (starter presets).
|
||||
- Optional **DJ tab** seeding creates:
|
||||
- `dj` tab 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`
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}}
|
||||
{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 5000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, 6, 2, 3]}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}}
|
||||
@@ -1 +1 @@
|
||||
{"1": {"name": "default", "type": "tabs", "tabs": ["1"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "tabs", "tabs": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
||||
{"1": {"name": "default", "type": "tabs", "tabs": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "tabs", "tabs": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
||||
@@ -1 +1 @@
|
||||
{"1": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["4", "2", "7"], ["15", "3", "14"], ["5", "6", "8"], ["10", "11", "9"], ["12", "1", "13"]], "presets_flat": ["4", "2", "7", "15", "3", "14", "5", "6", "8", "10", "11", "9", "12", "1", "13"]}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}}
|
||||
{"1": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["4", "2", "7"], ["15", "3", "14"], ["5", "8", "10"], ["11", "9", "12"], ["1", "13", "37"]], "presets_flat": ["4", "2", "7", "15", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37"], "default_preset": "4"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
||||
37
docs/API.md
37
docs/API.md
@@ -11,6 +11,22 @@ All JSON APIs use `Content-Type: application/json` for bodies and responses unle
|
||||
|
||||
---
|
||||
|
||||
## UI behavior notes
|
||||
|
||||
The main UI has two modes controlled by the mode toggle:
|
||||
|
||||
- **Run mode**: optimized for operation (tab/preset selection and profile apply).
|
||||
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, and profile management actions).
|
||||
|
||||
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.
|
||||
@@ -61,7 +77,7 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
|
||||
| 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. Returns `{ "<id>": { ... } }` with status 201. |
|
||||
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_tab` (request-only) seeds a DJ tab + presets. New profiles always get a populated `default` tab. Returns `{ "<id>": { ... } }` with status 201. |
|
||||
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
|
||||
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
|
||||
| PUT | `/profiles/current` | Update the current profile (from session). |
|
||||
@@ -99,6 +115,11 @@ Scoped to **current profile** in session (see above).
|
||||
|
||||
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.
|
||||
|
||||
### Tabs — `/tabs`
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -116,10 +137,10 @@ Response on success includes `presets_sent`, `messages_sent` (chunking splits pa
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/palettes` | Map of id → color list. |
|
||||
| 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 colors (`name` ignored). |
|
||||
| PUT | `/palettes/<id>` | Update colours (`name` ignored). |
|
||||
| DELETE | `/palettes/<id>` | Delete palette. |
|
||||
|
||||
### Groups — `/groups`
|
||||
@@ -196,7 +217,7 @@ 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` | Colors | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
|
||||
| `c` | Colours | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
|
||||
| `d` | Delay ms | Default 100 |
|
||||
| `b` | Preset brightness | 0–255; combined with global `b` on the device |
|
||||
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
|
||||
@@ -207,7 +228,7 @@ The HTTP app’s **`POST /presets/send`** path builds this from stored presets v
|
||||
### Pattern-specific parameters (`n1`–`n6`)
|
||||
|
||||
#### Rainbow
|
||||
- **`n1`**: Step increment on the color wheel per update (default 1).
|
||||
- **`n1`**: Step increment on the colour wheel per update (default 1).
|
||||
|
||||
#### Pulse
|
||||
- **`n1`**: Attack (fade in) ms
|
||||
@@ -219,8 +240,8 @@ The HTTP app’s **`POST /presets/send`** path builds this from stored presets v
|
||||
- **`d`**: Transition duration ms
|
||||
|
||||
#### Chase
|
||||
- **`n1`**: LEDs with first color
|
||||
- **`n2`**: LEDs with second color
|
||||
- **`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)
|
||||
|
||||
@@ -278,7 +299,7 @@ The HTTP app’s **`POST /presets/send`** path builds this from stored presets v
|
||||
|
||||
1. Reject if `v != "1"`.
|
||||
2. Apply optional top-level **`b`** (global brightness).
|
||||
3. For each entry in **`presets`**, normalize colors and upsert preset by id.
|
||||
3. For each entry in **`presets`**, normalize colours and upsert preset by id.
|
||||
4. If this device’s **`name`** appears in **`select`**, run selection (optional step).
|
||||
5. If **`default`** is set, store startup preset id.
|
||||
6. If **`save`** is set, persist presets.
|
||||
|
||||
@@ -44,7 +44,7 @@ The LED Driver system is a MicroPython-based application for controlling LED str
|
||||
- Pattern configuration and control (patterns run on remote devices)
|
||||
- Real-time brightness and speed control
|
||||
- Global brightness setting (system-wide brightness multiplier)
|
||||
- Multi-color support with customizable color palettes
|
||||
- Multi-colour support with customizable colour palettes
|
||||
- Device grouping for synchronized control
|
||||
- Preset system for saving and loading pattern configurations
|
||||
- Profile and Scene system for complex lighting setups
|
||||
@@ -239,7 +239,7 @@ Primary interface for real-time LED control and monitoring.
|
||||
- **Grid Layout:** 4-column responsive grid
|
||||
- Pattern Selection Card
|
||||
- Brightness & Speed Card
|
||||
- Color Selection Card
|
||||
- Colour Selection Card
|
||||
- Device Status Card
|
||||
- **Action Bar:** Apply and Save buttons
|
||||
|
||||
@@ -273,12 +273,12 @@ Primary interface for real-time LED control and monitoring.
|
||||
- **Default:** 100ms
|
||||
- **Step:** 10ms increments
|
||||
|
||||
**Color Selection**
|
||||
- **Type:** Color picker inputs (HTML5 color input)
|
||||
- **Quantity:** Multiple colors (minimum 2, expandable)
|
||||
- **Format:** Hex color codes (e.g., #FF0000)
|
||||
- **Display:** Large color swatches (60x60px)
|
||||
- **Action:** "Add Color" button for additional colors
|
||||
**Colour Selection**
|
||||
- **Type:** Colour picker inputs (HTML5 colour input)
|
||||
- **Quantity:** Multiple colours (minimum 2, expandable)
|
||||
- **Format:** Hex colour codes (e.g., #FF0000)
|
||||
- **Display:** Large colour swatches (60x60px)
|
||||
- **Action:** "Add Colour" button for additional colours
|
||||
|
||||
**Device Status List**
|
||||
- **Type:** List of connected devices
|
||||
@@ -295,7 +295,7 @@ Primary interface for real-time LED control and monitoring.
|
||||
- **Save to Device:** Persist settings to device storage
|
||||
|
||||
#### Design Specifications
|
||||
- **Color Scheme:** Purple gradient background (#667eea to #764ba2)
|
||||
- **Colour Scheme:** Purple gradient background (#667eea to #764ba2)
|
||||
- **Cards:** White background, rounded corners (12px), shadow
|
||||
- **Hover Effects:** Card lift (translateY -2px), increased shadow
|
||||
- **Typography:** System font stack, 1.25rem headings
|
||||
@@ -509,7 +509,7 @@ Comprehensive device configuration interface.
|
||||
- Device Name (text input)
|
||||
- LED Pin (number input, 0-40)
|
||||
- Number of LEDs (number input, 1-1000)
|
||||
- Color Order (visual selector with 6 options: RGB, RBG, GRB, GBR, BRG, BGR)
|
||||
- Colour Order (visual selector with 6 options: RGB, RBG, GRB, GBR, BRG, BGR)
|
||||
|
||||
**2. Pattern Settings**
|
||||
- Pattern (dropdown selection)
|
||||
@@ -577,16 +577,16 @@ Comprehensive device configuration interface.
|
||||
- Range: Slider with real-time value display
|
||||
- Select: Dropdown menu
|
||||
- Checkbox: Toggle switch
|
||||
- Color: HTML5 color picker
|
||||
- Colour: HTML5 colour picker
|
||||
|
||||
**Color Order Selector**
|
||||
**Colour Order Selector**
|
||||
- **Type:** Visual button grid
|
||||
- **Options:** RGB, RBG, GRB, GBR, BRG, BGR
|
||||
- **Display:** Color boxes showing order (R=red, G=green, B=blue)
|
||||
- **Display:** Colour boxes showing order (R=red, G=green, B=blue)
|
||||
- **Selection:** Single selection with visual feedback
|
||||
|
||||
#### Design Specifications
|
||||
- **Section Headers:** Purple color (#667eea), 1.5rem font, bottom border
|
||||
- **Section Headers:** Purple colour (#667eea), 1.5rem font, bottom border
|
||||
- **Form Groups:** 24px spacing between fields
|
||||
- **Labels:** Bold, 500 weight, dark gray (#333)
|
||||
- **Help Text:** Small gray text below inputs
|
||||
@@ -611,7 +611,7 @@ Save, load, and manage preset configurations for quick pattern switching.
|
||||
Each preset card displays:
|
||||
- **Name:** Preset name (bold, 1.25rem)
|
||||
- **Pattern Badge:** Current pattern type
|
||||
- **Color Preview:** Swatches showing preset colors
|
||||
- **Colour Preview:** Swatches showing preset colours
|
||||
- **Quick Info:** Delay and brightness values
|
||||
- **Actions:** Apply, Edit, Delete buttons
|
||||
|
||||
@@ -620,7 +620,7 @@ Each preset card displays:
|
||||
**Fields:**
|
||||
- Preset Name (text input, required)
|
||||
- Pattern (dropdown selection)
|
||||
- Colors (multiple color pickers, minimum 2)
|
||||
- Colours (multiple colour pickers, minimum 2)
|
||||
- Delay (slider, 10-1000ms)
|
||||
- Step Offset (number input, optional, default: 0)
|
||||
- Tooltip: "Step offset for group synchronization. Applied per device when preset is used in a group."
|
||||
@@ -667,7 +667,7 @@ Each preset card displays:
|
||||
#### Design Specifications
|
||||
- **Card Style:** White background, rounded corners, shadow
|
||||
- **Pattern Badge:** Colored pill with pattern name
|
||||
- **Color Swatches:** 40x40px squares in card header
|
||||
- **Colour Swatches:** 40x40px squares in card header
|
||||
- **Hover Effect:** Card lift, border highlight
|
||||
- **Selected State:** Purple border, subtle background tint
|
||||
|
||||
@@ -681,7 +681,7 @@ Patterns are configured on the controller and sent to remote devices for executi
|
||||
|
||||
- **Pattern Type:** Identifier for the pattern (e.g., "on", "off", "blink", "chase", "pulse", "rainbow", etc.)
|
||||
- **Pattern Parameters:** Numeric parameters (N1-N8) that configure pattern-specific behavior
|
||||
- **Colors:** Color palette for the pattern
|
||||
- **Colours:** Colour palette for the pattern
|
||||
- **Timing:** Delay and speed settings
|
||||
|
||||
**Note:** Pattern execution happens on the remote devices. The controller sends configuration commands to devices.
|
||||
@@ -698,7 +698,7 @@ Pattern-specific numeric parameters:
|
||||
|
||||
#### Overview
|
||||
|
||||
Presets allow users to save complete pattern configurations for quick recall and application. A preset encapsulates all pattern settings including pattern type, colors, timing, and all pattern parameters.
|
||||
Presets allow users to save complete pattern configurations for quick recall and application. A preset encapsulates all pattern settings including pattern type, colours, timing, and all pattern parameters.
|
||||
|
||||
**Note:** Presets are optional. Devices can be controlled directly without presets.
|
||||
|
||||
@@ -708,7 +708,7 @@ A preset contains the following fields:
|
||||
|
||||
- **name** (string, required): Unique identifier for the preset
|
||||
- **pattern** (string, required): Pattern type identifier (sent to remote devices)
|
||||
- **colors** (array of strings, required): Array of hex color codes (minimum 2 colors)
|
||||
- **colours** (array of strings, required): Array of hex colour codes (minimum 2 colours)
|
||||
- **delay** (integer, required): Delay in milliseconds (10-1000)
|
||||
- **n1** (integer, optional): Pattern parameter 1 (0-255, default: 0)
|
||||
- **n2** (integer, optional): Pattern parameter 2 (0-255, default: 0)
|
||||
@@ -889,7 +889,7 @@ A preset contains the following fields:
|
||||
#### Group Properties
|
||||
- **Name:** Unique group identifier
|
||||
- **Devices:** List of device names (can include master and/or slaves)
|
||||
- **Settings:** Pattern, delay, colors
|
||||
- **Settings:** Pattern, delay, colours
|
||||
- **Step Offset:** Per-device step offset sent to devices for synchronized patterns (integer, can be negative)
|
||||
- Each device in group can receive different step offset
|
||||
- Creates wave/chase effect across multiple LED strips
|
||||
@@ -953,7 +953,7 @@ Byte 1: Flags (bit 0: names, bit 1: groups, bit 2: settings, bit 3: save)
|
||||
|-----|------|-------------|--------------|
|
||||
| `pt` | string | Pattern type | on, off, blink, chase, circle, pulse, rainbow, transition |
|
||||
| `pm` | string | Pattern mode | auto, single_shot |
|
||||
| `cl` | array | Colors (hex strings) | Array of hex color codes |
|
||||
| `cl` | array | Colours (hex strings) | Array of hex colour codes |
|
||||
| `br` | int | Global brightness | 0-100 |
|
||||
| `dl` | int | Delay (ms) | 10-1000 |
|
||||
| `n1` | int | Parameter 1 | 0-255 |
|
||||
@@ -966,7 +966,7 @@ Byte 1: Flags (bit 0: names, bit 1: groups, bit 2: settings, bit 3: save)
|
||||
| `n8` | int | Parameter 8 | 0-255 |
|
||||
| `led_pin` | int | GPIO pin | 0-40 |
|
||||
| `num_leds` | int | LED count | 1-1000 |
|
||||
| `color_order` | string | Color order | rgb, rbg, grb, gbr, brg, bgr |
|
||||
| `color_order` | string | Colour order | rgb, rbg, grb, gbr, brg, bgr |
|
||||
| `name` | string | Device name | Any string |
|
||||
| `brightness` | int | Global brightness | 0-100 |
|
||||
| `delay` | int | Delay | 10-1000 |
|
||||
@@ -1247,7 +1247,7 @@ CREATE TABLE IF NOT EXISTS presets (
|
||||
**Preset Fields:**
|
||||
- `name` (string, required): Unique preset identifier
|
||||
- `pattern` (string, required): Pattern type
|
||||
- `colors` (array of strings, required): Hex color codes (minimum 2)
|
||||
- `colors` (array of strings, required): Hex colour codes (minimum 2)
|
||||
- `delay` (integer, required): Delay in milliseconds (10-1000)
|
||||
- `n1` through `n8` (integer, optional): Pattern parameters (0-255, default: 0)
|
||||
|
||||
@@ -1289,7 +1289,7 @@ CREATE TABLE IF NOT EXISTS presets (
|
||||
|
||||
**POST /api/presets**
|
||||
- Create a new preset
|
||||
- Body: Preset object (name, pattern, colors, delay, n1-n8)
|
||||
- Body: Preset object (name, pattern, colours, delay, n1-n8)
|
||||
- Response: Created preset object
|
||||
|
||||
**GET /api/presets/{name}**
|
||||
@@ -1506,7 +1506,7 @@ peak_mem = usqlite.mem_peak()
|
||||
|
||||
1. User navigates to Settings page
|
||||
2. User modifies settings in sections:
|
||||
- Basic Settings (pin, LED count, color order)
|
||||
- Basic Settings (pin, LED count, colour order)
|
||||
- Pattern Settings (pattern, delay)
|
||||
- Global Brightness
|
||||
- Advanced Settings (N1-N8 parameters)
|
||||
@@ -1519,7 +1519,7 @@ peak_mem = usqlite.mem_peak()
|
||||
### Flow 4: Multi-Device Control
|
||||
|
||||
1. User selects multiple devices or a group
|
||||
2. User changes pattern/colors/global brightness
|
||||
2. User changes pattern/colours/global brightness
|
||||
3. User clicks "Apply Settings"
|
||||
4. System sends message targeting selected devices/groups
|
||||
5. All targeted devices update simultaneously
|
||||
@@ -1585,7 +1585,7 @@ peak_mem = usqlite.mem_peak()
|
||||
|
||||
## Design Guidelines
|
||||
|
||||
### Color Palette
|
||||
### Colour Palette
|
||||
|
||||
- **Primary:** Purple gradient `#667eea` to `#764ba2`, Light gradient `#f5f7fa` to `#c3cfe2`
|
||||
- **UI:** Primary `#667eea`, Hover `#5568d3`, Secondary `#e0e0e0`
|
||||
@@ -1612,8 +1612,8 @@ peak_mem = usqlite.mem_peak()
|
||||
- Disabled: 50% opacity, no pointer events
|
||||
|
||||
**Inputs:**
|
||||
- Focus: Border color changes to primary purple
|
||||
- Hover: Slight border color change
|
||||
- Focus: Border colour changes to primary purple
|
||||
- Hover: Slight border colour change
|
||||
- Error: Red border
|
||||
|
||||
**Cards:**
|
||||
@@ -1738,7 +1738,7 @@ peak_mem = usqlite.mem_peak()
|
||||
- Validation
|
||||
|
||||
**Preset Management:**
|
||||
- Preset creation with all fields (name, pattern, colors, delay, n1-n8)
|
||||
- Preset creation with all fields (name, pattern, colours, delay, n1-n8)
|
||||
- Preset loading and application
|
||||
- Preset editing and deletion
|
||||
- Name uniqueness validation
|
||||
@@ -1758,7 +1758,7 @@ peak_mem = usqlite.mem_peak()
|
||||
- Configuration parameters are properly formatted
|
||||
|
||||
**Preset Application:**
|
||||
- Preset loads all parameters correctly (pattern, colors, delay, n1-n8)
|
||||
- Preset loads all parameters correctly (pattern, colours, delay, n1-n8)
|
||||
- Preset applies to single device
|
||||
- Preset applies to device group
|
||||
- Preset values match saved configuration
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Custom Color Picker Component
|
||||
# Custom Colour Picker Component
|
||||
|
||||
A cross-platform, cross-browser color picker component that provides a consistent user experience across all operating systems and browsers.
|
||||
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 Color Model** - Uses Hue, Saturation, Brightness for intuitive color selection
|
||||
✅ **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
|
||||
@@ -33,7 +33,7 @@ A cross-platform, cross-browser color picker component that provides a consisten
|
||||
<div id="my-color-picker"></div>
|
||||
```
|
||||
|
||||
### 3. Initialize the color picker
|
||||
### 3. Initialize the colour picker
|
||||
|
||||
```javascript
|
||||
const picker = new ColorPicker('#my-color-picker', {
|
||||
@@ -57,8 +57,8 @@ new ColorPicker(container, options)
|
||||
- `options` (object) - Configuration options
|
||||
|
||||
**Options:**
|
||||
- `initialColor` (string) - Initial color in hex format (default: '#FF0000')
|
||||
- `onColorChange` (function) - Callback when color changes (receives hex color string)
|
||||
- `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
|
||||
@@ -101,7 +101,7 @@ const picker = new ColorPicker('#picker1', {
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Color Pickers
|
||||
### Multiple Colour Pickers
|
||||
|
||||
```javascript
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF'];
|
||||
@@ -116,7 +116,7 @@ const pickers = colors.map((color, index) => {
|
||||
});
|
||||
```
|
||||
|
||||
### Dynamic Color Picker Creation
|
||||
### Dynamic Colour Picker Creation
|
||||
|
||||
```javascript
|
||||
function addColorPicker(containerId, initialColor = '#000000') {
|
||||
@@ -139,12 +139,12 @@ addColorPicker('color-2', '#00FF00');
|
||||
|
||||
## Styling
|
||||
|
||||
The color picker uses CSS classes that can be customized:
|
||||
The colour picker uses CSS classes that can be customized:
|
||||
|
||||
- `.color-picker-container` - Main container
|
||||
- `.color-picker-preview` - Color preview button
|
||||
- `.color-picker-preview` - Colour preview button
|
||||
- `.color-picker-panel` - Dropdown panel
|
||||
- `.color-picker-main` - Main color area
|
||||
- `.color-picker-main` - Main colour area
|
||||
- `.color-picker-hue` - Hue slider
|
||||
- `.color-picker-controls` - Controls section
|
||||
|
||||
@@ -183,20 +183,20 @@ The color picker uses CSS classes that can be customized:
|
||||
- ✅ iOS 12+
|
||||
- ✅ Android 7+
|
||||
|
||||
## Color Format
|
||||
## Colour Format
|
||||
|
||||
The color picker uses **hex color format** (`#RRGGBB`):
|
||||
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 color picker is integrated into:
|
||||
- `dashboard.html` - Color selection for patterns
|
||||
- `presets.html` - Color selection when creating/editing presets
|
||||
The colour picker is integrated into:
|
||||
- `dashboard.html` - Colour selection for patterns
|
||||
- `presets.html` - Colour selection when creating/editing presets
|
||||
|
||||
### Example: Getting Colors from Multiple Pickers
|
||||
### Example: Getting Colours from Multiple Pickers
|
||||
|
||||
```javascript
|
||||
const colorPickers = [];
|
||||
@@ -218,7 +218,7 @@ function sendColorsToDevice() {
|
||||
## Performance
|
||||
|
||||
- Lightweight: ~14KB JavaScript, ~4KB CSS
|
||||
- Fast rendering: Uses Canvas API for color gradients
|
||||
- Fast rendering: Uses Canvas API for colour gradients
|
||||
- Smooth interactions: Optimized event handling
|
||||
- Memory efficient: No external dependencies
|
||||
|
||||
@@ -235,5 +235,5 @@ Part of the LED Driver project. Use freely in your projects.
|
||||
|
||||
## Demo
|
||||
|
||||
See `color-picker-demo.html` for a live demonstration of the color picker component.
|
||||
See `color-picker-demo.html` for a live demonstration of the colour picker component.
|
||||
|
||||
|
||||
Submodule led-driver updated: 4c7646b2fe...fb53f900fb
@@ -171,9 +171,13 @@ async def send_presets(request, session):
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
async def send_chunk(chunk_presets):
|
||||
# Include save flag so the led-driver can persist when desired.
|
||||
msg = build_message(presets=chunk_presets, save=save_flag, default=default_id)
|
||||
async def send_chunk(chunk_presets, is_last):
|
||||
# Save/default should only be sent with the final presets chunk.
|
||||
msg = build_message(
|
||||
presets=chunk_presets,
|
||||
save=save_flag and is_last,
|
||||
default=default_id if is_last else None,
|
||||
)
|
||||
await sender.send(msg, addr=destination_mac)
|
||||
|
||||
MAX_BYTES = 240
|
||||
@@ -195,7 +199,7 @@ async def send_presets(request, session):
|
||||
last_msg = test_msg
|
||||
else:
|
||||
try:
|
||||
await send_chunk(batch)
|
||||
await send_chunk(batch, False)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
await asyncio.sleep(send_delay_s)
|
||||
@@ -205,7 +209,7 @@ async def send_presets(request, session):
|
||||
|
||||
if batch:
|
||||
try:
|
||||
await send_chunk(batch)
|
||||
await send_chunk(batch, True)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
await asyncio.sleep(send_delay_s)
|
||||
|
||||
@@ -122,22 +122,6 @@ class LightingController {
|
||||
document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette());
|
||||
document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor());
|
||||
|
||||
// Close modals on outside click
|
||||
document.getElementById('add-tab-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'add-tab-modal') this.hideModal('add-tab-modal');
|
||||
});
|
||||
document.getElementById('edit-tab-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'edit-tab-modal') this.hideModal('edit-tab-modal');
|
||||
});
|
||||
document.getElementById('profiles-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'profiles-modal') this.hideModal('profiles-modal');
|
||||
});
|
||||
document.getElementById('presets-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'presets-modal') this.hideModal('presets-modal');
|
||||
});
|
||||
document.getElementById('preset-editor-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'preset-editor-modal') this.hideModal('preset-editor-modal');
|
||||
});
|
||||
}
|
||||
|
||||
renderTabs() {
|
||||
|
||||
@@ -191,9 +191,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Add when the picker closes (user confirms selection).
|
||||
paletteNewColor.addEventListener('change', addSelectedColor);
|
||||
}
|
||||
paletteModal.addEventListener('click', (event) => {
|
||||
if (event.target === paletteModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,14 +18,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (helpModal) {
|
||||
helpModal.addEventListener('click', (event) => {
|
||||
if (event.target === helpModal) {
|
||||
helpModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile main menu: forward clicks to existing header buttons
|
||||
if (mainMenuBtn && mainMenuDropdown) {
|
||||
mainMenuBtn.addEventListener('click', () => {
|
||||
@@ -43,13 +35,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
mainMenuDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!mainMenuDropdown.contains(event.target) && event.target !== mainMenuBtn) {
|
||||
mainMenuDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Settings modal wiring (reusing existing settings endpoints).
|
||||
@@ -121,14 +106,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsModal) {
|
||||
settingsModal.addEventListener('click', (event) => {
|
||||
if (event.target === settingsModal) {
|
||||
settingsModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const deviceForm = document.getElementById('device-form');
|
||||
if (deviceForm) {
|
||||
deviceForm.addEventListener('submit', async (e) => {
|
||||
|
||||
@@ -78,9 +78,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
patternsCloseButton.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
patternsModal.addEventListener('click', (event) => {
|
||||
if (event.target === patternsModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,11 +175,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetDelayInput = document.getElementById('preset-delay-input');
|
||||
const presetDefaultButton = document.getElementById('preset-default-btn');
|
||||
const presetSaveButton = document.getElementById('preset-save-btn');
|
||||
const presetClearButton = document.getElementById('preset-clear-btn');
|
||||
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||||
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-tab-btn');
|
||||
|
||||
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton || !presetClearButton) {
|
||||
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -637,20 +635,55 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return payload;
|
||||
};
|
||||
|
||||
const normalizePatternMap = (raw) => {
|
||||
if (!raw) return {};
|
||||
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
// Support wrapped payloads like { patterns: {...} }.
|
||||
if (raw.patterns && typeof raw.patterns === 'object' && !Array.isArray(raw.patterns)) {
|
||||
return raw.patterns;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
// Support list payloads like [{name: "blink", ...}, ...].
|
||||
return raw.reduce((acc, item, idx) => {
|
||||
if (item && typeof item === 'object') {
|
||||
const name = item.name || item.id || String(idx);
|
||||
acc[String(name)] = item;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const loadPatterns = async () => {
|
||||
if (!presetPatternInput) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Load pattern definitions from pattern.json
|
||||
const response = await fetch('/patterns/definitions', {
|
||||
let patternsPayload = null;
|
||||
let response = await fetch('/patterns/definitions', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
if (response.ok) {
|
||||
patternsPayload = await response.json();
|
||||
}
|
||||
const patterns = await response.json();
|
||||
cachedPatterns = patterns || {};
|
||||
|
||||
let normalized = normalizePatternMap(patternsPayload);
|
||||
if (!Object.keys(normalized).length) {
|
||||
// Fallback when definitions route is unavailable or returns an empty map.
|
||||
response = await fetch('/patterns', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
patternsPayload = await response.json();
|
||||
normalized = normalizePatternMap(patternsPayload);
|
||||
}
|
||||
cachedPatterns = normalized;
|
||||
const entries = Object.keys(cachedPatterns);
|
||||
const desiredPattern = presetPatternInput.value;
|
||||
presetPatternInput.innerHTML = '<option value="">Pattern</option>';
|
||||
@@ -966,12 +999,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to show add preset modal:', error);
|
||||
alert('Failed to load presets.');
|
||||
@@ -1068,7 +1095,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (presetEditorCloseButton) {
|
||||
presetEditorCloseButton.addEventListener('click', closeEditor);
|
||||
}
|
||||
presetClearButton.addEventListener('click', clearForm);
|
||||
if (presetPatternInput) {
|
||||
presetPatternInput.addEventListener('change', () => {
|
||||
updatePresetNLabels(presetPatternInput.value);
|
||||
@@ -1144,9 +1170,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const close = () => modal.remove();
|
||||
modal.querySelector('#pick-palette-close-btn').addEventListener('click', close);
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) close();
|
||||
});
|
||||
|
||||
list.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
@@ -1181,7 +1204,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetSendButton = document.getElementById('preset-send-btn');
|
||||
|
||||
if (presetSendButton) {
|
||||
presetSendButton.addEventListener('click', () => {
|
||||
presetSendButton.addEventListener('click', async () => {
|
||||
const payload = buildPresetPayload();
|
||||
if (!payload.name) {
|
||||
alert('Preset name is required to send.');
|
||||
@@ -1195,10 +1218,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
: [];
|
||||
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
||||
const presetId = currentEditId || payload.name;
|
||||
// First send/override the preset definition under its ID
|
||||
sendPresetViaEspNow(presetId, payload, null, true, false);
|
||||
// Then send a separate select-only message for this preset ID to all devices in the tab
|
||||
sendSelectForCurrentTabDevices(presetId, section);
|
||||
// Try sends preset first, then select; never persist on device.
|
||||
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1238,33 +1259,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('Failed to save preset');
|
||||
}
|
||||
|
||||
// Determine device names from current tab (if any)
|
||||
let deviceNames = [];
|
||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||
if (section) {
|
||||
const namesAttr = section.getAttribute('data-device-names');
|
||||
deviceNames = namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
// Use saved preset from server response for sending
|
||||
const saved = await response.json().catch(() => null);
|
||||
if (saved && typeof saved === 'object') {
|
||||
if (currentEditId) {
|
||||
// PUT returns the preset object directly; use the existing ID
|
||||
sendPresetViaEspNow(currentEditId, saved, deviceNames, true, false);
|
||||
// Save & Send should not force-select the preset on devices.
|
||||
sendPresetViaEspNow(currentEditId, saved, [], true, false);
|
||||
} else {
|
||||
// POST returns { id: preset }
|
||||
const entries = Object.entries(saved);
|
||||
if (entries.length > 0) {
|
||||
const [newId, presetData] = entries[0];
|
||||
sendPresetViaEspNow(newId, presetData, deviceNames, true, false);
|
||||
// Save & Send should not force-select the preset on devices.
|
||||
sendPresetViaEspNow(newId, presetData, [], true, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: send what we just built
|
||||
sendPresetViaEspNow(payload.name, payload, deviceNames, true, false);
|
||||
// Save & Send should not force-select the preset on devices.
|
||||
sendPresetViaEspNow(payload.name, payload, [], true, false);
|
||||
}
|
||||
|
||||
await loadPresets();
|
||||
@@ -1303,40 +1317,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
openEditor();
|
||||
});
|
||||
|
||||
if (presetRemoveFromTabButton) {
|
||||
presetRemoveFromTabButton.addEventListener('click', async () => {
|
||||
if (!currentEditId) {
|
||||
alert('No preset loaded to remove.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await removePresetFromTab(currentEditTabId, currentEditId);
|
||||
closeEditor();
|
||||
} catch (e) {
|
||||
// removePresetFromTab already logs and alerts on error
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
presetsModal.addEventListener('click', (event) => {
|
||||
if (event.target === presetsModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
if (presetEditorModal) {
|
||||
presetEditorModal.addEventListener('click', (event) => {
|
||||
if (event.target === presetEditorModal) {
|
||||
closeEditor();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearForm();
|
||||
});
|
||||
|
||||
// Build an ESPNow preset message for a single preset and optionally include a select
|
||||
// for the given device names, then send it via WebSocket.
|
||||
// Build ESPNow messages for a single preset.
|
||||
// Send order:
|
||||
// 1) preset payload (optionally with save)
|
||||
// 2) optional select for device names (never with save)
|
||||
// saveToDevice defaults to true.
|
||||
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
|
||||
try {
|
||||
@@ -1346,7 +1333,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
|
||||
|
||||
const message = {
|
||||
const presetMessage = {
|
||||
v: '1',
|
||||
presets: {
|
||||
[presetId]: {
|
||||
@@ -1367,14 +1354,16 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
||||
},
|
||||
};
|
||||
if (saveToDevice) {
|
||||
// Instruct led-driver to save this preset when received.
|
||||
message.save = true;
|
||||
presetMessage.save = true;
|
||||
}
|
||||
if (setDefault) {
|
||||
message.default = presetId;
|
||||
presetMessage.default = presetId;
|
||||
}
|
||||
|
||||
// Optionally include a select section for specific devices
|
||||
// 1) Send presets first, without save.
|
||||
sendEspnowMessage(presetMessage);
|
||||
|
||||
// Optionally send a separate select message for specific devices.
|
||||
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
|
||||
const select = {};
|
||||
deviceNames.forEach((name) => {
|
||||
@@ -1383,11 +1372,12 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
||||
}
|
||||
});
|
||||
if (Object.keys(select).length > 0) {
|
||||
message.select = select;
|
||||
// Small gap helps slower receivers process preset update before select.
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
sendEspnowMessage({ v: '1', select });
|
||||
}
|
||||
}
|
||||
|
||||
sendEspnowMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to send preset via ESPNow:', error);
|
||||
alert('Failed to send preset via ESPNow.');
|
||||
@@ -1399,17 +1389,16 @@ const sendDefaultPreset = (presetId, deviceNames) => {
|
||||
alert('Select a preset to set as default.');
|
||||
return;
|
||||
}
|
||||
// Default should only set startup preset, not trigger live selection.
|
||||
// Save is attached to default messages.
|
||||
// When device names are provided, scope the default update to those devices.
|
||||
const targets = Array.isArray(deviceNames)
|
||||
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
const message = { v: '1', default: presetId };
|
||||
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
|
||||
const select = {};
|
||||
deviceNames.forEach((name) => {
|
||||
if (name) {
|
||||
select[name] = [presetId];
|
||||
}
|
||||
});
|
||||
if (Object.keys(select).length > 0) {
|
||||
message.select = select;
|
||||
}
|
||||
message.save = true;
|
||||
if (targets.length > 0) {
|
||||
message.targets = targets;
|
||||
}
|
||||
sendEspnowMessage(message);
|
||||
};
|
||||
@@ -1789,6 +1778,42 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
editPresetFromTab(presetId, tabId, preset);
|
||||
});
|
||||
|
||||
const defaultBtn = document.createElement('button');
|
||||
defaultBtn.type = 'button';
|
||||
defaultBtn.className = 'btn btn-secondary btn-small';
|
||||
defaultBtn.textContent = 'Default';
|
||||
defaultBtn.title = 'Set as default preset';
|
||||
defaultBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDraggingPreset) return;
|
||||
const section = row.closest('.presets-section');
|
||||
const namesAttr = section && section.getAttribute('data-device-names');
|
||||
const deviceNames = namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
sendDefaultPreset(presetId, deviceNames);
|
||||
// Persist tab-level default if we know the tab from this tile.
|
||||
if (tabId) {
|
||||
try {
|
||||
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (tabResponse.ok) {
|
||||
const tabData = await tabResponse.json();
|
||||
tabData.default_preset = presetId;
|
||||
await fetch(`/tabs/${tabId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to save tab default preset:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'btn btn-danger btn-small';
|
||||
@@ -1803,6 +1828,7 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(defaultBtn);
|
||||
actions.appendChild(removeBtn);
|
||||
row.appendChild(actions);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEditModeActive = () => {
|
||||
const toggle = document.querySelector('.ui-mode-toggle');
|
||||
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
|
||||
};
|
||||
|
||||
const updateProfileEditorControlsVisibility = () => {
|
||||
const editMode = isEditModeActive();
|
||||
const actions = profilesModal.querySelector('.profiles-actions');
|
||||
if (actions) {
|
||||
actions.style.display = editMode ? '' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
profilesModal.classList.add("active");
|
||||
updateProfileEditorControlsVisibility();
|
||||
loadProfiles();
|
||||
};
|
||||
|
||||
@@ -54,6 +68,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const editMode = isEditModeActive();
|
||||
entries.forEach(([profileId, profile]) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "profiles-row";
|
||||
@@ -161,8 +176,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(applyButton);
|
||||
row.appendChild(cloneButton);
|
||||
row.appendChild(deleteButton);
|
||||
if (editMode) {
|
||||
row.appendChild(cloneButton);
|
||||
row.appendChild(deleteButton);
|
||||
}
|
||||
profilesList.appendChild(row);
|
||||
});
|
||||
};
|
||||
@@ -197,6 +214,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
};
|
||||
|
||||
const createProfile = async () => {
|
||||
if (!isEditModeActive()) {
|
||||
return;
|
||||
}
|
||||
if (!newProfileInput) {
|
||||
return;
|
||||
}
|
||||
@@ -264,9 +284,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
profilesModal.addEventListener("click", (event) => {
|
||||
if (event.target === profilesModal) {
|
||||
closeModal();
|
||||
}
|
||||
// Keep modal controls in sync with run/edit mode.
|
||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (profilesModal.classList.contains('active')) {
|
||||
updateProfileEditorControlsVisibility();
|
||||
loadProfiles();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -621,21 +621,23 @@ body.preset-ui-run .edit-mode-only {
|
||||
}
|
||||
|
||||
.preset-tile-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 0.2rem;
|
||||
justify-content: center;
|
||||
align-content: stretch;
|
||||
flex-shrink: 0;
|
||||
padding: 0.15rem 0 0.15rem 0.25rem;
|
||||
width: 6.5rem;
|
||||
}
|
||||
|
||||
.preset-tile-actions .btn {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
min-height: 2.35rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ui-mode-toggle--edit {
|
||||
|
||||
@@ -354,7 +354,7 @@ async function loadTabContent(tabId) {
|
||||
brightnessSendTimeout = setTimeout(() => {
|
||||
if (typeof window.sendEspnowRaw === 'function') {
|
||||
try {
|
||||
window.sendEspnowRaw({ v: '1', b: val });
|
||||
window.sendEspnowRaw({ v: '1', b: val, save: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to send brightness via ESPNow:', err);
|
||||
}
|
||||
@@ -715,14 +715,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (tabsModal) {
|
||||
tabsModal.addEventListener('click', (event) => {
|
||||
if (event.target === tabsModal) {
|
||||
tabsModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Right-click on a tab button in the main header bar to edit that tab
|
||||
document.addEventListener('contextmenu', async (event) => {
|
||||
if (!isEditModeActive()) {
|
||||
@@ -793,16 +785,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Close edit modal when clicking outside
|
||||
const editTabModal = document.getElementById('edit-tab-modal');
|
||||
if (editTabModal) {
|
||||
editTabModal.addEventListener('click', (event) => {
|
||||
if (event.target === editTabModal) {
|
||||
editTabModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Profile-wide "Send Presets" button in header
|
||||
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
|
||||
if (sendProfilePresetsBtn) {
|
||||
|
||||
@@ -15,13 +15,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Color Palette</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary" id="settings-btn">Settings</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||
</div>
|
||||
@@ -29,13 +28,12 @@
|
||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
|
||||
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Color Palette</button>
|
||||
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
|
||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||
<button type="button" class="edit-mode-only" data-target="profiles-btn">Profiles</button>
|
||||
<button type="button" data-target="settings-btn">Settings</button>
|
||||
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
||||
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
||||
<button type="button" data-target="help-btn">Help</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,10 +129,10 @@
|
||||
<option value="">Pattern</option>
|
||||
</select>
|
||||
</div>
|
||||
<label>Colors</label>
|
||||
<label>Colours</label>
|
||||
<div id="preset-colors-container" class="preset-colors-container"></div>
|
||||
<div class="profiles-actions">
|
||||
<input type="color" id="preset-new-color" value="#ffffff" title="Choose color (auto-adds)">
|
||||
<input type="color" id="preset-new-color" value="#ffffff" title="Choose colour (auto-adds)">
|
||||
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
@@ -185,8 +183,6 @@
|
||||
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
|
||||
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
|
||||
<button class="btn btn-primary" id="preset-save-btn">Save & Send</button>
|
||||
<button class="btn btn-danger" id="preset-remove-from-tab-btn">Remove from Tab</button>
|
||||
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
|
||||
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,10 +199,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color Palette Modal -->
|
||||
<!-- Colour Palette Modal -->
|
||||
<div id="color-palette-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Color Palette</h2>
|
||||
<h2>Colour Palette</h2>
|
||||
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
|
||||
<div id="palette-container" class="profiles-list"></div>
|
||||
<div class="profiles-actions">
|
||||
@@ -224,27 +220,23 @@
|
||||
<h2>Help</h2>
|
||||
<p class="muted-text">How to use the LED controller UI.</p>
|
||||
|
||||
<h3>Tabs & devices</h3>
|
||||
<h3>Run mode</h3>
|
||||
<ul>
|
||||
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
|
||||
<li><strong>Edit tab</strong>: right-click a tab button, or click <strong>Edit</strong> in the Tabs modal.</li>
|
||||
<li><strong>Send all presets</strong>: open the <strong>Tabs</strong> menu and click <strong>Send Presets</strong> next to the tab to push every preset used in that tab to all devices.</li>
|
||||
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the tab.</li>
|
||||
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
|
||||
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current tab to all tab devices.</li>
|
||||
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Presets in a tab</h3>
|
||||
<h3>Edit mode</h3>
|
||||
<ul>
|
||||
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
|
||||
<li><strong>Run vs Edit mode</strong>: use the mode button in the menu (it shows the mode you will <em>switch to</em>). In <strong>Edit mode</strong>, each preset tile shows <strong>Edit</strong> and <strong>Remove</strong> on the right.</li>
|
||||
<li><strong>Edit preset</strong>: switch to <strong>Edit mode</strong> (menu button) and use <strong>Edit</strong> on each tile.</li>
|
||||
<li><strong>Remove from tab</strong>: in <strong>Edit mode</strong>, use <strong>Remove</strong> on the tile (the preset itself is not deleted, only its link from this tab).</li>
|
||||
<li><strong>Reorder presets</strong>: in <strong>Edit mode</strong> only, drag tiles to change order; the layout saves automatically.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Presets, profiles & colors</h3>
|
||||
<ul>
|
||||
<li><strong>Presets</strong>: use the <strong>Presets</strong> button in the header to create and manage reusable presets.</li>
|
||||
<li><strong>Profiles</strong>: use <strong>Profiles</strong> to save and recall groups of settings.</li>
|
||||
<li><strong>Color Palette</strong>: use <strong>Color Palette</strong> to build a reusable set of colors you can pull into presets.</li>
|
||||
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
|
||||
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
|
||||
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
|
||||
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab order.</li>
|
||||
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> tab and can optionally seed a <strong>DJ tab</strong>.</li>
|
||||
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
|
||||
</ul>
|
||||
|
||||
<div class="modal-actions">
|
||||
|
||||
@@ -74,7 +74,7 @@ See `docs/API.md` for the complete ESPNow API specification.
|
||||
## Key Features
|
||||
|
||||
- **Version Field**: All messages include `"v": "1"` for version tracking
|
||||
- **Preset Format**: Presets use hex color strings (`#RRGGBB`), not RGB tuples
|
||||
- **Preset Format**: Presets use hex colour strings (`#RRGGBB`), not RGB tuples
|
||||
- **Select Format**: Select values are always lists: `["preset_name"]` or `["preset_name", step]`
|
||||
- **Color Conversion**: Automatically converts RGB tuples to hex strings
|
||||
- **Colour Conversion**: Automatically converts RGB tuples to hex strings
|
||||
- **Default Values**: Provides sensible defaults for missing fields
|
||||
|
||||
Reference in New Issue
Block a user