Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,6 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div></div>
</div>

<script type="module" src="/js/admin.js?v=79e9c341"></script>
<script type="module" src="/js/admin.js?v=547e3870"></script>
</body>
</html>
1 change: 1 addition & 0 deletions ui/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@
"toast": {
"saved": "ذخیره شد",
"saveFailed": "ذخیره ناموفق بود: {err}",
"colorInvalid": "این یک رنگ نیست. از #rrggbb یا نام رنگ CSS استفاده کنید.",
"imported": "وارد شد",
"importFailed": "Import ناموفق بود: {err}",
"exportFailed": "Export ناموفق بود: {err}",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions ui/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@
"toast": {
"saved": "已保存",
"saveFailed": "保存失败:{err}",
"colorInvalid": "这不是颜色。请使用 #rrggbb 或 CSS 颜色名称。",
"imported": "已导入",
"importFailed": "Import 失败:{err}",
"exportFailed": "Export 失败:{err}",
Expand Down
2 changes: 1 addition & 1 deletion ui/js/admin-app-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
41 changes: 33 additions & 8 deletions ui/js/admin-color-control.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
},
});
Expand Down
2 changes: 1 addition & 1 deletion ui/js/admin-widget-form.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
17 changes: 14 additions & 3 deletions ui/js/admin.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion ui/js/widget-config-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 31 additions & 8 deletions ui/test/color-control.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -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 ─────────────────────────────── */
Expand Down