| … | … | @@ -16,7 +16,8 @@ const ASSETS = { |
| 16 | 16 | '/app.js': ['app.js', 'text/javascript; charset=utf-8'], |
| 17 | 17 | '/app.css': ['app.css', 'text/css; charset=utf-8'], |
| 18 | 18 | '/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'] | |
| 20 | 21 | } |
| 21 | 22 | const MAX_BODY = 64 * 1024 |
| 22 | 23 | const PING_MS = 15000 |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "cthuloid-web", |
| 3 | "version": "0.2.0", | |
| 3 | "version": "0.2.1", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "cthuloid-web", |
| 9 | "version": "0.2.0", | |
| 9 | "version": "0.2.1", | |
| 10 | 10 | "dependencies": { |
| 11 | 11 | "bare-events": "^2.9.2", |
| 12 | 12 | "bare-fs": "^4.8.1", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "cthuloid-web", |
| 3 | "version": "0.2.0", | |
| 3 | "version": "0.2.1", | |
| 4 | 4 | "description": "Web control for cthuloid ESP32 light units (esp324xlight) \u2014 discovers units over mDNS and drives them from a browser on the LAN.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -140,3 +140,10 @@ main { padding: 0 16px 2rem; max-width: 980px; margin: 0 auto; } |
| 140 | 140 | .slot.held-other .slot-row { opacity: .55; } |
| 141 | 141 | .slot-holder { margin: .15rem 0 0 3.7rem; color: var(--warn); } |
| 142 | 142 | .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; } |
| … | … | @@ -10,6 +10,20 @@ |
| 10 | 10 | // slot's grant for exactly as long as the pointer is down. |
| 11 | 11 | |
| 12 | 12 | import { hsvToRgb, rgbToHsv, toHex, fromHex } from './color.js' |
| 13 | import { 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. | |
| 17 | const mem = {} | |
| 18 | function load (key) { | |
| 19 | try { return JSON.parse(localStorage.getItem(key) || '[]') } catch { return mem[key] || [] } | |
| 20 | } | |
| 21 | function save (key, list) { | |
| 22 | mem[key] = list | |
| 23 | try { localStorage.setItem(key, JSON.stringify(list)) } catch {} | |
| 24 | } | |
| 25 | const RECENT = 'cthuloid.recent' | |
| 26 | const MINE = 'cthuloid.swatches' | |
| 13 | 27 | |
| 14 | 28 | const WHEEL_CSS = 184 // px |
| 15 | 29 | const TAU = Math.PI * 2 |
| … | … | @@ -55,6 +69,7 @@ export class Picker { |
| 55 | 69 | this.v = 1 |
| 56 | 70 | this.dragging = false |
| 57 | 71 | this.tab = 'rgb' |
| 72 | this.editing = false // Swatches tab: show remove buttons on saved swatches | |
| 58 | 73 | |
| 59 | 74 | root.classList.add('picker') |
| 60 | 75 | root.innerHTML = ` |
| … | … | @@ -66,6 +81,7 @@ export class Picker { |
| 66 | 81 | <button type="button" data-tab="rgb">RGB</button> |
| 67 | 82 | <button type="button" data-tab="hsv">HSV</button> |
| 68 | 83 | <button type="button" data-tab="hex">Hex</button> |
| 84 | <button type="button" data-tab="swatches">Swatches</button> | |
| 69 | 85 | </div> |
| 70 | 86 | <div class="pk-fields"></div>` |
| 71 | 87 | this.root = root |
| … | … | @@ -121,6 +137,7 @@ export class Picker { |
| 121 | 137 | if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) |
| 122 | 138 | if (this.dragging) { |
| 123 | 139 | this.dragging = false |
| 140 | this._remember() | |
| 124 | 141 | this.onEnd() |
| 125 | 142 | } |
| 126 | 143 | } |
| … | … | @@ -156,6 +173,7 @@ export class Picker { |
| 156 | 173 | b.classList.toggle('on', b.dataset.tab === this.tab) |
| 157 | 174 | } |
| 158 | 175 | this.fields.innerHTML = '' |
| 176 | if (this.tab === 'swatches') return this._buildSwatches() | |
| 159 | 177 | if (this.tab === 'hex') { |
| 160 | 178 | const row = document.createElement('label') |
| 161 | 179 | row.className = 'pk-row' |
| … | … | @@ -198,6 +216,77 @@ export class Picker { |
| 198 | 216 | this._syncFields() |
| 199 | 217 | } |
| 200 | 218 | |
| 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 | ||
| 201 | 290 | _setRgb (r, g, b) { |
| 202 | 291 | const hsv = rgbToHsv(r, g, b) |
| 203 | 292 | // Grey or black has no hue: keep the one we had so the wheel doesn't jump. |
| … | … | @@ -208,6 +297,7 @@ export class Picker { |
| 208 | 297 | } |
| 209 | 298 | |
| 210 | 299 | _syncFields () { |
| 300 | if (this.tab === 'swatches') return | |
| 211 | 301 | const active = document.activeElement |
| 212 | 302 | if (this.tab === 'hex') { |
| 213 | 303 | const input = this.fields.querySelector('.pk-hex') |
| … | … | @@ -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. | |
| 12 | export 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 | ||
| 33 | export const RECENT_MAX = 8 | |
| 34 | ||
| 35 | const 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. | |
| 38 | export 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 | ||
| 43 | export function pushRecent (list, color) { | |
| 44 | return [strip(color), ...list.filter((c) => !sameColor(c, color))].slice(0, RECENT_MAX) | |
| 45 | } | |
| 46 | ||
| 47 | export function addSwatch (list, color) { | |
| 48 | return list.some((c) => sameColor(c, color)) ? list : [...list, strip(color)] | |
| 49 | } | |
| 50 | ||
| 51 | export function removeSwatch (list, index) { | |
| 52 | return list.filter((_, i) => i !== index) | |
| 53 | } |
| … | … | @@ -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. | |
| 5 | import { spawn } from 'node:child_process' | |
| 6 | import { writeFileSync, mkdirSync, readFileSync } from 'node:fs' | |
| 7 | ||
| 8 | const OUT = process.argv[2] | |
| 9 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) | |
| 10 | const results = [] | |
| 11 | const check = (name, ok, detail = '') => { results.push(ok); console.log((ok ? 'PASS ' : 'FAIL ') + name + (ok ? '' : ' ' + detail)) } | |
| 12 | ||
| 13 | const prof = process.env.HOME + '/.cache/cthuloid-shots/prof-sw-' + Date.now() | |
| 14 | mkdirSync(prof, { recursive: true }) | |
| 15 | const chrome = spawn('chromium', ['--headless=new', '--disable-gpu', '--remote-debugging-port=9351', '--user-data-dir=' + prof, '--window-size=1100,1100', 'about:blank'], { stdio: 'ignore' }) | |
| 16 | let target | |
| 17 | for (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 {} } | |
| 18 | const ws = new WebSocket(target.webSocketDebuggerUrl) | |
| 19 | await new Promise((r) => ws.addEventListener('open', r)) | |
| 20 | let id = 0 | |
| 21 | const pending = new Map() | |
| 22 | ws.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) } }) | |
| 23 | const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) }) | |
| 24 | const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true })).result.result.value | |
| 25 | const 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 | } | |
| 32 | const lines = () => readFileSync(OUT + '/fake3-lines.txt', 'utf8').trim().split('\n').map((l) => l.split(' ').slice(1).join(' ')) | |
| 33 | ||
| 34 | await send('Page.enable'); await send('Runtime.enable') | |
| 35 | await send('Page.navigate', { url: 'http://127.0.0.1:4052/' }) | |
| 36 | await sleep(2000) | |
| 37 | const card = `[...document.querySelectorAll('.unit')].find(u => !u.classList.contains('off'))` | |
| 38 | await click(`${card}.querySelector('.swatch')`) | |
| 39 | await sleep(300) | |
| 40 | check('swatches tab exists', await click(`[...document.querySelectorAll('.pk-tabs button')].find(b => b.dataset.tab === 'swatches')`)) | |
| 41 | await sleep(300) | |
| 42 | const count = await evalJs(`document.querySelectorAll('.pk-swatches')[0].children.length`) | |
| 43 | check('palette shows 18 colours', count === 18, count) | |
| 44 | check('tap Red', await click(`[...document.querySelectorAll('.pk-swatch')].find(b => b.title === 'Red')`)) | |
| 45 | await sleep(1800) // grab, frame, 1 s quiet release | |
| 46 | const l = lines() | |
| 47 | const seq = l.filter((x) => /^(grant|slot|release)/.test(x)) | |
| 48 | check('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)) | |
| 50 | check('Recent now lists red', await evalJs(`[...document.querySelectorAll('.pk-sw-title')].some(t => t.textContent === 'Recent')`)) | |
| 51 | await send('Page.captureScreenshot', { format: 'png' }).then((r) => writeFileSync(OUT + '/sw-tab.png', Buffer.from(r.result.data, 'base64'))) | |
| 52 | check('save current', await click(`[...document.querySelectorAll('.pk-sw-actions button')].find(b => b.textContent === '+ Save current')`)) | |
| 53 | await sleep(200) | |
| 54 | const mine = () => evalJs(`(() => { const t = [...document.querySelectorAll('.pk-sw-title')].find(t => t.textContent === 'My swatches'); return t.nextElementSibling.children.length })()`) | |
| 55 | check('My swatches has it', (await mine()) === 1) | |
| 56 | await click(`[...document.querySelectorAll('.pk-sw-actions button')].find(b => b.textContent === 'Edit')`) | |
| 57 | await sleep(150) | |
| 58 | await click(`document.querySelector('.pk-swatch.removing')`) | |
| 59 | await sleep(150) | |
| 60 | check('Edit -> remove empties it', (await mine()) === 0) | |
| 61 | ws.close(); chrome.kill() | |
| 62 | console.log(results.every(Boolean) ? 'ALL PASS' : 'FAILURES') | |
| 63 | process.exit(results.every(Boolean) ? 0 : 1) |
| … | … | @@ -55,6 +55,7 @@ test('page and assets are served', async (t) => { |
| 55 | 55 | t.is((await request(s.port, 'GET', '/app.css')).status, 200) |
| 56 | 56 | t.is((await request(s.port, 'GET', '/color.js')).status, 200) |
| 57 | 57 | t.is((await request(s.port, 'GET', '/picker.js')).status, 200) |
| 58 | t.is((await request(s.port, 'GET', '/swatches.js')).status, 200) | |
| 58 | 59 | t.is((await request(s.port, 'GET', '/nope')).status, 404) |
| 59 | 60 | await s.teardown() |
| 60 | 61 | }) |
| … | … | @@ -0,0 +1,33 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { PALETTE, pushRecent, addSwatch, removeSwatch, sameColor } from '../static/swatches.js' | |
| 3 | ||
| 4 | test('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 | ||
| 12 | test('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 | ||
| 22 | test('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 | ||
| 30 | test('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 | }) |