Compare commits
141 Commits
2fa02086c9
...
beta-1.01
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d6ef5c7b4 | |||
| 78a4ce009c | |||
| 7ccab6fbc4 | |||
|
|
827eb97203 | ||
|
|
3cca0cffc5 | ||
|
|
d36828bde2 | ||
|
|
ed0048c795 | ||
|
|
b316edbaf9 | ||
| c1b0c41ef2 | |||
| 3bb75d49de | |||
| 3d77cb448a | |||
| 49383c0003 | |||
| 7d821b9c1c | |||
| 9b7e387ea6 | |||
| b4f0d1891e | |||
| 0da30b6d6b | |||
| 6cbb728d9a | |||
| ff92451a76 | |||
| 60485bc06a | |||
| f6f299c3e5 | |||
| 66485f5c59 | |||
| 5f9ff9bcc9 | |||
| 35730b36f0 | |||
| d516833cc3 | |||
| 220be64dec | |||
| b433477c64 | |||
| 43b7047c57 | |||
| 167417d1ec | |||
| fb8141b320 | |||
| 96712dda88 | |||
| f5a7b42e7c | |||
| 1b1e9d727e | |||
| 668d29b786 | |||
| e5f42e099e | |||
| a9edda38ef | |||
| edec5ff460 | |||
|
|
264eb7296f | ||
|
|
fbd4295302 | ||
|
|
7bdb324ebc | ||
|
|
28b19b5219 | ||
|
|
75ddd559c9 | ||
|
|
5a1067263a | ||
|
|
e67de6215a | ||
|
|
7179b6531e | ||
|
|
fd618d7714 | ||
|
|
d1ffb857c8 | ||
|
|
f8eba0ee7e | ||
|
|
e6b5bf2cf1 | ||
|
|
fbae75b957 | ||
|
|
93476655fc | ||
|
|
09a87b79d2 | ||
|
|
ec39df00fc | ||
|
|
43d494bcb9 | ||
|
|
fed312a397 | ||
| 63235c7822 | |||
| 5badf17719 | |||
| 4597573ac5 | |||
| 1550122ced | |||
| b7c45fd72c | |||
| 9479d0d292 | |||
| 3698385af4 | |||
| ef968ebe39 | |||
| a5432db99a | |||
| 764d918d5b | |||
| edadb40cb6 | |||
| 9323719a85 | |||
| 91de705647 | |||
| 3ee7b74152 | |||
| 98bbdcbb3d | |||
| a2abd3e833 | |||
| 550217c443 | |||
| 2d2032e8b9 | |||
| 81bf4dded5 | |||
| a75e27e3d2 | |||
| 13538c39a6 | |||
| 7b724e9ce1 | |||
| aaca5435e9 | |||
| b64dacc1c3 | |||
| 8689bdb6ef | |||
| c178e87966 | |||
| dfe7ae50d2 | |||
| 8e87559af6 | |||
| aa3546e9ac | |||
| b56af23cbf | |||
| ac9fca8d4b | |||
| 0fdc11c0b0 | |||
| 91bd78ab31 | |||
| 2be0640622 | |||
| 0e96223bf6 | |||
| d8b33923d5 | |||
| 4ce515be1c | |||
| f88bf03939 | |||
| 7cd4a91350 | |||
| d907ca37ad | |||
| 6c6ed22dbe | |||
| 00514f0525 | |||
| cf1d831b5a | |||
| fd37183400 | |||
| 5fdeb57b74 | |||
| 1576383d09 | |||
| 8503315bef | |||
| 928263fbd8 | |||
| 7e33f7db6a | |||
| e74ef6d64f | |||
| 3ed435824c | |||
| d7fabf58a4 | |||
| a7e921805a | |||
| c56739c5fa | |||
| fd52e40d17 | |||
| f48c8789c7 | |||
| 80ff216e54 | |||
| 1fb3dee942 | |||
| a4502055fb | |||
| 6e61ec8de6 | |||
| 48d02f0e70 | |||
| cacaa3505e | |||
| 97ffc69b12 | |||
| 9f37dbbff0 | |||
| df37f15f73 | |||
| 9c43a0a22b | |||
| d41faddfca | |||
| 9e2409430c | |||
| 5f6e45af09 | |||
| cccda24448 | |||
| 5cca60d830 | |||
| ac750a36e7 | |||
| 01f373f0bd | |||
| d00d21e2b6 | |||
| deca1b6c37 | |||
| 5c35e68ab2 | |||
| 8b6bbdeb56 | |||
| 09bc09cca3 | |||
| e57feda131 | |||
| 3242aa464b | |||
| 72b7ba39ef | |||
| c2a0cfaef4 | |||
| 4c3337a232 | |||
| 825ae1f637 | |||
| 14a70cb024 | |||
| 425511d41f | |||
| 3e5239f3c6 |
116
.cursor/debug.log
Normal 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
@@ -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`.
|
||||||
45
.cursor/rules/led-driver.mdc
Normal 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/`**.
|
||||||
12
.cursor/rules/pattern-workflow.mdc
Normal 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.
|
||||||
18
.cursor/rules/scoped-fixes.mdc
Normal 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 user’s *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.
|
||||||
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.
|
||||||
16
.cursor/rules/strict-user-scope.mdc
Normal 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.
|
||||||
18
.cursor/rules/submodules-led-driver-tool.mdc
Normal 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
@@ -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
@@ -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
@@ -7,8 +7,26 @@ name = "pypi"
|
|||||||
mpremote = "*"
|
mpremote = "*"
|
||||||
pyserial = "*"
|
pyserial = "*"
|
||||||
esptool = "*"
|
esptool = "*"
|
||||||
|
pyjwt = "*"
|
||||||
|
watchfiles = "*"
|
||||||
|
requests = "*"
|
||||||
|
selenium = "*"
|
||||||
|
adafruit-ampy = "*"
|
||||||
|
microdot = "*"
|
||||||
|
websockets = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
pytest = "*"
|
||||||
|
|
||||||
[requires]
|
[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
41
README.md
@@ -1,2 +1,43 @@
|
|||||||
# led-controller
|
# 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
@@ -0,0 +1 @@
|
|||||||
|
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}
|
||||||
1
db/palette.json
Normal 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
@@ -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 10–30 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1–255, higher = more changes)", "n2": "Density (0–255, 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
BIN
db/presets/1.bin
Normal file
3
db/presets/10.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœ%ÎÁ
|
||||||
|
Â0Ð_‘ñšCSµJîæ'D$¶«
|
||||||
|
ÄÝ’¦ˆˆÿntOovæ²opxz‘´zޱ¦P
|
||||||
2
db/presets/11.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xњ%ОAВ …б»<·,J5\Е4
|
||||||
|
К $84SX4Ж»‹eхеНlюШЅ B
|
||||||
1
db/presets/12.bin
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PRST1xœ%ÎA л|·, ŠÐK˜ÆP;*
|
||||||
2
db/presets/13.bin
Normal 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
@@ -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
BIN
db/presets/2.bin
Normal file
2
db/presets/3.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœUÎÁ
|
||||||
|
Â0ЙsM5Uò+"²µ«â¦lSDÄwiNž³3‡ý@èɈPJ2–fª•Uþn×’‹.ˆ§³Ã¨éþ¨Â‹å>‡‰3½}×9ÐZbÕ•ÄÛÀè‘]cß<08>¡qh7f-·”ù’&ûÁãûF9/.
|
||||||
2
db/presets/30.bin
Normal 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
2
db/presets/32.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ%ͽÂ0àW©Ž5C~•&VÆ
|
||||||
|
¡@<40>)uª4K…xwR<}ç»Á° —ks<DjÎ)¦…É•B™ë–¸ž¯µža;l¼×Ú{Üž9ïÂ4×ÁÐStl«kævÅ[a'ì…ƒpN¦œ|ˆô}ýmðý‡-‰
|
||||||
1
db/presets/33.bin
Normal 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
2
db/presets/35.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ%ͽÂ0àW©Ž5C~•&VÆ
|
||||||
|
¡@<40>)uª4K…xwR<}ç»Á° —ks<DjÎ)¦…É•B™ë–¸ž¯µža;l¼×Ú{Üž9ïÂ4×ÁÐStl«kævÅ[a'ì…ƒpN¦œ|ˆô}ýmðý‡-‰
|
||||||
1
db/presets/36.bin
Normal 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
BIN
db/presets/38.bin
Normal file
3
db/presets/39.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœUÎÁ‚0„áw¯=¤jú*†<>
|
||||||
|
[m\[²”ƒ1¾»…ž<}ÉÌåÿ ºÁÂsŸ$P˜]Î$ño'Y`¯88ÒÚ{ô
|
||||||
|
7 ÷GŽ´”£5Fa"voX£Üšl–•bÛè2ÆvãXé*¦rªœ+—<>Y’LC˜JM³·1•ºAÈo5qeî¿?ªð9±
|
||||||
BIN
db/presets/4.bin
Normal file
4
db/presets/40.bin
Normal 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
@@ -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
@@ -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
2
db/presets/44.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœEÎM
|
||||||
|
Â0à«Ès›Eÿ¢’ôE$¶£â¤$Ó…ˆww0góÁ{o1o°„ŠìÊì™)Ã`õ"”Y‹6§˜r<CB9C>›°ÇFgƒk÷‡0-:k
|
||||||
3
db/presets/45.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœ=ŽA‚0E¯B>Û.
|
||||||
|
*š€KC*ŒØ¤¶¤Æxw<1B>Í{™7‹y!ØÁ€)s5';9
|
||||||
|
\å1Eï¡°XfJA~mø·1ú˜2ÌußkÙÕZo^ls\®ÉÍw”å¸mµÂDÞ>a:Q»r„á´’Bh¤Z)aW°/8tÇ‚ÓKŠ7çip“üÙàý)<¡
|
||||||
3
db/presets/46.bin
Normal 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
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1x<EFBFBD>5־A‚0…ב«<D791>ַ¶@Dׂ- —0ֶT©<54>X[2ֶxwG׳ש&»˜‚yXh°M\₪<>׀<EFBFBD><D780>‚ֹ8…<>0[
|
||||||
|
’ור/חט#%ט=ֺ¾†q”·r\…¹כ<C2B9>ƒMע¥©*…ֹzף„מd5Gh¦ֵ*„Zz+6b-1l ¿´™m¦ֻל2ֺLסגה"7ֹy5<79>־ד:G
|
||||||
2
db/presets/48.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ-ÎÁ Ð_1ã•ÔZŽúÆ´«’ 4°Õã¿»Š§7;sÙ¢»,˜
|
||||||
|
/îNP˜3å(í¿8¥<38>r<EFBFBD>Ýa©õ¶ìŽÙ_®©ÈÐh0RpOØN¢›9ÁržI!XÓˆ<C393>ØËW„ö{+]eSéL9<4C>} ƒåƒ÷ªù0¿
|
||||||
2
db/presets/49.bin
Normal 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>1И
|
||||||
BIN
db/presets/5.bin
Normal file
2
db/presets/50.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ5ÎA‚0Ы<C390>϶‹‚ˆ¦è%Œ!F <20>–´ÃÂïîhu6o2ÿ/æ ïV‚Sâ"Ѹ’碟\"(lŽ™¢—ø—tÿ¤Kˆ æ‚ÒZ-#·ò£µ¸*Üâ<Nì)I¥ÖZa Å=`ZYÝΆãN
|
||||||
|
¾‚i„¦0RðMæ˜i3§ÌùËÃ}^¨›ùÂë
|
||||||
BIN
db/presets/51.bin
Normal file
BIN
db/presets/52.bin
Normal file
2
db/presets/53.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ5Î=Â0†á«Tk†þQ<C3BE>À%*T%Ô@¥’TŽ; ÄÝIáå±ôzðÞ¾å¨ET Ž·JT,V•ŧšÃð·0‰ ‡Ë>¸8™OõS¨ËÒ`äÙ¾A]Zíª¤²²<C2B2>¯@M¢ÎÉ7 v;÷-hã˜é2§Ìyg‘pŸf¦1ýTáû^
|
||||||
|
7˜
|
||||||
3
db/presets/54.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1x<EFBFBD>5ΞΝ
|
||||||
|
Β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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
PRST1xœ5ŽAƒ E¯b¾[¨U+WiŒ¡2¶¦`š¦éÝ’nxÌ›Y¼Œ|ù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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ=ÎÍ
|
||||||
|
Â0àW‘é5‡ô?ìM"} ‰vÕBMJ’D|wSž¾afû†5O!rˆ;³zç
|
||||||
3
db/presets/59.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1x°Mна
|
||||||
|
б0ЮW▒вз╘SzTЯ%D╓╨L╣m├┬ЬНfКе\╬ДOЫ ╦'а┌)С"┤ЬЙ°ВP3╔ ⌡©П}LЖ└Й8≈dуNЖр²╝╘©?8P√⌠Zk┘√╪{ц6р╨▒#,╖▒┌≥Жb
|
||||||
|
k└%Л4╜
|
||||||
2
db/presets/6.bin
Normal 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ìûSŠøèÇù’Æë
|
||||||
4
db/presets/60.bin
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
PRST1xœMÎA‚0Ы˜ï¶‹RÉ€KcŠŒBR[Òc¼»l\½Éÿùɼáí“ANr˜ÙFÙ
|
||||||
|
V+ÂÑçê?½b
|
||||||
|
8ö½éj<EFBFBD>‹Â—Ç,žS.ŒÖ
|
||||||
|
;ûµù´›<04>Ä<EFBFBD>|ªL½uŨ)_ƒ
|
||||||
BIN
db/presets/61.bin
Normal file
3
db/presets/62.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœ5ŽA‚0E¯B>Û.
|
||||||
|
*š€KCªŒBRÚ¦c¼»ÅÙ¼7óÿb>ðv"0Í\D눙Š)¤8@!ZÙ’—xOºò.¤æŠ²mµŒÜJW϶:n
|
||||||
|
÷4¾ö4K¹ÖZ¡'gß0<C39F>¨]8ÀpZHÁW0ÕVðõÞô˜ÇŒSF“qθlˆ)<GGÝØË«¾?ð¹<
|
||||||
3
db/presets/7.bin
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PRST1xœMŽ1Â0Eïò»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
2
db/presets/9.bin
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PRST1xœ%ÎK
|
||||||
|
Ã0Ы”éÖ‹$ýâ«”ÜFnŽ›PJï^ÇÖæI£Í|Áf&hlFæÃ6¹HPXLŒ$œãÀù|d…~àhË WxŠ{O‘iÍ<69>®iFòæÝî»I1@GI¤À-tޏ«œ*çÊ¥rÜ*÷Â"Á:Oƒs<>¶´ò”{
|
||||||
1
db/profile.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 driver’s 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 Wi‑Fi 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 Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
|
||||||
|
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). 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 profile’s 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 0–255 (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 | 0–255; 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 app’s **`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 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
114
docs/help.md
Normal 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 device’s 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*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 zone’s 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 device’s **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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*The slider controls global brightness for the zone’s 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 **n1–n8** 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 profile’s palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
|
||||||
|
- **Brightness (0–255)** 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 zone’s **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 zone’s list only**; the preset remains in the profile for other zones.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*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 zone’s preset IDs, and calls **`POST /presets/send`** per zone (including each zone’s **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 profile’s** palette swatches. Those colours are reused by **From Palette** in the preset editor and stay in sync while the **P** link remains.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*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.).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*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
14
docs/images/help/colour-palette.svg
Normal 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 |
24
docs/images/help/header-toolbar.svg
Normal 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 |
26
docs/images/help/mobile-menu.svg
Normal 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 |
31
docs/images/help/preset-editor.svg
Normal 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 |
35
docs/images/help/tab-preset-strip.svg
Normal 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 |
239
docs/mockups/COLOR_PICKER_README.md
Normal 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
@@ -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)
|
||||||
|
|
||||||
210
docs/mockups/color-picker-chromium-demo.html
Normal 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>
|
||||||
|
|
||||||
253
docs/mockups/color-picker-chromium.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
452
docs/mockups/color-picker-chromium.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
153
docs/mockups/color-picker-demo.html
Normal 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>
|
||||||
|
|
||||||
282
docs/mockups/color-picker.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
474
docs/mockups/color-picker.js
Normal 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
@@ -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>
|
||||||
|
|
||||||
418
docs/mockups/device-management.html
Normal 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
@@ -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()
|
||||||
|
|
||||||
BIN
docs/mockups/images/color-picker-demo.png
Normal file
|
After Width: | Height: | Size: 625 KiB |
BIN
docs/mockups/images/dashboard.png
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
docs/mockups/images/device-management.png
Normal file
|
After Width: | Height: | Size: 585 KiB |
BIN
docs/mockups/images/index.png
Normal file
|
After Width: | Height: | Size: 714 KiB |
BIN
docs/mockups/images/pattern-selector.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
docs/mockups/images/presets.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
docs/mockups/images/settings.png
Normal file
|
After Width: | Height: | Size: 904 KiB |
136
docs/mockups/index.html
Normal 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>
|
||||||
|
|
||||||
310
docs/mockups/pattern-selector.html
Normal 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
@@ -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
@@ -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>
|
||||||
|
|
||||||