OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / b765087

webui 0.1.1: Blender-style colour picker per slot
Each slot's swatch opens an inline picker: hue/saturation wheel that dims with value (as Blender's does), a value bar, and RGB / HSV / Hex fields. V is the slot brightness, so the bar and the row slider are one control; hue survives V=0. Pointer events (mouse + touch, touch-action none). Colour maths in static/color.js, tested under brittle-bare (37/37). Exercised in real Chromium over CDP against a fake unit: wheel drag to the red edge + value bar to the middle delivered `pwm 356 356 15000 ...` (red = half the 30000 cap); 390 px viewport has no overflow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b765087673d90d74e4df3f12bcaa7bd9a53def11
by plankatron <fleeky@gmail.com> · 2026-10-02T14:14:43.000Z

Changed files (9) +456 -35

webui/lib/server.js +3 -1
……@@ -14,7 +14,9 @@ const STATIC_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '.
1414const ASSETS = {
1515 '/': ['index.html', 'text/html; charset=utf-8'],
1616 '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
17 '/app.css': ['app.css', 'text/css; charset=utf-8']
17 '/app.css': ['app.css', 'text/css; charset=utf-8'],
18 '/color.js': ['color.js', 'text/javascript; charset=utf-8'],
19 '/picker.js': ['picker.js', 'text/javascript; charset=utf-8']
1820}
1921const MAX_BODY = 64 * 1024
2022const PING_MS = 15000
webui/package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "cthuloid-web",
3 "version": "0.1.0",
3 "version": "0.1.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 +22 -3
……@@ -96,9 +96,28 @@ main { padding: 0 16px 2rem; max-width: 980px; margin: 0 auto; }
9696.where { margin: .15rem 0 .5rem; }
9797.note { margin: 0 0 .5rem; color: var(--warn); }
9898.slots { display: grid; gap: .4rem; }
99.slot { display: grid; grid-template-columns: 3.2rem 2.6rem 1fr 2.8rem 1.6rem; gap: .5rem; align-items: center; }
100.slot input[type=color] { width: 2.6rem; height: 2rem; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: none; }
101.slot input[type=range] { width: 100%; }
99.slot-row { display: grid; grid-template-columns: 3.2rem 2.6rem 1fr 2.8rem 1.6rem; gap: .5rem; align-items: center; }
100.swatch { width: 2.6rem; height: 2rem; padding: 0; border: 1px solid var(--line); border-radius: 6px; }
101.swatch.open { outline: 2px solid var(--accent); outline-offset: 1px; }
102.slot-row input[type=range] { width: 100%; }
103
104/* Blender-style picker: wheel + value bar, RGB / HSV / Hex fields. */
105.picker { margin: .5rem 0 .3rem; padding: .7rem; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
106.pk-top { display: flex; gap: .8rem; align-items: stretch; justify-content: center; }
107/* touch-action: none, or a finger drag on the wheel scrolls the page instead. */
108.pk-wheel, .pk-vbar { touch-action: none; cursor: crosshair; }
109.pk-wheel { border-radius: 50%; }
110.pk-vbar { position: relative; width: 22px; border-radius: 6px; border: 1px solid var(--line); }
111.pk-vhandle { position: absolute; left: -4px; right: -4px; height: 6px; margin-top: -3px; border: 2px solid #fff; outline: 1px solid #000; border-radius: 3px; pointer-events: none; }
112.pk-tabs { display: flex; gap: 0; margin: .7rem 0 .4rem; }
113.pk-tabs button { flex: 1; border-radius: 0; padding: .3rem; }
114.pk-tabs button:first-child { border-radius: 6px 0 0 6px; }
115.pk-tabs button:last-child { border-radius: 0 6px 6px 0; }
116.pk-tabs button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
117.pk-fields { display: grid; gap: .3rem; }
118.pk-row { display: grid; grid-template-columns: 2.2rem 1fr 5rem; gap: .5rem; align-items: center; font-size: .85rem; }
119.pk-row input[type=number] { width: 100%; }
120.pk-row input[type=text] { grid-column: 2 / 4; font-family: ui-monospace, monospace; }
102121.actual { width: 1.6rem; height: 1.6rem; border-radius: 50%; border: 1px solid var(--line); background: #000; }
103122.slots.empty { color: var(--muted); }
104123.unit-actions { margin-top: .6rem; }
webui/static/app.js +60 -24
……@@ -1,6 +1,7 @@
11// Cthuloid Lights page. Server state arrives over /api/events (SSE) and is the
22// only source of truth; controls send changes and wait to be told the result.
3'use strict'
3import { hsvToRgb, hsvToSlot, slotToHsv, toHex } from './color.js'
4import { Picker } from './picker.js'
45
56const $ = (sel, el) => (el || document).querySelector(sel)
67
……@@ -55,13 +56,6 @@ function act (fn) {
5556
5657// ---- colour helpers ---------------------------------------------------------
5758
58const hex2 = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0')
59const toHex = (s) => '#' + hex2(s.r) + hex2(s.g) + hex2(s.b)
60function fromHex (h) {
61 const n = parseInt(h.slice(1), 16)
62 return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 }
63}
64
6559// A unit's readback (BGR per slot, red capped) as a CSS colour.
6660function actualCss (values, i, redMax) {
6761 const red = Math.min(redMax, 45000) || 1
……@@ -154,18 +148,41 @@ function makeCard (id) {
154148 const base = '/api/units/' + encodeURIComponent(id)
155149 const slots = []
156150 for (let i = 0; i < 4; i++) {
157 const row = $('#slot-tpl').content.firstElementChild.cloneNode(true)
158 $('.slot-n', row).textContent = 'Slot ' + (i + 1)
159 const color = $('.color', row)
160 const level = $('.level', row)
151 const el2 = $('#slot-tpl').content.firstElementChild.cloneNode(true)
152 $('.slot-n', el2).textContent = 'Slot ' + (i + 1)
153 // The slot as the user is editing it. V is the brightness: the picker's
154 // value bar and the row's slider are the same control.
155 const row = { el: el2, hsv: { h: 0, s: 0, v: 1 }, picker: null }
156 const level = $('.level', el2)
157 const panel = $('.slot-picker', el2)
158 const swatch = $('.swatch', el2)
161159 const send = () => {
162 const c = fromHex(color.value)
163 $('.pct', row).textContent = level.value + '%'
164 sendLatest(id + ':' + i, base + '/slot', { index: i, r: c.r, g: c.g, b: c.b, level: Number(level.value) / 100 })
160 paintSlot(row)
161 sendLatest(id + ':' + i, base + '/slot', { index: i, ...hsvToSlot(row.hsv.h, row.hsv.s, row.hsv.v) })
162 }
163 level.addEventListener('input', () => {
164 row.hsv = { ...row.hsv, v: Number(level.value) / 100 }
165 if (row.picker) row.picker.set(row.hsv)
166 send()
167 })
168 swatch.onclick = () => {
169 const opening = panel.hidden
170 closePicker()
171 if (!opening) return
172 if (!row.picker) {
173 row.picker = new Picker(panel, {
174 onChange: (hsv) => {
175 row.hsv = hsv
176 send()
177 }
178 })
179 }
180 row.picker.set(row.hsv)
181 panel.hidden = false
182 swatch.classList.add('open')
183 openRow = row
165184 }
166 color.addEventListener('input', send)
167 level.addEventListener('input', send)
168 $('.slots', el).appendChild(row)
185 $('.slots', el).appendChild(el2)
169186 slots.push(row)
170187 }
171188
……@@ -210,13 +227,16 @@ function updateCard (card, u, st) {
210227 for (let i = 0; i < 4; i++) {
211228 const row = card.slots[i]
212229 const s = u.slots ? u.slots[i] : null
213 row.hidden = !u.slots
214 if (s) {
215 setValue($('.color', row), toHex(s))
216 setValue($('.level', row), Math.round(s.level * 100))
217 $('.pct', row).textContent = Math.round(s.level * 100) + '%'
230 row.el.hidden = !u.slots
231 // Skip the echo while the user is moving this slot's controls, or an
232 // older value from the server would yank the handle back mid-drag.
233 const busy = document.activeElement === $('.level', row.el) || (row.picker && row.picker.dragging)
234 if (s && !busy) {
235 row.hsv = slotToHsv(s, row.hsv.h)
236 paintSlot(row)
237 if (row.picker) row.picker.set(row.hsv)
218238 }
219 $('.actual', row).style.background = actual ? actualCss(actual, i, u.redMax) : 'transparent'
239 $('.actual', row.el).style.background = actual ? actualCss(actual, i, u.redMax) : 'transparent'
220240 }
221241
222242 setValue($('.fade', el), u.fadeMs)
……@@ -231,6 +251,22 @@ function updateCard (card, u, st) {
231251 $('.adopt', el).disabled = !u.actual
232252}
233253
254// One picker open at a time, page-wide.
255let openRow = null
256function closePicker () {
257 if (!openRow) return
258 $('.slot-picker', openRow.el).hidden = true
259 $('.swatch', openRow.el).classList.remove('open')
260 openRow = null
261}
262
263function paintSlot (row) {
264 const { h, s, v } = row.hsv
265 $('.swatch', row.el).style.background = toHex(hsvToRgb(h, s, v))
266 setValue($('.level', row.el), Math.round(v * 100))
267 $('.pct', row.el).textContent = Math.round(v * 100) + '%'
268}
269
234270// ---- wiring -------------------------------------------------------------------
235271
236272$('#connect').onclick = act(() => post('/api/connect', { on: !(state && state.connected) }))
webui/static/color.js +65 -0
……@@ -0,0 +1,65 @@
1// Colour maths for the page's Blender-style picker. A plain ES module so the
2// browser loads it and brittle-bare tests it (test/color.test.js).
3//
4// All values are 0..1. A slot is { r, g, b, level }: the colour at full drive,
5// normalised so its brightest channel is 1, and `level` the brightness. That
6// is exactly HSV with V = level, which is why the picker's value bar and the
7// row's brightness slider are the same control.
8
9export function hsvToRgb (h, s, v) {
10 h = ((h % 1) + 1) % 1
11 const i = Math.floor(h * 6)
12 const f = h * 6 - i
13 const p = v * (1 - s)
14 const q = v * (1 - f * s)
15 const t = v * (1 - (1 - f) * s)
16 switch (i % 6) {
17 case 0: return { r: v, g: t, b: p }
18 case 1: return { r: q, g: v, b: p }
19 case 2: return { r: p, g: v, b: t }
20 case 3: return { r: p, g: q, b: v }
21 case 4: return { r: t, g: p, b: v }
22 default: return { r: v, g: p, b: q }
23 }
24}
25
26export function rgbToHsv (r, g, b) {
27 const max = Math.max(r, g, b)
28 const min = Math.min(r, g, b)
29 const d = max - min
30 let h = 0
31 if (d > 0) {
32 if (max === r) h = ((g - b) / d) % 6
33 else if (max === g) h = (b - r) / d + 2
34 else h = (r - g) / d + 4
35 h /= 6
36 if (h < 0) h += 1
37 }
38 return { h, s: max > 0 ? d / max : 0, v: max }
39}
40
41// Hue and saturation come from the slot's colour, V from its level -- so a
42// slot dimmed to 0 still knows its colour. `fallbackHue` is for a colourless
43// (grey or black) slot, where hue is undefined.
44export function slotToHsv (slot, fallbackHue = 0) {
45 const { h, s } = rgbToHsv(slot.r, slot.g, slot.b)
46 return { h: s > 0 ? h : fallbackHue, s, v: slot.level }
47}
48
49export function hsvToSlot (h, s, v) {
50 const c = hsvToRgb(h, s, 1)
51 return { r: c.r, g: c.g, b: c.b, level: v }
52}
53
54const hex2 = (x) => Math.round(Math.max(0, Math.min(1, x)) * 255).toString(16).padStart(2, '0')
55
56export function toHex (c) {
57 return '#' + hex2(c.r) + hex2(c.g) + hex2(c.b)
58}
59
60export function fromHex (str) {
61 const m = /^#?([0-9a-f]{6})$/i.exec(String(str).trim())
62 if (!m) return null
63 const n = parseInt(m[1], 16)
64 return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 }
65}
webui/static/index.html +9 -6
……@@ -75,14 +75,17 @@
7575
7676<template id="slot-tpl">
7777 <div class="slot">
78 <span class="slot-n"></span>
79 <input type="color" class="color" aria-label="Colour">
80 <input type="range" class="level" min="0" max="100" step="1" aria-label="Brightness">
81 <span class="pct small"></span>
82 <span class="actual" title="What the unit is actually showing"></span>
78 <div class="slot-row">
79 <span class="slot-n"></span>
80 <button type="button" class="swatch" title="Edit colour" aria-label="Edit colour"></button>
81 <input type="range" class="level" min="0" max="100" step="1" aria-label="Brightness">
82 <span class="pct small"></span>
83 <span class="actual" title="What the unit is actually showing"></span>
84 </div>
85 <div class="slot-picker" hidden></div>
8386 </div>
8487</template>
8588
86<script src="/app.js"></script>
89<script type="module" src="/app.js"></script>
8790</body>
8891</html>
webui/static/picker.js +237 -0
……@@ -0,0 +1,237 @@
1// A Blender-style colour picker: hue/saturation wheel, a value bar beside it,
2// and RGB / HSV / Hex fields underneath. Pointer events, so mouse and touch
3// both drag. Values are 0..1 throughout, as in Blender.
4//
5// const p = new Picker(el, { onChange: ({ h, s, v }) => ... })
6// p.set({ h, s, v }) // from the server; ignored while the user drags
7
8import { hsvToRgb, rgbToHsv, toHex, fromHex } from './color.js'
9
10const WHEEL_CSS = 184 // px
11const TAU = Math.PI * 2
12
13// The wheel at full value is the same for every picker: draw it once.
14let wheelImage = null
15function wheelBitmap (size) {
16 if (wheelImage && wheelImage.width === size) return wheelImage
17 const c = document.createElement('canvas')
18 c.width = c.height = size
19 const ctx = c.getContext('2d')
20 const img = ctx.createImageData(size, size)
21 const R = size / 2
22 for (let y = 0; y < size; y++) {
23 for (let x = 0; x < size; x++) {
24 const dx = x + 0.5 - R
25 const dy = y + 0.5 - R
26 const d = Math.sqrt(dx * dx + dy * dy)
27 if (d > R) continue
28 // Hue 0 (red) at the right, increasing counter-clockwise.
29 const h = (Math.atan2(-dy, dx) / TAU + 1) % 1
30 const { r, g, b } = hsvToRgb(h, d / R, 1)
31 const i = (y * size + x) * 4
32 img.data[i] = r * 255
33 img.data[i + 1] = g * 255
34 img.data[i + 2] = b * 255
35 img.data[i + 3] = Math.min(1, R - d) * 255 // soft edge
36 }
37 }
38 ctx.putImageData(img, 0, 0)
39 wheelImage = c
40 return c
41}
42
43export class Picker {
44 constructor (root, { onChange }) {
45 this.onChange = onChange
46 this.h = 0
47 this.s = 0
48 this.v = 1
49 this.dragging = false
50 this.tab = 'rgb'
51
52 root.classList.add('picker')
53 root.innerHTML = `
54 <div class="pk-top">
55 <canvas class="pk-wheel" aria-label="Hue and saturation"></canvas>
56 <div class="pk-vbar" aria-label="Value (brightness)"><div class="pk-vhandle"></div></div>
57 </div>
58 <div class="pk-tabs" role="tablist">
59 <button type="button" data-tab="rgb">RGB</button>
60 <button type="button" data-tab="hsv">HSV</button>
61 <button type="button" data-tab="hex">Hex</button>
62 </div>
63 <div class="pk-fields"></div>`
64 this.root = root
65 this.canvas = root.querySelector('.pk-wheel')
66 this.vbar = root.querySelector('.pk-vbar')
67 this.vhandle = root.querySelector('.pk-vhandle')
68 this.fields = root.querySelector('.pk-fields')
69
70 const dpr = window.devicePixelRatio || 1
71 this.px = Math.round(WHEEL_CSS * dpr)
72 this.canvas.width = this.canvas.height = this.px
73 this.canvas.style.width = this.canvas.style.height = WHEEL_CSS + 'px'
74 this.ctx = this.canvas.getContext('2d')
75
76 this._drag(this.canvas, (e) => this._fromWheel(e))
77 this._drag(this.vbar, (e) => this._fromBar(e))
78 for (const b of root.querySelectorAll('.pk-tabs button')) {
79 b.onclick = () => { this.tab = b.dataset.tab; this._buildFields() }
80 }
81 this._buildFields()
82 this._draw()
83 }
84
85 set ({ h, s, v }) {
86 if (this.dragging) return
87 this.h = h
88 this.s = s
89 this.v = v
90 this._draw()
91 }
92
93 // ---- input --------------------------------------------------------------
94
95 _drag (el, apply) {
96 el.addEventListener('pointerdown', (e) => {
97 e.preventDefault()
98 el.setPointerCapture(e.pointerId)
99 this.dragging = true
100 apply(e)
101 })
102 el.addEventListener('pointermove', (e) => {
103 if (this.dragging && el.hasPointerCapture(e.pointerId)) apply(e)
104 })
105 const end = (e) => {
106 if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
107 this.dragging = false
108 }
109 el.addEventListener('pointerup', end)
110 el.addEventListener('pointercancel', end)
111 }
112
113 _fromWheel (e) {
114 const r = this.canvas.getBoundingClientRect()
115 const R = r.width / 2
116 const dx = e.clientX - r.left - R
117 const dy = e.clientY - r.top - R
118 this.h = (Math.atan2(-dy, dx) / TAU + 1) % 1
119 this.s = Math.min(1, Math.sqrt(dx * dx + dy * dy) / R)
120 this._changed()
121 }
122
123 _fromBar (e) {
124 const r = this.vbar.getBoundingClientRect()
125 this.v = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height))
126 this._changed()
127 }
128
129 _changed () {
130 this._draw()
131 this.onChange({ h: this.h, s: this.s, v: this.v })
132 }
133
134 // ---- fields -------------------------------------------------------------
135
136 _buildFields () {
137 for (const b of this.root.querySelectorAll('.pk-tabs button')) {
138 b.classList.toggle('on', b.dataset.tab === this.tab)
139 }
140 this.fields.innerHTML = ''
141 if (this.tab === 'hex') {
142 const row = document.createElement('label')
143 row.className = 'pk-row'
144 row.innerHTML = '<span>Hex</span><input type="text" class="pk-hex" maxlength="7" spellcheck="false">'
145 const input = row.querySelector('input')
146 input.addEventListener('change', () => {
147 const c = fromHex(input.value)
148 if (!c) return this._syncFields()
149 this._setRgb(c.r, c.g, c.b)
150 })
151 this.fields.appendChild(row)
152 } else {
153 const names = this.tab === 'rgb' ? ['R', 'G', 'B'] : ['H', 'S', 'V']
154 for (let i = 0; i < 3; i++) {
155 const row = document.createElement('label')
156 row.className = 'pk-row'
157 row.innerHTML = '<span>' + names[i] + '</span>' +
158 '<input type="range" min="0" max="1" step="0.001">' +
159 '<input type="number" min="0" max="1" step="0.01">'
160 const [range, num] = row.querySelectorAll('input')
161 const apply = (val) => {
162 val = Math.max(0, Math.min(1, Number(val) || 0))
163 if (this.tab === 'rgb') {
164 const c = hsvToRgb(this.h, this.s, this.v)
165 const rgb = [c.r, c.g, c.b]
166 rgb[i] = val
167 this._setRgb(rgb[0], rgb[1], rgb[2])
168 } else {
169 if (i === 0) this.h = val
170 else if (i === 1) this.s = val
171 else this.v = val
172 this._changed()
173 }
174 }
175 range.addEventListener('input', () => apply(range.value))
176 num.addEventListener('change', () => apply(num.value))
177 this.fields.appendChild(row)
178 }
179 }
180 this._syncFields()
181 }
182
183 _setRgb (r, g, b) {
184 const hsv = rgbToHsv(r, g, b)
185 // Grey or black has no hue: keep the one we had so the wheel doesn't jump.
186 if (hsv.s > 0 && hsv.v > 0) this.h = hsv.h
187 this.s = hsv.s
188 this.v = hsv.v
189 this._changed()
190 }
191
192 _syncFields () {
193 const active = document.activeElement
194 if (this.tab === 'hex') {
195 const input = this.fields.querySelector('.pk-hex')
196 if (input !== active) input.value = toHex(hsvToRgb(this.h, this.s, this.v))
197 return
198 }
199 const c = hsvToRgb(this.h, this.s, this.v)
200 const vals = this.tab === 'rgb' ? [c.r, c.g, c.b] : [this.h, this.s, this.v]
201 this.fields.querySelectorAll('.pk-row').forEach((row, i) => {
202 const [range, num] = row.querySelectorAll('input')
203 if (range !== active) range.value = vals[i]
204 if (num !== active) num.value = vals[i].toFixed(3)
205 })
206 }
207
208 // ---- drawing ------------------------------------------------------------
209
210 _draw () {
211 const { ctx, px } = this
212 const R = px / 2
213 ctx.clearRect(0, 0, px, px)
214 ctx.drawImage(wheelBitmap(px), 0, 0)
215 // Like Blender, the wheel shows the current value: darken it toward V = 0.
216 if (this.v < 1) {
217 ctx.fillStyle = 'rgba(0,0,0,' + (1 - this.v) + ')'
218 ctx.beginPath()
219 ctx.arc(R, R, R, 0, TAU)
220 ctx.fill()
221 }
222 const a = this.h * TAU
223 const x = R + Math.cos(a) * this.s * R
224 const y = R - Math.sin(a) * this.s * R
225 const dpr = px / WHEEL_CSS
226 ctx.lineWidth = 2 * dpr
227 ctx.strokeStyle = this.v > 0.5 ? '#000' : '#fff'
228 ctx.beginPath()
229 ctx.arc(x, y, 6 * dpr, 0, TAU)
230 ctx.stroke()
231
232 const full = hsvToRgb(this.h, this.s, 1)
233 this.vbar.style.background = 'linear-gradient(to top, #000, ' + toHex(full) + ')'
234 this.vhandle.style.top = ((1 - this.v) * 100) + '%'
235 this._syncFields()
236 }
237}
webui/test/color.test.js +57 -0
……@@ -0,0 +1,57 @@
1// Colour maths behind the page's Blender-style picker (static/color.js).
2import test from 'brittle'
3import { hsvToRgb, rgbToHsv, slotToHsv, hsvToSlot, toHex, fromHex } from '../static/color.js'
4
5const near = (t, a, b, msg) => t.ok(Math.abs(a - b) < 1e-6, msg + ' (' + a + ' vs ' + b + ')')
6
7test('primaries convert both ways', (t) => {
8 t.alike(hsvToRgb(0, 1, 1), { r: 1, g: 0, b: 0 })
9 // 1/3 and 2/3 are not exact in binary: compare within float noise.
10 for (const [h, want] of [[1 / 3, [0, 1, 0]], [2 / 3, [0, 0, 1]]]) {
11 const c = hsvToRgb(h, 1, 1)
12 near(t, c.r, want[0], 'r at h=' + h)
13 near(t, c.g, want[1], 'g at h=' + h)
14 near(t, c.b, want[2], 'b at h=' + h)
15 }
16 t.alike(hsvToRgb(0.5, 0, 0.25), { r: 0.25, g: 0.25, b: 0.25 })
17 const h = rgbToHsv(0, 0, 1)
18 near(t, h.h, 2 / 3, 'blue hue')
19 t.is(h.s, 1)
20 t.is(h.v, 1)
21})
22
23test('rgb -> hsv -> rgb round-trips', (t) => {
24 for (const [r, g, b] of [[0.2, 0.7, 0.1], [1, 0.5, 0.25], [0.9, 0.9, 0.95], [0.01, 0, 0.6]]) {
25 const { h, s, v } = rgbToHsv(r, g, b)
26 const back = hsvToRgb(h, s, v)
27 near(t, back.r, r, 'r')
28 near(t, back.g, g, 'g')
29 near(t, back.b, b, 'b')
30 }
31})
32
33test('a slot maps to HSV with V = brightness', (t) => {
34 const hsv = slotToHsv({ r: 0, g: 0.5, b: 1, level: 0.4 })
35 near(t, hsv.v, 0.4, 'V is the level')
36 near(t, hsv.s, 1, 'fully saturated')
37 const slot = hsvToSlot(hsv.h, hsv.s, hsv.v)
38 near(t, slot.level, 0.4, 'level back')
39 near(t, slot.b, 1, 'colour normalised to full')
40 near(t, slot.g, 0.5, 'g kept')
41})
42
43test('a dark slot keeps the hue it was given', (t) => {
44 // V = 0 must not lose the colour: dimming to black and back returns to it.
45 const hsv = slotToHsv({ r: 1, g: 0, b: 0, level: 0 }, 0.3)
46 t.is(hsv.v, 0)
47 near(t, hsv.h, 0, 'hue from the slot colour, not the fallback')
48 const grey = slotToHsv({ r: 0, g: 0, b: 0, level: 0 }, 0.3)
49 near(t, grey.h, 0.3, 'no colour at all: fallback hue')
50})
51
52test('hex conversion', (t) => {
53 t.is(toHex({ r: 1, g: 0.5, b: 0 }), '#ff8000')
54 t.alike(fromHex('#ff8000'), { r: 1, g: 128 / 255, b: 0 })
55 t.alike(fromHex('ff8000'), { r: 1, g: 128 / 255, b: 0 }, 'no #')
56 t.is(fromHex('#zzz'), null)
57})
webui/test/server.test.js +2 -0
……@@ -53,6 +53,8 @@ test('page and assets are served', async (t) => {
5353 t.ok(page.body.includes('<title>'), 'html')
5454 t.is((await request(s.port, 'GET', '/app.js')).status, 200)
5555 t.is((await request(s.port, 'GET', '/app.css')).status, 200)
56 t.is((await request(s.port, 'GET', '/color.js')).status, 200)
57 t.is((await request(s.port, 'GET', '/picker.js')).status, 200)
5658 t.is((await request(s.port, 'GET', '/nope')).status, 404)
5759 await s.teardown()
5860})