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 ─────────────────────────────── */