From e802882fc10f54b438ae6fb63eaf37ca272b07e7 Mon Sep 17 00:00:00 2001
From: Thomas Faour
Date: Wed, 22 Jul 2026 01:26:03 -0400
Subject: [PATCH] Palette calibration: precise hex/RGB inputs instead of a
color picker
A native 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.
---
server/app/static/frame_config.js | 61 +++++++++++++++++++++++---
server/app/static/theme.css | 45 ++++++++++---------
server/app/templates/frame_config.html | 32 ++++++++++----
3 files changed, 104 insertions(+), 34 deletions(-)
diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js
index bf6fc11..bbaf73f 100644
--- a/server/app/static/frame_config.js
+++ b/server/app/static/frame_config.js
@@ -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' }));
});
diff --git a/server/app/static/theme.css b/server/app/static/theme.css
index 716b160..e337bae 100644
--- a/server/app/static/theme.css
+++ b/server/app/static/theme.css
@@ -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 {
diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html
index 018bea0..62a5505 100644
--- a/server/app/templates/frame_config.html
+++ b/server/app/templates/frame_config.html
@@ -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.
-
- {% set current_hex = palette_to_hex(frame.palette_rgb or default_palette_rgb) %}
- {% for label in palette_labels %}
-
- {% endfor %}
+
+
+
Color
Hex
R
G
B
+
+ {% set current_palette = frame.palette_rgb or default_palette_rgb %}
+ {% set current_hex = palette_to_hex(current_palette) %}
+ {% for label in palette_labels %}
+