Palette calibration: precise hex/RGB inputs instead of a color picker
Build and push server image / build-and-push (push) Successful in 41s
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user