Palette calibration: precise hex/RGB inputs instead of a color picker
Build and push server image / build-and-push (push) Successful in 41s

A native <input type="color"> swatch can't be typed into precisely --
no way to enter an exact measured value. Replaced with a small table:
a read-only preview swatch, a hex text field, and three 0-255 number
fields (R/G/B) per ink color, kept in sync live in both directions
(editing hex updates R/G/B and the swatch; editing any of R/G/B updates
hex and the swatch). Hex stays the field actually read at save time --
the server-side validation (#rrggbb via hex_to_rgb) is unchanged, this
is a client-side-only swap of the input widget.
This commit is contained in:
2026-07-22 01:26:03 -04:00
parent 5b11f2accb
commit e802882fc1
3 changed files with 104 additions and 34 deletions
+55 -6
View File
@@ -67,10 +67,56 @@ async function loadControl() {
document.getElementById('take-control').addEventListener('click', takeControl);
// ---- Advanced configuration: color palette ----
//
// Hex field and R/G/B number fields are kept in sync live, both
// directions -- editing either updates the other plus the preview
// swatch. Hex stays the field actually read at save time (it's what
// the server already validates as #rrggbb); the R/G/B fields are purely
// an alternate, more precise way to arrive at the same value than
// eyeballing a color-picker swatch.
function paletteInputs() {
return Array.from(document.querySelectorAll('[id^="palette_"]'))
.sort((a, b) => Number(a.id.split('_')[1]) - Number(b.id.split('_')[1]));
function paletteHexInputs() {
return Array.from(document.querySelectorAll('.palette-hex'))
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
}
function hexFromRgb(r, g, b) {
const clamp = (v) => Math.max(0, Math.min(255, Math.round(Number(v) || 0)));
return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('');
}
function rgbFromHex(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function paletteFieldsFor(index) {
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
}
function syncPaletteFromHex(index) {
const f = paletteFieldsFor(index);
const rgb = rgbFromHex(f.hex.value);
if (!rgb) return;
[f.r.value, f.g.value, f.b.value] = rgb;
f.swatch.style.background = f.hex.value;
}
function syncPaletteFromRgb(index) {
const f = paletteFieldsFor(index);
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
f.hex.value = hex;
f.swatch.style.background = hex;
}
const palettePickerCount = paletteHexInputs().length;
for (let i = 0; i < palettePickerCount; i++) {
const f = paletteFieldsFor(i);
f.hex.addEventListener('input', () => syncPaletteFromHex(i));
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
}
async function savePalette(body) {
@@ -89,15 +135,18 @@ async function savePalette(body) {
document.getElementById('palette-save').addEventListener('click', () => {
const body = new URLSearchParams();
for (const input of paletteInputs()) {
for (const input of paletteHexInputs()) {
body.append('palette', input.value);
}
savePalette(body);
});
document.getElementById('palette-reset').addEventListener('click', () => {
const inputs = paletteInputs();
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { inputs[i].value = hex; });
const inputs = paletteHexInputs();
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex(i);
});
savePalette(new URLSearchParams({ palette_reset: 'true' }));
});
+25 -20
View File
@@ -168,28 +168,33 @@ summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.palette-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 12px;
margin-top: 14px;
.palette-table-wrap { overflow-x: auto; margin-top: 14px; }
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.palette-table th {
text-align: left;
color: var(--text-muted);
font-weight: 600;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 0 8px 8px 0;
}
.palette-swatch {
display: flex;
align-items: center;
gap: 8px;
margin-top: 0;
font-size: 13px;
font-weight: 500;
color: var(--text);
.palette-table td { padding: 4px 8px 4px 0; vertical-align: middle; }
.palette-table input { margin-top: 0; }
.palette-swatch-preview {
display: block;
width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid var(--border);
}
.palette-swatch input[type="color"] {
width: 34px;
height: 34px;
padding: 2px;
margin-top: 0;
border-radius: 8px;
cursor: pointer;
.palette-table input.palette-hex {
width: 92px;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
text-transform: lowercase;
}
.palette-table input.palette-rgb {
width: 58px;
}
.card {
+24 -8
View File
@@ -120,14 +120,30 @@
aren't published. Tune them by comparing a rendered photo against
the physical panel; different panel units can vary enough to be
worth calibrating per frame.</p>
<div class="palette-grid">
{% set current_hex = palette_to_hex(frame.palette_rgb or default_palette_rgb) %}
{% for label in palette_labels %}
<label class="palette-swatch">
<input type="color" id="palette_{{ loop.index0 }}" value="{{ current_hex[loop.index0] }}">
{{ label }}
</label>
{% endfor %}
<div class="palette-table-wrap">
<table class="palette-table">
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
<tbody>
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% set current_hex = palette_to_hex(current_palette) %}
{% for label in palette_labels %}
<tr>
<td><span class="palette-swatch-preview" data-index="{{ loop.index0 }}"
style="background: {{ current_hex[loop.index0] }};"></span></td>
<td>{{ label }}</td>
<td><input type="text" class="palette-hex" id="palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
value="{{ current_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
spellcheck="false" autocomplete="off"></td>
<td><input type="number" class="palette-rgb palette-r" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_palette[loop.index0][0] }}"></td>
<td><input type="number" class="palette-rgb palette-g" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_palette[loop.index0][1] }}"></td>
<td><input type="number" class="palette-rgb palette-b" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_palette[loop.index0][2] }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="button" class="secondary" id="palette-save">Save</button>
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>