From 836c83b8f4e2c526b374c2f0c463b57918072d1e Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:09:41 -0400 Subject: [PATCH] Fix: accept a hex color typed without its leading hash Detect an invalid CSS color with two seeded fillStyle reads, so an unparseable value no longer resolves to black. Add the leading hash to a bare 3 to 8 digit hex code in the color control and in the background color field, and warn when a value is not a color. --- CHANGELOG.md | 3 +++ ui/admin/index.html | 2 +- ui/i18n/de.json | 1 + ui/i18n/en.json | 1 + ui/i18n/es.json | 1 + ui/i18n/fa.json | 1 + ui/i18n/fr.json | 1 + ui/i18n/zh-Hans.json | 1 + ui/js/admin-app-form.js | 2 +- ui/js/admin-color-control.js | 41 +++++++++++++++++++++++++++------- ui/js/admin-widget-form.js | 2 +- ui/js/admin.js | 17 +++++++++++--- ui/js/widget-config-form.js | 2 +- ui/test/color-control.test.mjs | 39 +++++++++++++++++++++++++------- 14 files changed, 91 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e87013..04a103fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Color fields now accept a hex code typed without its leading `#`, and say so + when a value is not a color. + - Apps with a container check no longer show an unhealthy badge while Docker container health checks are off. diff --git a/ui/admin/index.html b/ui/admin/index.html index e83f5eee..7decc1cf 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -303,6 +303,6 @@

General

- + diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 524869a5..425df273 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -198,6 +198,7 @@ "toast": { "saved": "Gespeichert", "saveFailed": "Speichern fehlgeschlagen: {err}", + "colorInvalid": "Das ist keine Farbe. Verwende #rrggbb oder einen CSS-Farbnamen.", "imported": "Importiert", "importFailed": "Import fehlgeschlagen: {err}", "exportFailed": "Export fehlgeschlagen: {err}", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 3e97e8ac..07163c91 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -198,6 +198,7 @@ "toast": { "saved": "Saved", "saveFailed": "Save failed: {err}", + "colorInvalid": "That is not a color. Use #rrggbb or a CSS color name.", "imported": "Imported", "importFailed": "Import failed: {err}", "exportFailed": "Export failed: {err}", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index fe763f52..59cda46b 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -198,6 +198,7 @@ "toast": { "saved": "Guardado", "saveFailed": "Error al guardar: {err}", + "colorInvalid": "Eso no es un color. Usa #rrggbb o un nombre de color CSS.", "imported": "Importado", "importFailed": "Error al importar: {err}", "exportFailed": "Error al exportar: {err}", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index a393d9e2..031ddb92 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -198,6 +198,7 @@ "toast": { "saved": "ذخیره شد", "saveFailed": "ذخیره ناموفق بود: {err}", + "colorInvalid": "این یک رنگ نیست. از #rrggbb یا نام رنگ CSS استفاده کنید.", "imported": "وارد شد", "importFailed": "Import ناموفق بود: {err}", "exportFailed": "Export ناموفق بود: {err}", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index b0ca05b7..e5ac48f7 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -198,6 +198,7 @@ "toast": { "saved": "Enregistré", "saveFailed": "Échec de l'enregistrement : {err}", + "colorInvalid": "Ceci n'est pas une couleur. Utilisez #rrggbb ou un nom de couleur CSS.", "imported": "Importé", "importFailed": "Échec de l'import : {err}", "exportFailed": "Échec de l'export : {err}", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 8334cc1e..88ccda30 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -198,6 +198,7 @@ "toast": { "saved": "已保存", "saveFailed": "保存失败:{err}", + "colorInvalid": "这不是颜色。请使用 #rrggbb 或 CSS 颜色名称。", "imported": "已导入", "importFailed": "Import 失败:{err}", "exportFailed": "Export 失败:{err}", diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 9dccbb3e..21c848e4 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -14,7 +14,7 @@ import { setTogDisabled, wireChecklist, } from '/js/admin-shared.js?v=3d2627a9'; -import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=3a3a1bad'; +import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=5fb6a01b'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1'; export function buildFolderForm(body, item) { diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index bb7a5880..cf242a88 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,4 +1,5 @@ -import { PE_SVG, initInlineEdit } from '/js/admin-shared.js?v=3d2627a9'; +import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=3d2627a9'; +import { t } from '/js/i18n.js?v=d056c9c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=b18c93ed'; @@ -52,21 +53,44 @@ function _hsvToHex(h, s, v) { /* Any CSS colour to lowercase #rrggbb, or null. Callers compare these strings, so the case must stay stable. */ const _HEX6 = /^#[0-9a-f]{6}$/i; +/* An invalid assignment to fillStyle leaves the previous value, so a single + read cannot tell "black" from "not a colour". Two seeds can: only an + assignment that took effect makes both reads agree. */ +function _cssParse(str) { + const c = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d')); + const read = seed => { + c.fillStyle = seed; + c.fillStyle = str; + return String(c.fillStyle); + }; + const first = read('#000000'); + return first === read('#ffffff') ? first : null; +} function _cssToHex(str) { const direct = String(str ?? '').trim(); if (_HEX6.test(direct)) return direct.toLowerCase(); try { - const c = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d')); - c.fillStyle = '#000'; - c.fillStyle = str; - const v = String(c.fillStyle); - if (_HEX6.test(v)) return v; + const v = _cssParse(str); + if (v === null) return null; + if (_HEX6.test(v)) return v.toLowerCase(); const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); return m ? '#' + [m[1], m[2], m[3]].map(n => (+n).toString(16).padStart(2, '0')).join('') : null; } catch { return null; } } + +const _BARE_HEX = /^[0-9a-f]{3,8}$/i; +/** A typed colour, with a missing leading hash supplied. + @param {string} str + @returns {{ value: string, ok: boolean }} */ +export function normalizeColorInput(str) { + const v = String(str ?? '').trim(); + if (!v) return { value: '', ok: false }; + if (_cssToHex(v)) return { value: v, ok: true }; + if (_BARE_HEX.test(v) && _cssToHex('#' + v)) return { value: '#' + v, ok: true }; + return { value: v, ok: false }; +} function _hexToHsv(hex) { const h6 = _cssToHex(hex); if (!h6) return null; @@ -213,14 +237,15 @@ export function renderColorControl( root: container, placeholder: '#rrggbb or any CSS color', onCommit(val) { - const hv = _hexToHsv(val); + const { value, ok } = normalizeColorInput(val); + const hv = ok ? _hexToHsv(value) : null; if (hv) { mode = 'color'; showTune = true; hEl.value = String(hv.h); sEl.value = String(hv.s); vEl.value = String(hv.v); - } + } else if (val) toast(t('toast.colorInvalid'), 'err'); commit(); }, }); diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index c26957f7..2a4350b6 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,6 +1,6 @@ import { state } from '/js/admin-state.js?v=b7731aa4'; import { PE_SVG, CHEV_SVG, initInlineEdit } from '/js/admin-shared.js?v=3d2627a9'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=bf180b36'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=c8fdc715'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, diff --git a/ui/js/admin.js b/ui/js/admin.js index 92aadfa9..df4c91ff 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,4 +1,4 @@ -import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=848f1e7d'; +import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=fde6479c'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=76be763e'; import { applyDrop, canJoinFolder } from '/js/admin-drag-logic.js?v=8fcf583a'; import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=f3f87abf'; @@ -13,7 +13,7 @@ import { import { loadSettings, showBgFields } from '/js/admin-settings.js?v=e9bec962'; import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=3d2627a9'; import { state } from '/js/admin-state.js?v=b7731aa4'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=5292c6a0'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=bb1a94d7'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=d056c9c5'; import { iconChain, loadLocalIcons, resolveIcon } from '/js/icons.js?v=69c2b9bd'; @@ -30,6 +30,7 @@ import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; import { confirmModal, openModal as openDialog, promptModal } from '/js/modal.js?v=ff76dc56'; import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=fbd2d2ef'; import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=b18c93ed'; +import { normalizeColorInput } from '/js/admin-color-control.js?v=5fb6a01b'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=cceca788'; /* A class rather than a bare media query. Some phones report a wider CSS @@ -1078,7 +1079,17 @@ function initAllInlineEdits() { const colorInp = document.createElement('input'); colorInp.id = 'bg-color-inp'; document.body.appendChild(colorInp); - initInlineEdit('ie-bgcolor', 'bg-color-inp', { placeholder: '#0d1117' }); + initInlineEdit('ie-bgcolor', 'bg-color-inp', { + placeholder: '#0d1117', + onCommit(val) { + if (!val) return; + const { value, ok } = normalizeColorInput(val); + if (!ok) return toast(t('toast.colorInvalid'), 'err'); + colorInp.value = value; + const rv = q('#ie-bgcolor .rv'); + if (rv) rv.textContent = value; + }, + }); } async function initVersion() { diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 6a3f33ab..c9adea11 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -5,7 +5,7 @@ import { t } from '/js/i18n.js?v=d056c9c5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { wireChecklist } from '/js/admin-shared.js?v=3d2627a9'; -import { renderColorControl } from '/js/admin-color-control.js?v=3a3a1bad'; +import { renderColorControl } from '/js/admin-color-control.js?v=5fb6a01b'; import { seedCarried, applyOptionSet, diff --git a/ui/test/color-control.test.mjs b/ui/test/color-control.test.mjs index 4251e886..55d6771e 100644 --- a/ui/test/color-control.test.mjs +++ b/ui/test/color-control.test.mjs @@ -43,7 +43,7 @@ globalThis.document = { }, }; -const { _internals } = await import('../js/admin-color-control.js'); +const { _internals, normalizeColorInput } = await import('../js/admin-color-control.js'); const { cssToHex, hsvToRgb, hexToHsv } = _internals; /* ── the finding ─────────────────────────────────────────────────────────── */ @@ -80,18 +80,41 @@ test('shorthand, named and rgb forms resolve as before', () => { assert.equal(cssToHex('rgba(4,5,6,0.5)'), '#040506', 'alpha is dropped'); }); -/* A browser ignores an invalid assignment to fillStyle, so the '#000' seeded - just before it is what comes back. Pinned because callers treat the result as - a colour rather than checking it. */ -test('an unreadable value falls back rather than throwing', () => { +test('a value that is not a colour is rejected rather than read as black', () => { for (const bad of ['not-a-colour', '', '#12345', '#1234567']) { - assert.equal(cssToHex(bad), '#000000', JSON.stringify(bad)); + assert.equal(cssToHex(bad), null, JSON.stringify(bad)); } }); +test('black itself is still parsed', () => { + assert.equal(cssToHex('#000'), '#000000'); +}); + test('a null or undefined input does not throw', () => { - assert.equal(cssToHex(undefined), '#000000'); - assert.equal(cssToHex(null), '#000000'); + assert.equal(cssToHex(undefined), null); + assert.equal(cssToHex(null), null); +}); + +test('a hex code typed without its hash is accepted', () => { + for (const [typed, want] of [ + ['ffffff', '#ffffff'], + ['FFF', '#FFF'], + ['abc', '#abc'], + ]) { + assert.deepEqual(normalizeColorInput(typed), { value: want, ok: true }, typed); + } +}); + +test('a value that needs no hash is left as typed', () => { + for (const good of ['#0289ff', 'red', 'rgb(1, 2, 3)', ' #1c1c1e '.trim()]) { + assert.deepEqual(normalizeColorInput(good), { value: good, ok: true }, good); + } +}); + +test('a value that is not a colour is reported, not repaired', () => { + for (const bad of ['not-a-colour', '#12345', 'ggg', '']) { + assert.equal(normalizeColorInput(bad).ok, false, JSON.stringify(bad)); + } }); /* ── the conversions the control is built on ─────────────────────────────── */