OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / bc36fba

webui 0.2.1: Swatches tab in the colour picker
One tap instead of the wheel: a palette tuned for the fixtures (warm/cool white, candle, amber, the usual hues -- LED drive values, not screen RGB), Recent (last 8 applied, per browser) and My swatches (+ Save current, Edit to remove; per browser until phase 3 shares them). A tap keeps the slot's brightness, or uses full if the slot was off, and goes through the normal change path, so proto-3 units get grab / frame / release. Tests: swatch list logic (4), 60/60 total. Chromium over CDP against a fake proto-3 unit: 8/8 -- tapping Red sent grant, `slot 0 ... 0 0 30000`, release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bc36fba98bb90be5425bb351c5da0583cb63a002
by plankatron <fleeky@gmail.com> · 2026-10-02T15:26:48.000Z

Changed files (9) +252 -4

webui/lib/server.js +2 -1
……@@ -16,7 +16,8 @@ const ASSETS = {
1616 '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
1717 '/app.css': ['app.css', 'text/css; charset=utf-8'],
1818 '/color.js': ['color.js', 'text/javascript; charset=utf-8'],
19 '/picker.js': ['picker.js', 'text/javascript; charset=utf-8']
19 '/picker.js': ['picker.js', 'text/javascript; charset=utf-8'],
20 '/swatches.js': ['swatches.js', 'text/javascript; charset=utf-8']
2021}
2122const MAX_BODY = 64 * 1024
2223const PING_MS = 15000
webui/package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "cthuloid-web",
3 "version": "0.2.0",
3 "version": "0.2.1",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "cthuloid-web",
9 "version": "0.2.0",
9 "version": "0.2.1",
1010 "dependencies": {
1111 "bare-events": "^2.9.2",
1212 "bare-fs": "^4.8.1",
webui/package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "cthuloid-web",
3 "version": "0.2.0",
3 "version": "0.2.1",
44 "description": "Web control for cthuloid ESP32 light units (esp324xlight) \u2014 discovers units over mDNS and drives them from a browser on the LAN.",
55 "type": "module",
66 "main": "index.js",
webui/static/app.css +7 -0
……@@ -140,3 +140,10 @@ main { padding: 0 16px 2rem; max-width: 980px; margin: 0 auto; }
140140.slot.held-other .slot-row { opacity: .55; }
141141.slot-holder { margin: .15rem 0 0 3.7rem; color: var(--warn); }
142142.picker.disabled { opacity: .55; pointer-events: none; }
143
144/* Picker: Swatches tab */
145.pk-sw-title { color: var(--muted); margin: .35rem 0 .2rem; }
146.pk-swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(2rem, 1fr)); gap: .35rem; }
147.pk-swatch { aspect-ratio: 1; padding: 0; border: 1px solid var(--line); border-radius: 6px; min-height: 2rem; }
148.pk-swatch.removing { color: #fff; font-weight: 700; text-shadow: 0 0 3px #000; }
149.pk-sw-actions { display: flex; gap: .5rem; margin-top: .5rem; }
webui/static/picker.js +90 -0
……@@ -10,6 +10,20 @@
1010// slot's grant for exactly as long as the pointer is down.
1111
1212import { hsvToRgb, rgbToHsv, toHex, fromHex } from './color.js'
13import { PALETTE, pushRecent, addSwatch, removeSwatch } from './swatches.js'
14
15// Recent and saved swatches, per browser, shared by every picker on the page.
16// Storage may be unavailable (private mode): then they last for this page load.
17const mem = {}
18function load (key) {
19 try { return JSON.parse(localStorage.getItem(key) || '[]') } catch { return mem[key] || [] }
20}
21function save (key, list) {
22 mem[key] = list
23 try { localStorage.setItem(key, JSON.stringify(list)) } catch {}
24}
25const RECENT = 'cthuloid.recent'
26const MINE = 'cthuloid.swatches'
1327
1428const WHEEL_CSS = 184 // px
1529const TAU = Math.PI * 2
……@@ -55,6 +69,7 @@ export class Picker {
5569 this.v = 1
5670 this.dragging = false
5771 this.tab = 'rgb'
72 this.editing = false // Swatches tab: show remove buttons on saved swatches
5873
5974 root.classList.add('picker')
6075 root.innerHTML = `
……@@ -66,6 +81,7 @@ export class Picker {
6681 <button type="button" data-tab="rgb">RGB</button>
6782 <button type="button" data-tab="hsv">HSV</button>
6883 <button type="button" data-tab="hex">Hex</button>
84 <button type="button" data-tab="swatches">Swatches</button>
6985 </div>
7086 <div class="pk-fields"></div>`
7187 this.root = root
……@@ -121,6 +137,7 @@ export class Picker {
121137 if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
122138 if (this.dragging) {
123139 this.dragging = false
140 this._remember()
124141 this.onEnd()
125142 }
126143 }
……@@ -156,6 +173,7 @@ export class Picker {
156173 b.classList.toggle('on', b.dataset.tab === this.tab)
157174 }
158175 this.fields.innerHTML = ''
176 if (this.tab === 'swatches') return this._buildSwatches()
159177 if (this.tab === 'hex') {
160178 const row = document.createElement('label')
161179 row.className = 'pk-row'
……@@ -198,6 +216,77 @@ export class Picker {
198216 this._syncFields()
199217 }
200218
219 // ---- swatches -------------------------------------------------------------
220
221 _color () {
222 return hsvToRgb(this.h, this.s, 1) // full-drive colour; brightness is separate
223 }
224
225 _remember () {
226 save(RECENT, pushRecent(load(RECENT), this._color()))
227 }
228
229 // One tap: take the swatch's colour, keep the slot's brightness (or full, if
230 // it was off), and send it like any other change.
231 _apply (c) {
232 const hsv = rgbToHsv(c.r, c.g, c.b)
233 if (hsv.s > 0) this.h = hsv.h
234 this.s = hsv.s
235 if (this.v === 0) this.v = 1
236 this._changed()
237 this._remember()
238 if (this.tab === 'swatches') this._buildSwatches()
239 }
240
241 _buildSwatches () {
242 const f = this.fields
243 f.innerHTML = ''
244 const section = (title, list, { removable = false } = {}) => {
245 const h = document.createElement('div')
246 h.className = 'pk-sw-title small'
247 h.textContent = title
248 const grid = document.createElement('div')
249 grid.className = 'pk-swatches'
250 list.forEach((c, i) => {
251 const b = document.createElement('button')
252 b.type = 'button'
253 b.className = 'pk-swatch'
254 b.style.background = toHex(c)
255 b.title = c.name || toHex(c)
256 b.setAttribute('aria-label', b.title)
257 if (removable && this.editing) {
258 b.classList.add('removing')
259 b.textContent = '×'
260 b.onclick = () => { save(MINE, removeSwatch(load(MINE), i)); this._buildSwatches() }
261 } else {
262 b.onclick = () => this._apply(c)
263 }
264 b.disabled = !this.enabled
265 grid.appendChild(b)
266 })
267 f.append(h, grid)
268 }
269 section('Palette', PALETTE)
270 const recent = load(RECENT)
271 if (recent.length) section('Recent', recent)
272 const mine = load(MINE)
273 section('My swatches', mine, { removable: true })
274 const row = document.createElement('div')
275 row.className = 'pk-sw-actions'
276 const add = document.createElement('button')
277 add.type = 'button'
278 add.textContent = '+ Save current'
279 add.onclick = () => { save(MINE, addSwatch(load(MINE), this._color())); this._buildSwatches() }
280 const edit = document.createElement('button')
281 edit.type = 'button'
282 edit.textContent = this.editing ? 'Done' : 'Edit'
283 edit.hidden = mine.length === 0 && !this.editing
284 edit.onclick = () => { this.editing = !this.editing; this._buildSwatches() }
285 add.disabled = !this.enabled
286 row.append(add, edit)
287 f.appendChild(row)
288 }
289
201290 _setRgb (r, g, b) {
202291 const hsv = rgbToHsv(r, g, b)
203292 // Grey or black has no hue: keep the one we had so the wheel doesn't jump.
……@@ -208,6 +297,7 @@ export class Picker {
208297 }
209298
210299 _syncFields () {
300 if (this.tab === 'swatches') return
211301 const active = document.activeElement
212302 if (this.tab === 'hex') {
213303 const input = this.fields.querySelector('.pk-hex')
webui/static/swatches.js +53 -0
……@@ -0,0 +1,53 @@
1// Swatches for the picker's "Swatches" tab: a built-in palette for lights, the
2// last colours you applied, and your own saved ones. Colours are { r, g, b } at
3// full drive (brightest channel = 1); applying one keeps the slot's brightness.
4// Pure functions, tested in test/swatches.test.js.
5//
6// Recent and saved swatches live in this browser for now; phase 3 moves the
7// saved ones into the rig's shared state (docs/DESIGN-collab.md).
8
9// Values are LED drive levels (linear), not screen colours: "warm white" here
10// is what looks warm on the fixtures, which is redder than the same RGB on a
11// monitor.
12export const PALETTE = [
13 { name: 'White', r: 1, g: 1, b: 1 },
14 { name: 'Warm white', r: 1, g: 0.75, b: 0.45 },
15 { name: 'Cool white', r: 0.75, g: 0.85, b: 1 },
16 { name: 'Candle', r: 1, g: 0.5, b: 0.15 },
17 { name: 'Amber', r: 1, g: 0.6, b: 0 },
18 { name: 'Orange', r: 1, g: 0.35, b: 0 },
19 { name: 'Red', r: 1, g: 0, b: 0 },
20 { name: 'Pink', r: 1, g: 0.3, b: 0.6 },
21 { name: 'Magenta', r: 1, g: 0, b: 1 },
22 { name: 'Purple', r: 0.6, g: 0, b: 1 },
23 { name: 'Indigo', r: 0.3, g: 0, b: 1 },
24 { name: 'Blue', r: 0, g: 0, b: 1 },
25 { name: 'Sky', r: 0, g: 0.6, b: 1 },
26 { name: 'Cyan', r: 0, g: 1, b: 1 },
27 { name: 'Teal', r: 0, g: 1, b: 0.6 },
28 { name: 'Green', r: 0, g: 1, b: 0 },
29 { name: 'Lime', r: 0.5, g: 1, b: 0 },
30 { name: 'Yellow', r: 1, g: 1, b: 0 }
31]
32
33export const RECENT_MAX = 8
34
35const strip = (c) => ({ r: c.r, g: c.g, b: c.b })
36
37// Within one 8-bit step: colours that look the same are the same swatch.
38export function sameColor (a, b) {
39 const d = 1 / 255
40 return Math.abs(a.r - b.r) <= d && Math.abs(a.g - b.g) <= d && Math.abs(a.b - b.b) <= d
41}
42
43export function pushRecent (list, color) {
44 return [strip(color), ...list.filter((c) => !sameColor(c, color))].slice(0, RECENT_MAX)
45}
46
47export function addSwatch (list, color) {
48 return list.some((c) => sameColor(c, color)) ? list : [...list, strip(color)]
49}
50
51export function removeSwatch (list, index) {
52 return list.filter((_, i) => i !== index)
53}
webui/test/browser-swatches.mjs +63 -0
……@@ -0,0 +1,63 @@
1// Manual browser test (Node + Chromium over CDP), not part of `npm test`; same
2// setup as browser-shared-control.mjs (fake proto-3 unit on :1339, server :4052).
3// Swatches tab in real Chromium: tap Red -> unit gets grant/slot/release;
4// save current -> appears under My swatches; edit -> remove it.
5import { spawn } from 'node:child_process'
6import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'
7
8const OUT = process.argv[2]
9const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
10const results = []
11const check = (name, ok, detail = '') => { results.push(ok); console.log((ok ? 'PASS ' : 'FAIL ') + name + (ok ? '' : ' ' + detail)) }
12
13const prof = process.env.HOME + '/.cache/cthuloid-shots/prof-sw-' + Date.now()
14mkdirSync(prof, { recursive: true })
15const chrome = spawn('chromium', ['--headless=new', '--disable-gpu', '--remote-debugging-port=9351', '--user-data-dir=' + prof, '--window-size=1100,1100', 'about:blank'], { stdio: 'ignore' })
16let target
17for (let i = 0; i < 50 && !target; i++) { await sleep(200); try { target = (await (await fetch('http://127.0.0.1:9351/json')).json()).find((t) => t.type === 'page') } catch {} }
18const ws = new WebSocket(target.webSocketDebuggerUrl)
19await new Promise((r) => ws.addEventListener('open', r))
20let id = 0
21const pending = new Map()
22ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } })
23const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
24const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true })).result.result.value
25const click = async (expr) => {
26 const b = await evalJs(`(() => { const e = ${expr}; if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 } })()`)
27 if (!b) return false
28 await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: b.x, y: b.y, button: 'left', buttons: 1, clickCount: 1 })
29 await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: b.x, y: b.y, button: 'left', buttons: 0, clickCount: 1 })
30 return true
31}
32const lines = () => readFileSync(OUT + '/fake3-lines.txt', 'utf8').trim().split('\n').map((l) => l.split(' ').slice(1).join(' '))
33
34await send('Page.enable'); await send('Runtime.enable')
35await send('Page.navigate', { url: 'http://127.0.0.1:4052/' })
36await sleep(2000)
37const card = `[...document.querySelectorAll('.unit')].find(u => !u.classList.contains('off'))`
38await click(`${card}.querySelector('.swatch')`)
39await sleep(300)
40check('swatches tab exists', await click(`[...document.querySelectorAll('.pk-tabs button')].find(b => b.dataset.tab === 'swatches')`))
41await sleep(300)
42const count = await evalJs(`document.querySelectorAll('.pk-swatches')[0].children.length`)
43check('palette shows 18 colours', count === 18, count)
44check('tap Red', await click(`[...document.querySelectorAll('.pk-swatch')].find(b => b.title === 'Red')`))
45await sleep(1800) // grab, frame, 1 s quiet release
46const l = lines()
47const seq = l.filter((x) => /^(grant|slot|release)/.test(x))
48check('unit got grant, red frame, release', seq[0] === 'grant 0 ' + (await evalJs(`document.querySelector('#my-name').value`)) &&
49 seq.some((x) => /^slot 0 5 \d+ 0 0 30000 0$/.test(x)) && seq[seq.length - 1] === 'release 0', JSON.stringify(seq))
50check('Recent now lists red', await evalJs(`[...document.querySelectorAll('.pk-sw-title')].some(t => t.textContent === 'Recent')`))
51await send('Page.captureScreenshot', { format: 'png' }).then((r) => writeFileSync(OUT + '/sw-tab.png', Buffer.from(r.result.data, 'base64')))
52check('save current', await click(`[...document.querySelectorAll('.pk-sw-actions button')].find(b => b.textContent === '+ Save current')`))
53await sleep(200)
54const mine = () => evalJs(`(() => { const t = [...document.querySelectorAll('.pk-sw-title')].find(t => t.textContent === 'My swatches'); return t.nextElementSibling.children.length })()`)
55check('My swatches has it', (await mine()) === 1)
56await click(`[...document.querySelectorAll('.pk-sw-actions button')].find(b => b.textContent === 'Edit')`)
57await sleep(150)
58await click(`document.querySelector('.pk-swatch.removing')`)
59await sleep(150)
60check('Edit -> remove empties it', (await mine()) === 0)
61ws.close(); chrome.kill()
62console.log(results.every(Boolean) ? 'ALL PASS' : 'FAILURES')
63process.exit(results.every(Boolean) ? 0 : 1)
webui/test/server.test.js +1 -0
……@@ -55,6 +55,7 @@ test('page and assets are served', async (t) => {
5555 t.is((await request(s.port, 'GET', '/app.css')).status, 200)
5656 t.is((await request(s.port, 'GET', '/color.js')).status, 200)
5757 t.is((await request(s.port, 'GET', '/picker.js')).status, 200)
58 t.is((await request(s.port, 'GET', '/swatches.js')).status, 200)
5859 t.is((await request(s.port, 'GET', '/nope')).status, 404)
5960 await s.teardown()
6061})
webui/test/swatches.test.js +33 -0
……@@ -0,0 +1,33 @@
1import test from 'brittle'
2import { PALETTE, pushRecent, addSwatch, removeSwatch, sameColor } from '../static/swatches.js'
3
4test('palette: named lighting colours, all valid 0..1', (t) => {
5 t.ok(PALETTE.length >= 12)
6 for (const p of PALETTE) {
7 t.ok(p.name && [p.r, p.g, p.b].every((v) => v >= 0 && v <= 1), p.name)
8 t.ok(Math.max(p.r, p.g, p.b) === 1, p.name + ' normalised to full drive')
9 }
10})
11
12test('recent: newest first, no duplicates, capped at 8', (t) => {
13 let r = []
14 for (let i = 0; i < 10; i++) r = pushRecent(r, { r: i / 10, g: 0, b: 1 })
15 t.is(r.length, 8)
16 t.is(r[0].r, 0.9)
17 r = pushRecent(r, { r: 0.5, g: 0, b: 1 })
18 t.is(r[0].r, 0.5, 'reused colour moves to the front')
19 t.is(r.filter((c) => sameColor(c, { r: 0.5, g: 0, b: 1 })).length, 1, 'not duplicated')
20})
21
22test('my swatches: add skips duplicates, remove by index', (t) => {
23 let m = addSwatch([], { r: 1, g: 0, b: 0 })
24 m = addSwatch(m, { r: 1, g: 0, b: 0 })
25 t.is(m.length, 1)
26 m = addSwatch(m, { r: 0, g: 1, b: 0 })
27 t.alike(removeSwatch(m, 0), [{ r: 0, g: 1, b: 0 }])
28})
29
30test('colours within 1/255 count as the same', (t) => {
31 t.ok(sameColor({ r: 1, g: 0.5, b: 0 }, { r: 1, g: 0.501, b: 0 }))
32 t.absent(sameColor({ r: 1, g: 0.5, b: 0 }, { r: 1, g: 0.52, b: 0 }))
33})