From 4500b4180849371cdc22d7d82090d5b44e866c8d Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 20:43:26 -0400 Subject: [PATCH 01/13] Use one color picker for all colors A row of color buttons above the picker picks which color it edits. Only the colors in use are shown, followed by a single "+" that adds the next color. Colors 2 and up get a Remove button; removing a color moves the later ones down. This replaces scrolling through a full picker per color and the checkbox(es) for turning extra colors on. The original color fields stay in the page with only the selected one shown, so reading, writing and file handling are unchanged. Works with the single "Additional Colors" checkbox (colors 2-4 move up next to Color 1) and with a checkbox per color. Formats with one color keep the plain Color 1 picker. Made with help from Claude --- public/colorslots.js | 280 +++++++++++++++++++++++++++++++++++++++++++ public/index.html | 1 + 2 files changed, 281 insertions(+) create mode 100644 public/colorslots.js diff --git a/public/colorslots.js b/public/colorslots.js new file mode 100644 index 0000000..2021740 --- /dev/null +++ b/public/colorslots.js @@ -0,0 +1,280 @@ +// One color picker for all colors: a row of color buttons picks which color +// the picker edits, and "+" adds the next color. The original color fields +// stay in the page (only one is shown at a time), so reading, writing and +// saving tags keep working through them unchanged. +// +// Works with both color layouts: +// - one "Additional Colors" checkbox for colors 2-4 (white = unused) +// - a checkbox per extra color (colorEnable2..N) + +const ColorSlots = { + active: 1, // color the picker is editing + count: 1, // colors in use (single-checkbox layout only) + root: null, + actions: null, + busy: false, // ignore our own calls into app while changing colors + + perColor() { return !!document.getElementById('colorEnable2'); }, + + group(i) { + if (i === 1) { + const el = document.getElementById('colorHex1'); + return el && el.closest('.form-group'); + } + return document.querySelector(`.form-group[data-field="colorHex${i}"]`); + }, + + // Colors the current format can hold + max() { + let n = 1; + while (document.getElementById(`colorHex${n + 1}`)) { + const g = this.group(n + 1); + if (!g || g.classList.contains('hidden')) break; + n++; + } + return n; + }, + + // Colors in use + used() { + if (this.perColor()) { + let n = 1; + while (document.getElementById(`colorEnable${n + 1}`) && document.getElementById(`colorEnable${n + 1}`).checked) n++; + return n; + } + return this.count; + }, + + // Single-checkbox layout: colors in use, from the values (white = unused) + countFromValues() { + const box = document.getElementById('showAdditionalColors'); + if (!box || !box.checked) return 1; + let n = 1; + for (let i = 2; document.getElementById(`colorHex${i}`); i++) { + const v = document.getElementById(`colorHex${i}`).value.replace('#', '').toUpperCase(); + if (/^[0-9A-F]{6}$/.test(v) && v !== 'FFFFFF') n = i; + } + return n; + }, + + hex(i) { + const v = (document.getElementById(`colorHex${i}`).value || '').replace('#', '').toUpperCase(); + return /^[0-9A-F]{6}$/.test(v) ? v : 'FFFFFF'; + }, + + setHex(i, v) { + document.getElementById(`colorHex${i}`).value = v; + app.updateColor('#' + v, i); + }, + + setAdditional(on) { + const box = document.getElementById('showAdditionalColors'); + if (!box) return; + box.checked = on; + app.toggleAdditionalColors(on); + }, + + select(i) { + this.active = i; + this.render(); + }, + + add() { + const n = this.used() + 1; + if (n > this.max()) return; + this.busy = true; + this.setHex(n, 'FFFFFF'); + if (this.perColor()) { + app.setColorEnabled(n, true); + } else { + this.count = n; + this.setAdditional(true); + } + this.busy = false; + this.active = n; + app.updateRecordSize(); + this.render(); + }, + + // Remove color k; later colors move down one + remove(k) { + const n = this.used(); + if (k < 2 || k > n) return; + this.busy = true; + for (let j = k; j < n; j++) this.setHex(j, this.hex(j + 1)); + this.setHex(n, 'FFFFFF'); + if (this.perColor()) { + app.setColorEnabled(n, false); + } else { + this.count = n - 1; + if (this.count === 1) this.setAdditional(false); + } + this.busy = false; + this.active = Math.min(k, n - 1); + app.updateRecordSize(); + this.render(); + }, + + // Update just the button colors (called while dragging the picker) + paint() { + if (!this.root) return; + this.root.querySelectorAll('.cs-slot[data-index]').forEach(el => { + el.querySelector('.cs-sw').style.background = '#' + this.hex(+el.dataset.index); + }); + }, + + render() { + if (!this.root) return; + const max = this.max(); + const on = max > 1; + document.body.classList.toggle('cs-on', on); + this.root.classList.toggle('cs-hidden', !on); + const n = Math.min(this.used(), max); + if (this.active > n) this.active = n; + + // Show only the color being edited + for (let i = 1; document.getElementById(`colorHex${i}`); i++) { + const g = this.group(i); + if (!g) continue; + g.classList.add('cs-group'); + g.classList.toggle('cs-hidden', on && i !== this.active); + } + + const grid = this.root.querySelector('.cs-grid'); + grid.innerHTML = ''; + for (let i = 1; i <= n; i++) { + const b = document.createElement('div'); + b.className = 'material-swatch cs-slot' + (i === this.active ? ' cs-active' : ''); + b.dataset.index = i; + b.setAttribute('role', 'button'); + b.setAttribute('aria-label', `Color ${i}`); + b.innerHTML = ''; + b.querySelector('.cs-num').textContent = i; + b.addEventListener('click', () => this.select(i)); + grid.appendChild(b); + } + if (n < max) { + const plus = document.createElement('div'); + plus.className = 'material-swatch cs-slot cs-add'; + plus.setAttribute('role', 'button'); + plus.setAttribute('aria-label', `Add color ${n + 1}`); + plus.textContent = '+'; + plus.addEventListener('click', () => this.add()); + grid.appendChild(plus); + } + this.paint(); + + const editing = this.root.querySelector('.cs-editing span'); + editing.textContent = this.active === 1 ? 'Color 1 (Primary)' : `Color ${this.active}`; + + // Remove button under the picker for colors 2+ + const g = this.group(this.active); + if (on && this.active > 1 && g) { + g.insertAdjacentElement('afterend', this.actions); + const btn = this.actions.querySelector('button'); + btn.textContent = `🗑 Remove Color ${this.active}`; + this.actions.classList.remove('cs-hidden'); + } else { + this.actions.classList.add('cs-hidden'); + } + this.followSavedColors(); + + // Canvases that were hidden need resizing to their real width + requestAnimationFrame(() => window.dispatchEvent(new Event('resize'))); + }, + + // When a saved color hides the pickers, hide the buttons too + followSavedColors() { + const g1 = this.group(1); + const hidden = !!(g1 && g1.classList.contains('saved-color-hidden')); + this.root.classList.toggle('cs-saved-hidden', hidden); + this.actions.classList.toggle('cs-saved-hidden', hidden); + }, + + wrap(name, after) { + const original = app[name]; + if (typeof original !== 'function') return; + app[name] = function (...args) { + const result = original.apply(this, args); + after(args); + return result; + }; + }, + + init() { + const g1 = this.group(1); + if (!g1) return; + + const style = document.createElement('style'); + style.textContent = ` + .cs-hidden, .cs-saved-hidden { display: none !important; } + body.cs-on .cs-group > label { display: none; } + body.cs-on .cs-group { margin-bottom: 0.5rem; } + body.cs-on #showAdditionalColors { display: none; } + body.cs-on .cs-extra-hint { display: none !important; } + #colorSlots .cs-grid { margin-bottom: 0.5rem; } + #colorSlots .cs-slot { flex-direction: column; align-items: stretch; justify-content: flex-end; gap: 0.2rem; padding: 0.3rem; min-height: 58px; font-weight: 700; } + #colorSlots .cs-sw { flex: 1; min-height: 26px; border-radius: 3px; border: 1px solid rgba(255, 255, 255, 0.25); } + #colorSlots .cs-num { text-align: center; line-height: 1; font-size: 0.8rem; } + #colorSlots .cs-slot.cs-active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(255, 107, 53, 0.35); } + #colorSlots .cs-slot.cs-active .cs-num { color: var(--accent); } + #colorSlots .cs-add { justify-content: center; align-items: center; border-style: dashed; font-size: 1.4rem; font-weight: 400; color: var(--text-secondary); } + #colorSlots .cs-editing { font-size: 0.85rem; font-weight: 600; margin-bottom: 0; } + #colorSlots .cs-editing span { color: var(--accent); } + #colorSlotsActions button { width: auto; margin: 0 0 1rem; padding: 0.3rem 0.6rem; font-size: 0.8rem; } + `; + document.head.appendChild(style); + + this.root = document.createElement('div'); + this.root.id = 'colorSlots'; + this.root.className = 'form-group'; + this.root.innerHTML = '
Editing
'; + this.actions = document.createElement('div'); + this.actions.id = 'colorSlotsActions'; + this.actions.innerHTML = ''; + this.actions.querySelector('button').addEventListener('click', () => this.remove(this.active)); + + // Single-checkbox layout keeps colors 2+ in the Advanced section: + // move them up next to Color 1 so they share its place + let last = g1; + for (let i = 2; document.getElementById(`colorHex${i}`); i++) { + const g = this.group(i); + if (!g) break; + if (g.previousElementSibling !== last) last.insertAdjacentElement('afterend', g); + last = g; + } + // Hide the old ways of turning extra colors on + const box = document.getElementById('showAdditionalColors'); + if (box) box.closest('.form-group').classList.add('cs-extra-hint'); + document.querySelectorAll('small[data-field="colorHex2"]').forEach(el => el.classList.add('cs-extra-hint')); + + // Follow changes made elsewhere (loading a tag, format change, saved colors) + this.wrap('populateForm', () => { + this.active = 1; + if (!this.perColor()) this.count = this.countFromValues(); + this.render(); + }); + this.wrap('updateVisibility', () => this.render()); + this.wrap('updateColorSlots', () => { if (!this.busy) this.render(); }); + this.wrap('toggleAdditionalColors', () => { + if (this.busy) return; + this.count = this.countFromValues(); + this.render(); + }); + this.wrap('updateColor', () => this.paint()); + new MutationObserver(() => this.followSavedColors()) + .observe(g1, { attributes: true, attributeFilter: ['class'] }); + + // Placed once every script has run, so it sits just above Color 1 + // (below Saved Colors, when that's on the page) + const place = () => { + g1.insertAdjacentElement('beforebegin', this.root); + if (!this.perColor()) this.count = this.countFromValues(); + this.render(); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', place); + else place(); + } +}; + +ColorSlots.init(); diff --git a/public/index.html b/public/index.html index d2c871b..da04f3e 100644 --- a/public/index.html +++ b/public/index.html @@ -832,5 +832,6 @@

✏️ Create New Tag

+ From 5e76d74aea03faad9259802c8f3c1e5036a56a09 Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 20:46:06 -0400 Subject: [PATCH 02/13] One color picker: hide per-color checkbox labels Their inline display style was overriding the rule that hides them. Made with help from Claude --- public/colorslots.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/colorslots.js b/public/colorslots.js index 2021740..f0b28f2 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -208,7 +208,7 @@ const ColorSlots = { const style = document.createElement('style'); style.textContent = ` .cs-hidden, .cs-saved-hidden { display: none !important; } - body.cs-on .cs-group > label { display: none; } + body.cs-on .cs-group > label { display: none !important; } body.cs-on .cs-group { margin-bottom: 0.5rem; } body.cs-on #showAdditionalColors { display: none; } body.cs-on .cs-extra-hint { display: none !important; } From 2a226e9d360a916a85512e6ed19f8ac6318a0193 Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 20:46:52 -0400 Subject: [PATCH 03/13] One color picker: tighten spacing under the color buttons Made with help from Claude --- public/colorslots.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/colorslots.js b/public/colorslots.js index f0b28f2..b458cba 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -212,6 +212,8 @@ const ColorSlots = { body.cs-on .cs-group { margin-bottom: 0.5rem; } body.cs-on #showAdditionalColors { display: none; } body.cs-on .cs-extra-hint { display: none !important; } + #colorSlots { margin-bottom: 0.5rem; } + body.cs-on .cs-group [id^="colorFields"] { margin-top: 0 !important; } #colorSlots .cs-grid { margin-bottom: 0.5rem; } #colorSlots .cs-slot { flex-direction: column; align-items: stretch; justify-content: flex-end; gap: 0.2rem; padding: 0.3rem; min-height: 58px; font-weight: 700; } #colorSlots .cs-sw { flex: 1; min-height: 26px; border-radius: 3px; border: 1px solid rgba(255, 255, 255, 0.25); } From f44017608fc466a6854f7fb0eaf1dbff6755cf8a Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 21:05:21 -0400 Subject: [PATCH 04/13] One color picker: allow removing Color 1 With two or more colors, any color can be removed, including Color 1; the later colors move down one. With a single color there's no Remove button. Made with help from Claude --- public/colorslots.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/public/colorslots.js b/public/colorslots.js index b458cba..e3b7236 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -96,10 +96,11 @@ const ColorSlots = { this.render(); }, - // Remove color k; later colors move down one + // Remove color k (any color, while there's more than one); later + // colors move down one remove(k) { const n = this.used(); - if (k < 2 || k > n) return; + if (n < 2 || k < 1 || k > n) return; this.busy = true; for (let j = k; j < n; j++) this.setHex(j, this.hex(j + 1)); this.setHex(n, 'FFFFFF'); @@ -167,9 +168,9 @@ const ColorSlots = { const editing = this.root.querySelector('.cs-editing span'); editing.textContent = this.active === 1 ? 'Color 1 (Primary)' : `Color ${this.active}`; - // Remove button under the picker for colors 2+ + // Remove button under the picker, while there's more than one color const g = this.group(this.active); - if (on && this.active > 1 && g) { + if (on && n > 1 && g) { g.insertAdjacentElement('afterend', this.actions); const btn = this.actions.querySelector('button'); btn.textContent = `🗑 Remove Color ${this.active}`; From e37ddbeb3b4409f2f5b98f72d8ec4371885a4090 Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 21:41:49 -0400 Subject: [PATCH 05/13] One color picker: keyboard, copy color, move, confirm remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Color buttons and + work from the keyboard (Tab, Enter/Space), like the other palettes, and keep focus after a press. - A new color starts as a copy of the previous one instead of white. - "◀ Move left" / "Move right ▶" swap the selected color with its neighbour. - Removing a color asks to confirm first. Made with help from Claude --- public/colorslots.js | 65 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/public/colorslots.js b/public/colorslots.js index e3b7236..0ce6e24 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -13,6 +13,7 @@ const ColorSlots = { root: null, actions: null, busy: false, // ignore our own calls into app while changing colors + refocus: false, // put keyboard focus back on the active button after render perColor() { return !!document.getElementById('colorEnable2'); }, @@ -83,7 +84,8 @@ const ColorSlots = { const n = this.used() + 1; if (n > this.max()) return; this.busy = true; - this.setHex(n, 'FFFFFF'); + // Start from the previous color, so similar shades are a nudge away + this.setHex(n, this.hex(n - 1)); if (this.perColor()) { app.setColorEnabled(n, true); } else { @@ -101,6 +103,7 @@ const ColorSlots = { remove(k) { const n = this.used(); if (n < 2 || k < 1 || k > n) return; + if (!confirm(`Remove Color ${k} (#${this.hex(k)})?`)) return; this.busy = true; for (let j = k; j < n; j++) this.setHex(j, this.hex(j + 1)); this.setHex(n, 'FFFFFF'); @@ -116,6 +119,33 @@ const ColorSlots = { this.render(); }, + // Swap color k with its neighbour (dir -1 = left, +1 = right) + move(k, dir) { + const j = k + dir; + if (j < 1 || j > this.used()) return; + const a = this.hex(k), b = this.hex(j); + this.setHex(k, b); + this.setHex(j, a); + this.active = j; + app.updateRecordSize(); + this.render(); + }, + + // Make a div act like a button for the keyboard too, like the palettes + button(el, label, fn) { + el.setAttribute('role', 'button'); + el.setAttribute('tabindex', '0'); + el.setAttribute('aria-label', label); + el.addEventListener('click', fn); + el.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.refocus = true; + fn(); + } + }); + }, + // Update just the button colors (called while dragging the picker) paint() { if (!this.root) return; @@ -147,33 +177,37 @@ const ColorSlots = { const b = document.createElement('div'); b.className = 'material-swatch cs-slot' + (i === this.active ? ' cs-active' : ''); b.dataset.index = i; - b.setAttribute('role', 'button'); - b.setAttribute('aria-label', `Color ${i}`); + if (i === this.active) b.setAttribute('aria-pressed', 'true'); b.innerHTML = ''; b.querySelector('.cs-num').textContent = i; - b.addEventListener('click', () => this.select(i)); + this.button(b, `Color ${i}`, () => this.select(i)); grid.appendChild(b); } if (n < max) { const plus = document.createElement('div'); plus.className = 'material-swatch cs-slot cs-add'; - plus.setAttribute('role', 'button'); - plus.setAttribute('aria-label', `Add color ${n + 1}`); plus.textContent = '+'; - plus.addEventListener('click', () => this.add()); + this.button(plus, `Add color ${n + 1}`, () => this.add()); grid.appendChild(plus); } this.paint(); + // After a keyboard press, keep focus on the color being edited + if (this.refocus) { + this.refocus = false; + const el = grid.querySelector(`.cs-slot[data-index="${this.active}"]`); + if (el) el.focus(); + } const editing = this.root.querySelector('.cs-editing span'); editing.textContent = this.active === 1 ? 'Color 1 (Primary)' : `Color ${this.active}`; - // Remove button under the picker, while there's more than one color + // Move/Remove buttons under the picker, while there's more than one color const g = this.group(this.active); if (on && n > 1 && g) { g.insertAdjacentElement('afterend', this.actions); - const btn = this.actions.querySelector('button'); - btn.textContent = `🗑 Remove Color ${this.active}`; + this.actions.querySelector('.cs-left').classList.toggle('cs-hidden', this.active === 1); + this.actions.querySelector('.cs-right').classList.toggle('cs-hidden', this.active === n); + this.actions.querySelector('.cs-remove').textContent = `🗑 Remove Color ${this.active}`; this.actions.classList.remove('cs-hidden'); } else { this.actions.classList.add('cs-hidden'); @@ -224,7 +258,8 @@ const ColorSlots = { #colorSlots .cs-add { justify-content: center; align-items: center; border-style: dashed; font-size: 1.4rem; font-weight: 400; color: var(--text-secondary); } #colorSlots .cs-editing { font-size: 0.85rem; font-weight: 600; margin-bottom: 0; } #colorSlots .cs-editing span { color: var(--accent); } - #colorSlotsActions button { width: auto; margin: 0 0 1rem; padding: 0.3rem 0.6rem; font-size: 0.8rem; } + #colorSlotsActions { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 1rem; } + #colorSlotsActions button { width: auto; margin: 0; padding: 0.3rem 0.6rem; font-size: 0.8rem; } `; document.head.appendChild(style); @@ -234,8 +269,12 @@ const ColorSlots = { this.root.innerHTML = '
Editing
'; this.actions = document.createElement('div'); this.actions.id = 'colorSlotsActions'; - this.actions.innerHTML = ''; - this.actions.querySelector('button').addEventListener('click', () => this.remove(this.active)); + this.actions.innerHTML = '' + + '' + + ''; + this.actions.querySelector('.cs-left').addEventListener('click', () => this.move(this.active, -1)); + this.actions.querySelector('.cs-right').addEventListener('click', () => this.move(this.active, 1)); + this.actions.querySelector('.cs-remove').addEventListener('click', () => this.remove(this.active)); // Single-checkbox layout keeps colors 2+ in the Advanced section: // move them up next to Color 1 so they share its place From 3278ee816d83c496c51702c6a06ef1ccf3795bf6 Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 21:50:19 -0400 Subject: [PATCH 06/13] One color picker: Remove first, Move buttons stay in place Remove now comes first in the row, and Move left/right are always shown (greyed out when they can't be used), so repeated taps on a Move button never land on a different button. Made with help from Claude --- public/colorslots.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/public/colorslots.js b/public/colorslots.js index 0ce6e24..8abd25b 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -205,8 +205,8 @@ const ColorSlots = { const g = this.group(this.active); if (on && n > 1 && g) { g.insertAdjacentElement('afterend', this.actions); - this.actions.querySelector('.cs-left').classList.toggle('cs-hidden', this.active === 1); - this.actions.querySelector('.cs-right').classList.toggle('cs-hidden', this.active === n); + this.actions.querySelector('.cs-left').disabled = this.active === 1; + this.actions.querySelector('.cs-right').disabled = this.active === n; this.actions.querySelector('.cs-remove').textContent = `🗑 Remove Color ${this.active}`; this.actions.classList.remove('cs-hidden'); } else { @@ -260,6 +260,7 @@ const ColorSlots = { #colorSlots .cs-editing span { color: var(--accent); } #colorSlotsActions { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 1rem; } #colorSlotsActions button { width: auto; margin: 0; padding: 0.3rem 0.6rem; font-size: 0.8rem; } + #colorSlotsActions button:disabled { opacity: 0.35; } `; document.head.appendChild(style); @@ -269,9 +270,11 @@ const ColorSlots = { this.root.innerHTML = '
Editing
'; this.actions = document.createElement('div'); this.actions.id = 'colorSlotsActions'; - this.actions.innerHTML = '' - + '' - + ''; + // Remove first and the moves always present (greyed out when they + // can't be used), so no button slides under a repeated tap + this.actions.innerHTML = '' + + '' + + ''; this.actions.querySelector('.cs-left').addEventListener('click', () => this.move(this.active, -1)); this.actions.querySelector('.cs-right').addEventListener('click', () => this.move(this.active, 1)); this.actions.querySelector('.cs-remove').addEventListener('click', () => this.remove(this.active)); From e5b4395ded814feddda40c14a1ae567a98a1db2d Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 21:58:19 -0400 Subject: [PATCH 07/13] One color picker: one-line hint, cleaner hex boxes - Every color shows the same one-line hint ("Hold and drag on the picker, or type a hex code"), so the picker no longer moves up and down when switching between Color 1 and the others. - Hex boxes keep only hex digits, switch to upper case, stop at 6 characters and drop a pasted leading #. The cursor stays where it was when a character is rejected. Made with help from Claude --- public/colorslots.js | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/public/colorslots.js b/public/colorslots.js index 8abd25b..d20babe 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -146,6 +146,19 @@ const ColorSlots = { }); }, + // Hex boxes: keep only hex digits, upper case, at most 6. Runs before + // the app's own input handling, and keeps the cursor where it was. + cleanHex(el) { + const raw = el.value; + const clean = raw.replace(/[^0-9a-fA-F]/g, '').toUpperCase().slice(0, 6); + if (clean === raw) return; + const caret = el.selectionStart == null ? raw.length : el.selectionStart; + const before = raw.slice(0, caret).replace(/[^0-9a-fA-F]/g, '').length; + el.value = clean; + const pos = Math.min(before, clean.length); + try { el.setSelectionRange(pos, pos); } catch (e) { /* not focused */ } + }, + // Update just the button colors (called while dragging the picker) paint() { if (!this.root) return; @@ -198,6 +211,11 @@ const ColorSlots = { if (el) el.focus(); } + // Same one-line hint for every color, so the picker doesn't jump + document.querySelectorAll('.cs-hint').forEach(el => { + el.textContent = on ? 'Hold and drag on the picker, or type a hex code' : el.dataset.csText; + }); + const editing = this.root.querySelector('.cs-editing span'); editing.textContent = this.active === 1 ? 'Color 1 (Primary)' : `Color ${this.active}`; @@ -248,6 +266,7 @@ const ColorSlots = { body.cs-on #showAdditionalColors { display: none; } body.cs-on .cs-extra-hint { display: none !important; } #colorSlots { margin-bottom: 0.5rem; } + body.cs-on .cs-hint { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } body.cs-on .cs-group [id^="colorFields"] { margin-top: 0 !important; } #colorSlots .cs-grid { margin-bottom: 0.5rem; } #colorSlots .cs-slot { flex-direction: column; align-items: stretch; justify-content: flex-end; gap: 0.2rem; padding: 0.3rem; min-height: 58px; font-weight: 700; } @@ -288,6 +307,25 @@ const ColorSlots = { if (g.previousElementSibling !== last) last.insertAdjacentElement('afterend', g); last = g; } + // Hex boxes: clean what's typed or pasted (a leading # is dropped) + for (let i = 1; document.getElementById(`colorHex${i}`); i++) { + const el = document.getElementById(`colorHex${i}`); + el.maxLength = 7; // room for a pasted "#RRGGBB"; cleaned to 6 + el.setAttribute('autocapitalize', 'characters'); + el.setAttribute('autocomplete', 'off'); + el.setAttribute('autocorrect', 'off'); + el.spellcheck = false; + const g = this.group(i); + const hint = g && Array.from(g.querySelectorAll('small')).find(sm => /Hold and drag/.test(sm.textContent)); + if (hint) { + hint.classList.add('cs-hint'); + hint.dataset.csText = hint.textContent.trim(); + } + } + document.addEventListener('input', (e) => { + if (e.target && /^colorHex\d$/.test(e.target.id)) this.cleanHex(e.target); + }, true); + // Hide the old ways of turning extra colors on const box = document.getElementById('showAdditionalColors'); if (box) box.closest('.form-group').classList.add('cs-extra-hint'); From 0dbeb1537af7b7d561a66203570de3b6e667ee55 Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 22:13:39 -0400 Subject: [PATCH 08/13] One color picker: expand 3-digit codes, block invalid ones - A 3-digit hex code (e.g. F53) expands to 6 digits (FF5533) when the box is left, when switching, adding or moving colors, and right before writing, downloading or saving. - Any other length (1, 2, 4, 5 digits, or empty) is not a valid color: the box turns red with a message under it, and its color button gets a red outline. Writing, downloading and "Save current colors" are stopped with a status message, and the picker jumps to the first bad color. The error clears as soon as the code is valid. Made with help from Claude --- public/colorslots.js | 121 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/public/colorslots.js b/public/colorslots.js index d20babe..a9eccd8 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -14,6 +14,7 @@ const ColorSlots = { actions: null, busy: false, // ignore our own calls into app while changing colors refocus: false, // put keyboard focus back on the active button after render + touched: new Set(), // colors whose hex box was left, so errors can show perColor() { return !!document.getElementById('colorEnable2'); }, @@ -76,6 +77,7 @@ const ColorSlots = { }, select(i) { + this.expandAll(); this.active = i; this.render(); }, @@ -83,6 +85,7 @@ const ColorSlots = { add() { const n = this.used() + 1; if (n > this.max()) return; + this.expandAll(); this.busy = true; // Start from the previous color, so similar shades are a nudge away this.setHex(n, this.hex(n - 1)); @@ -114,6 +117,7 @@ const ColorSlots = { if (this.count === 1) this.setAdditional(false); } this.busy = false; + this.touched.clear(); this.active = Math.min(k, n - 1); app.updateRecordSize(); this.render(); @@ -123,6 +127,7 @@ const ColorSlots = { move(k, dir) { const j = k + dir; if (j < 1 || j > this.used()) return; + this.expandAll(); const a = this.hex(k), b = this.hex(j); this.setHex(k, b); this.setHex(j, a); @@ -159,6 +164,98 @@ const ColorSlots = { try { el.setSelectionRange(pos, pos); } catch (e) { /* not focused */ } }, + // --- Hex validation: 3 digits expand to 6; anything else but 6 is an error + + // Colors that count right now + inUse() { return Math.min(this.used(), this.max()); }, + + // "F53" -> "FF5533" (in the box, and tell the app) + expand(i) { + const el = document.getElementById(`colorHex${i}`); + if (!el || !/^[0-9A-F]{3}$/i.test(el.value)) return; + el.value = el.value.toUpperCase().split('').map(c => c + c).join(''); + el.dispatchEvent(new Event('input', { bubbles: true })); + }, + + expandAll() { + for (let i = 1; i <= this.inUse(); i++) this.expand(i); + }, + + isValid(i) { + const el = document.getElementById(`colorHex${i}`); + return !el || /^[0-9A-F]{6}$/i.test(el.value); + }, + + invalid() { + const bad = []; + for (let i = 1; i <= this.inUse(); i++) if (!this.isValid(i)) bad.push(i); + return bad; + }, + + // Red box + message under each bad color that's been left (or checked), + // and a red outline on its button + showErrors() { + for (let i = 1; document.getElementById(`colorHex${i}`); i++) { + const el = document.getElementById(`colorHex${i}`); + const bad = i <= this.inUse() && this.touched.has(i) && !this.isValid(i); + el.classList.toggle('cs-invalid', bad); + el.setAttribute('aria-invalid', bad ? 'true' : 'false'); + const msg = this.errorEl(i); + if (msg) msg.classList.toggle('cs-hidden', !bad); + } + if (!this.root) return; + this.root.querySelectorAll('.cs-slot[data-index]').forEach(b => { + const i = +b.dataset.index; + b.classList.toggle('cs-bad', this.touched.has(i) && !this.isValid(i)); + }); + }, + + // The error message element under color i's hex box (made on first use) + errorEl(i) { + let msg = document.getElementById(`colorHexError${i}`); + if (msg) return msg; + const el = document.getElementById(`colorHex${i}`); + const row = el && el.parentElement; + if (!row) return null; + msg = document.createElement('small'); + msg.id = `colorHexError${i}`; + msg.className = 'cs-error cs-hidden'; + msg.textContent = 'Not a valid color: enter 3 or 6 hex digits (e.g. F53 or FF5533)'; + row.insertAdjacentElement('afterend', msg); + return msg; + }, + + // Before writing, downloading or saving: expand short codes, and stop + // (pointing at the first bad color) if any color isn't valid + check(what) { + this.expandAll(); + const bad = this.invalid(); + for (let i = 1; i <= this.inUse(); i++) this.touched.add(i); + this.showErrors(); + if (!bad.length) return true; + const i = bad[0]; + if (this.root && !this.root.classList.contains('cs-hidden')) this.select(i); + const el = document.getElementById(`colorHex${i}`); + if (el && el.offsetParent !== null) { + el.focus(); + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + const which = bad.length === 1 ? `Color ${i} isn't` : `Colors ${bad.join(', ')} aren't`; + app.showStatus('writeStatus', 'error', `${which} a valid hex code, so nothing was ${what}. Use 3 or 6 digits.`); + return false; + }, + + // Run check() before an app (or saved colors) action; skip the action if it fails + guard(obj, name, what) { + const original = obj && obj[name]; + if (typeof original !== 'function') return; + const self = this; + obj[name] = function (...args) { + if (!self.check(what)) return; + return original.apply(this, args); + }; + }, + // Update just the button colors (called while dragging the picker) paint() { if (!this.root) return; @@ -231,6 +328,7 @@ const ColorSlots = { this.actions.classList.add('cs-hidden'); } this.followSavedColors(); + this.showErrors(); // Canvases that were hidden need resizing to their real width requestAnimationFrame(() => window.dispatchEvent(new Event('resize'))); @@ -267,6 +365,10 @@ const ColorSlots = { body.cs-on .cs-extra-hint { display: none !important; } #colorSlots { margin-bottom: 0.5rem; } body.cs-on .cs-hint { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + input.cs-invalid { border-color: #e74c3c !important; box-shadow: 0 0 0 1px #e74c3c; } + .cs-error { display: block; color: #e74c3c; font-size: 0.8rem; margin-top: 0.25rem; } + #colorSlots .cs-slot.cs-bad { border-color: #e74c3c; } + #colorSlots .cs-slot.cs-bad .cs-num { color: #e74c3c; } body.cs-on .cs-group [id^="colorFields"] { margin-top: 0 !important; } #colorSlots .cs-grid { margin-bottom: 0.5rem; } #colorSlots .cs-slot { flex-direction: column; align-items: stretch; justify-content: flex-end; gap: 0.2rem; padding: 0.3rem; min-height: 58px; font-weight: 700; } @@ -325,6 +427,20 @@ const ColorSlots = { document.addEventListener('input', (e) => { if (e.target && /^colorHex\d$/.test(e.target.id)) this.cleanHex(e.target); }, true); + document.addEventListener('input', (e) => { + if (e.target && /^colorHex\d$/.test(e.target.id)) this.showErrors(); + }); + // Leaving a hex box: expand 3 digits, then flag it if it's still not valid + document.addEventListener('focusout', (e) => { + const m = e.target && e.target.id && e.target.id.match(/^colorHex(\d)$/); + if (!m) return; + this.expand(+m[1]); + this.touched.add(+m[1]); + this.showErrors(); + }); + // No writing, downloading or saving with a bad color + this.guard(app, 'writeNFC', 'written'); + this.guard(app, 'downloadFile', 'downloaded'); // Hide the old ways of turning extra colors on const box = document.getElementById('showAdditionalColors'); @@ -334,6 +450,8 @@ const ColorSlots = { // Follow changes made elsewhere (loading a tag, format change, saved colors) this.wrap('populateForm', () => { this.active = 1; + this.touched.clear(); + this.showErrors(); if (!this.perColor()) this.count = this.countFromValues(); this.render(); }); @@ -344,7 +462,7 @@ const ColorSlots = { this.count = this.countFromValues(); this.render(); }); - this.wrap('updateColor', () => this.paint()); + this.wrap('updateColor', () => { this.paint(); this.showErrors(); }); new MutationObserver(() => this.followSavedColors()) .observe(g1, { attributes: true, attributeFilter: ['class'] }); @@ -352,6 +470,7 @@ const ColorSlots = { // (below Saved Colors, when that's on the page) const place = () => { g1.insertAdjacentElement('beforebegin', this.root); + if (typeof SavedColors !== 'undefined') this.guard(SavedColors, 'saveCurrent', 'saved'); if (!this.perColor()) this.count = this.countFromValues(); this.render(); }; From e6ee8e6c5b33c5cfbf278388d43a162f32bdc6ca Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 22:15:56 -0400 Subject: [PATCH 09/13] One color picker: don't swallow a tap right after typing a hex code Leaving a hex box updates the page, which redrew the color buttons in the middle of the tap that left it, so the first tap on + or another color could be lost. The buttons are now only redrawn when the number of colors or the selected color changes. Made with help from Claude --- public/colorslots.js | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/public/colorslots.js b/public/colorslots.js index a9eccd8..d27a973 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -14,6 +14,7 @@ const ColorSlots = { actions: null, busy: false, // ignore our own calls into app while changing colors refocus: false, // put keyboard focus back on the active button after render + layout: '', // last button layout drawn (count|max|active) touched: new Set(), // colors whose hex box was left, so errors can show perColor() { return !!document.getElementById('colorEnable2'); }, @@ -282,23 +283,29 @@ const ColorSlots = { } const grid = this.root.querySelector('.cs-grid'); - grid.innerHTML = ''; - for (let i = 1; i <= n; i++) { - const b = document.createElement('div'); - b.className = 'material-swatch cs-slot' + (i === this.active ? ' cs-active' : ''); - b.dataset.index = i; - if (i === this.active) b.setAttribute('aria-pressed', 'true'); - b.innerHTML = ''; - b.querySelector('.cs-num').textContent = i; - this.button(b, `Color ${i}`, () => this.select(i)); - grid.appendChild(b); - } - if (n < max) { - const plus = document.createElement('div'); - plus.className = 'material-swatch cs-slot cs-add'; - plus.textContent = '+'; - this.button(plus, `Add color ${n + 1}`, () => this.add()); - grid.appendChild(plus); + // Rebuild the buttons only when they change: rebuilding mid-tap (e.g. + // when leaving a hex box updates the page) would swallow the tap + const layout = `${n}|${max}|${this.active}`; + if (layout !== this.layout || !grid.children.length) { + this.layout = layout; + grid.innerHTML = ''; + for (let i = 1; i <= n; i++) { + const b = document.createElement('div'); + b.className = 'material-swatch cs-slot' + (i === this.active ? ' cs-active' : ''); + b.dataset.index = i; + if (i === this.active) b.setAttribute('aria-pressed', 'true'); + b.innerHTML = ''; + b.querySelector('.cs-num').textContent = i; + this.button(b, `Color ${i}`, () => this.select(i)); + grid.appendChild(b); + } + if (n < max) { + const plus = document.createElement('div'); + plus.className = 'material-swatch cs-slot cs-add'; + plus.textContent = '+'; + this.button(plus, `Add color ${n + 1}`, () => this.add()); + grid.appendChild(plus); + } } this.paint(); // After a keyboard press, keep focus on the color being edited From 8e1de2579fb1ac52cf59e963ae8226e446785a12 Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 22:18:35 -0400 Subject: [PATCH 10/13] One color picker: show invalid codes as "not a color" While a hex code isn't valid, its preview box and color button show a red-striped pattern instead of a color (the browser would show e.g. "C039" as a see-through color, and the button showed white). A 3-digit code is shown as its expanded color. A bad color's button keeps its red outline even when selected. Made with help from Claude --- public/colorslots.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/public/colorslots.js b/public/colorslots.js index d27a973..3cef014 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -258,10 +258,26 @@ const ColorSlots = { }, // Update just the button colors (called while dragging the picker) + // What to show for color i: its color (3 digits shown expanded), or a + // red-striped "not a color" pattern while the code isn't valid + swatch(i) { + const el = document.getElementById(`colorHex${i}`); + const v = el ? el.value.replace('#', '') : ''; + if (/^[0-9A-F]{6}$/i.test(v)) return '#' + v; + if (/^[0-9A-F]{3}$/i.test(v)) return '#' + v.split('').map(c => c + c).join(''); + return 'repeating-linear-gradient(45deg, rgba(231, 76, 60, 0.6) 0 5px, transparent 5px 10px)'; + }, + paint() { + // The preview boxes next to the hex codes (the browser would otherwise + // show e.g. "C039" as a see-through color) + for (let i = 1; document.getElementById(`colorHex${i}`); i++) { + const preview = document.getElementById(`colorPreview${i}`); + if (preview) preview.style.background = this.swatch(i); + } if (!this.root) return; this.root.querySelectorAll('.cs-slot[data-index]').forEach(el => { - el.querySelector('.cs-sw').style.background = '#' + this.hex(+el.dataset.index); + el.querySelector('.cs-sw').style.background = this.swatch(+el.dataset.index); }); }, @@ -374,7 +390,8 @@ const ColorSlots = { body.cs-on .cs-hint { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } input.cs-invalid { border-color: #e74c3c !important; box-shadow: 0 0 0 1px #e74c3c; } .cs-error { display: block; color: #e74c3c; font-size: 0.8rem; margin-top: 0.25rem; } - #colorSlots .cs-slot.cs-bad { border-color: #e74c3c; } + #colorSlots .cs-slot.cs-bad, #colorSlots .cs-slot.cs-bad.cs-active { border-color: #e74c3c; } + #colorSlots .cs-slot.cs-bad.cs-active { box-shadow: 0 0 0 2px rgba(231, 76, 60, 0.35); } #colorSlots .cs-slot.cs-bad .cs-num { color: #e74c3c; } body.cs-on .cs-group [id^="colorFields"] { margin-top: 0 !important; } #colorSlots .cs-grid { margin-bottom: 0.5rem; } From ad7802c382e27e25b987cf17b0c0502cfa815d4f Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 22:19:57 -0400 Subject: [PATCH 11/13] One color picker: shorter error, red number on a bad selected color Made with help from Claude --- public/colorslots.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/colorslots.js b/public/colorslots.js index 3cef014..c71c593 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -221,7 +221,7 @@ const ColorSlots = { msg = document.createElement('small'); msg.id = `colorHexError${i}`; msg.className = 'cs-error cs-hidden'; - msg.textContent = 'Not a valid color: enter 3 or 6 hex digits (e.g. F53 or FF5533)'; + msg.textContent = 'Not a valid color: use 3 or 6 hex digits'; row.insertAdjacentElement('afterend', msg); return msg; }, @@ -392,7 +392,7 @@ const ColorSlots = { .cs-error { display: block; color: #e74c3c; font-size: 0.8rem; margin-top: 0.25rem; } #colorSlots .cs-slot.cs-bad, #colorSlots .cs-slot.cs-bad.cs-active { border-color: #e74c3c; } #colorSlots .cs-slot.cs-bad.cs-active { box-shadow: 0 0 0 2px rgba(231, 76, 60, 0.35); } - #colorSlots .cs-slot.cs-bad .cs-num { color: #e74c3c; } + #colorSlots .cs-slot.cs-bad .cs-num, #colorSlots .cs-slot.cs-bad.cs-active .cs-num { color: #e74c3c; } body.cs-on .cs-group [id^="colorFields"] { margin-top: 0 !important; } #colorSlots .cs-grid { margin-bottom: 0.5rem; } #colorSlots .cs-slot { flex-direction: column; align-items: stretch; justify-content: flex-end; gap: 0.2rem; padding: 0.3rem; min-height: 58px; font-weight: 700; } From 82e8723e4088ab9ab6797914d0eca3bc3cfe5f76 Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 22:39:44 -0400 Subject: [PATCH 12/13] One color picker: pull the code out of pasted text The box's 7-character limit cut pastes like "##FF5733" short before they could be cleaned, leaving 5 digits. Pasting now picks the first stand-alone 3, 6 or 8 digit hex code out of the pasted text ("##FF5733", "0xFF5733", " #ff5733cc ", "Color: #FF5733") and puts it in the box, replacing what was there; alpha is dropped. Clipboard text inserted by a keyboard without a paste event is handled the same way. The box has no browser length limit any more; typing still stops at 6 digits. Made with help from Claude --- public/colorslots.js | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/public/colorslots.js b/public/colorslots.js index c71c593..fba164a 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -154,6 +154,21 @@ const ColorSlots = { // Hex boxes: keep only hex digits, upper case, at most 6. Runs before // the app's own input handling, and keeps the cursor where it was. + // Pull a color code out of pasted text: the first stand-alone run of 3, 6 + // or 8 hex digits (8 = with alpha, which is dropped). Handles "##FF5733", + // "0xFF5733", " #ff5733cc ", "Color: #FF5733" and the like. + extractCode(text) { + const m = String(text || '').match(/(? { + if (!e.target || !/^colorHex\d$/.test(e.target.id)) return; + const code = this.extractCode(e.clipboardData && e.clipboardData.getData('text')); + if (!code) return; // nothing that looks like a code: clean as typed + e.preventDefault(); + this.pasteCode(e.target, code); + }, true); document.addEventListener('input', (e) => { - if (e.target && /^colorHex\d$/.test(e.target.id)) this.cleanHex(e.target); + if (!e.target || !/^colorHex\d$/.test(e.target.id)) return; + // Keyboards that insert clipboard text without a paste event + // (e.g. a clipboard suggestion) send it as one multi-character input + if (e.data && e.data.length > 1) { + const code = this.extractCode(e.data); + if (code) { + e.target.value = code; + try { e.target.setSelectionRange(code.length, code.length); } catch (err) { /* not focused */ } + return; + } + } + this.cleanHex(e.target); }, true); document.addEventListener('input', (e) => { if (e.target && /^colorHex\d$/.test(e.target.id)) this.showErrors(); From ac19dac25eb144ef71bf0581720237a03547888d Mon Sep 17 00:00:00 2001 From: Rrrrobert Date: Sat, 17 Oct 2026 22:44:04 -0400 Subject: [PATCH 13/13] One color picker: no error for 3-digit codes, expand pasted ones A 3-digit code no longer shows the "not a valid color" error while it's still in the box (it's expanded on leaving, or before writing/saving), and a pasted 3-digit code like "#abc" is expanded to AABBCC right away. Made with help from Claude --- public/colorslots.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/public/colorslots.js b/public/colorslots.js index fba164a..4a7b9e7 100644 --- a/public/colorslots.js +++ b/public/colorslots.js @@ -155,11 +155,14 @@ const ColorSlots = { // Hex boxes: keep only hex digits, upper case, at most 6. Runs before // the app's own input handling, and keeps the cursor where it was. // Pull a color code out of pasted text: the first stand-alone run of 3, 6 - // or 8 hex digits (8 = with alpha, which is dropped). Handles "##FF5733", - // "0xFF5733", " #ff5733cc ", "Color: #FF5733" and the like. + // or 8 hex digits (8 = with alpha, which is dropped; 3 is expanded to 6, + // since a paste is complete). Handles "##FF5733", "0xFF5733", + // " #ff5733cc ", "Color: #FF5733", "#abc" and the like. extractCode(text) { const m = String(text || '').match(/(? c + c).join('') : code.slice(0, 6); }, // Put a pasted code in the box (replacing what was there) and tell the app @@ -197,9 +200,10 @@ const ColorSlots = { for (let i = 1; i <= this.inUse(); i++) this.expand(i); }, + // 6 digits, or 3 (expanded when the box is left or before writing/saving) isValid(i) { const el = document.getElementById(`colorHex${i}`); - return !el || /^[0-9A-F]{6}$/i.test(el.value); + return !el || /^([0-9A-F]{3}|[0-9A-F]{6})$/i.test(el.value); }, invalid() {