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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- Fix Homebridge night-mode contrast for custom settings text, fields, help, tables, and buttons. Follow Homebridge's selected theme even when the operating system uses a different appearance.
- Add browser checks for light/night theme switching, text and field-border contrast, and mobile layout.

## 1.0.2 — 2026-09-11

# ⚠️ **BREAKING CHANGE — BACK UP YOUR CONFIGURATION BEFORE UPDATING.**
Expand Down
59 changes: 58 additions & 1 deletion homebridge-ui/public/style.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.script2-settings, .script2-settings * { box-sizing: border-box; }
.script2-settings { max-width: 960px; margin: auto; padding: 0.25rem; color: var(--bs-body-color, inherit); }
.script2-settings { max-width: 960px; margin: auto; padding: 0.25rem; color: inherit; }
.script2-intro { border-left: 4px solid #d99130; padding: 0.4rem 1.2rem; margin-bottom: 1.5rem; }
.script2-eyebrow { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.13em; }
.script2-intro h2 { margin: 0.45rem 0; font-size: 1.7rem; }
Expand Down Expand Up @@ -38,3 +38,60 @@
.script2-settings input[type=checkbox] { width:auto; margin-right:0.5rem; }
.script2-settings code { overflow-wrap:anywhere; }
.script2-settings .alert { padding:0.8rem; border:1px solid var(--bs-border-color,#8886);border-radius:0.5rem; margin-bottom:1rem; }

/* Homebridge forwards its selected theme classes to the custom-UI iframe.
Do not use prefers-color-scheme: the user's Homebridge theme takes priority.
Bootstrap's --bs-body-color can retain its light-theme value in night mode. */
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings {
color: #f1f1f1;
color-scheme: dark;
--bs-border-color: #858585;
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is(.script2-device, .script2-pdu, .script2-outlet, .script2-empty) {
background-color: #2b2b2b;
border-color: #858585;
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is(h2, h3, label, summary, table, th, td) { color: inherit; }
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is(small, caption, .script2-help, .script2-footer) {
color: #d3d3d3;
opacity: 1;
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is(.form-control, .form-select, textarea) {
color: #f1f1f1;
background-color: #333333;
border-color: #858585;
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is(.form-control, textarea)::placeholder {
color: #bdbdbd !important;
opacity: 1 !important;
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is(.form-control, .form-select):is(:disabled, [readonly]) {
color: #d3d3d3;
background-color: #2b2b2b;
opacity: 1;
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings .form-select {
--bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23f1f1f1' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings input[type=checkbox] { accent-color: #e9ae57; }
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is(.btn-outline-primary, .btn-outline-secondary) {
color: #f1f1f1;
border-color: #949494;
background-color: transparent;
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is(.btn-outline-primary, .btn-outline-secondary):is(:hover, :focus-visible) {
color: #ffffff;
background-color: #414141;
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings .btn-outline-danger { color: #ffb4ab; border-color: #ffb4ab; }
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings .btn-outline-danger:is(:hover, :focus-visible) { color: #ffffff; background-color: #6e2727; }
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings .btn-primary {
color: #171717 !important;
background-color: #e9ae57 !important;
border-color: #e9ae57 !important;
}
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings .btn-primary:is(:hover, :focus-visible) { background-color: #f2c17b !important; }
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings .alert { color: #f1f1f1; background-color: #333333; }
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is(a:not(.btn), code) { color: #9dccff; }
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :is([aria-invalid=true], .is-invalid) { border-color: #ffb4ab; }
:is(body.dark-mode, body[class*="config-ui-x-dark-mode"]) .script2-settings :focus-visible { outline: 2px solid #f2c17b; outline-offset: 3px; }
91 changes: 91 additions & 0 deletions test/theme-fixture.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use strict';
const assert = require('node:assert/strict');

// Representative Bootstrap defaults and Homebridge iframe theme rules. These
// arrive AFTER plugin CSS, just as Homebridge mirrors its styles into the iframe.
// Source: homebridge-config-ui-x ui/src/scss/themes/themes-{dark,light}.scss
const hostStyles = `
:root { --bs-body-color:#212529; --bs-border-color:#dee2e6; }
body { color:#212529; background:#fff; font-family:Arial,sans-serif; }
body.dark-mode, body[class*="config-ui-x-dark-mode"] { color:#fff; background:#242424; }
.form-control, .form-select { color:#212529; background-color:#fff; border:1px solid #ced4da; border-radius:.25rem; padding:.375rem .75rem; }
.form-control:disabled { background:#e9ecef; opacity:1; }
.form-control::placeholder { color:#636363 !important; opacity:1 !important; }
.btn { display:inline-block; padding:.375rem .75rem; border:1px solid transparent; border-radius:.25rem; }
.btn-outline-primary { color:#0d6efd; border-color:#0d6efd; background:transparent; }
.btn-outline-secondary { color:#6c757d; border-color:#6c757d; background:transparent; }
.btn-outline-danger { color:#dc3545; border-color:#dc3545; background:transparent; }
.btn-primary { color:#fff; background:#0d6efd; }
.config-ui-x-dark-mode-orange .btn-primary { background-color:#ffa000 !important; border-color:#ffa000 !important; }
.alert { color:#055160; background:#cff4fc; }
.dark-mode .alert { color:#eee; background:#2b2b2b; }
caption { color:#6c757d; }
`;

async function checkThemeContrast(page, root, screenshot) {
await page.addStyleTag({ content: hostStyles });
// Explicit Homebridge choice wins even when the OS preference is opposite.
for (const [classes, osTheme, dark] of [
['config-ui-x-dark-mode-orange dark-mode modal-content', 'light', true],
['config-ui-x-dark-mode-blue modal-content', 'light', true],
['dark-mode modal-content', 'light', true],
['config-ui-x-blue modal-content', 'dark', false],
]) {
await page.emulateMedia({ colorScheme: osTheme });
await page.evaluate(value => { document.body.className = value; }, classes);
const result = await page.evaluate(({ root, dark }) => {
const settings = document.querySelector(root);
const parse = color => color.match(/[\d.]+/g).map(Number);
const blend = (top, bottom, opacity = 1) => {
const a = (top[3] ?? 1) * opacity;
return top.slice(0, 3).map((v, i) => v * a + bottom[i] * (1 - a));
};
const luminance = rgb => rgb.slice(0,3).map(v => {
v /= 255; return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
}).reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0);
const ratio = (a, b) => {
const x = luminance(a), y = luminance(b);
return (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
};
const failures = []; let checked = 0;
for (const el of settings.querySelectorAll('h2,h3,label,summary,p,small,caption,th,td,a,code,.btn,input:not([type=checkbox]),select,textarea')) {
if (!el.getClientRects().length || getComputedStyle(el).visibility === 'hidden') continue;
const chain = []; for (let node = el; node; node = node.parentElement) chain.unshift(node);
let background = [255,255,255], opacity = 1;
for (const node of chain) {
const style = getComputedStyle(node);
background = blend(parse(style.backgroundColor), background);
opacity *= Number(style.opacity);
}
const style = getComputedStyle(el);
const foreground = blend(parse(style.color), background, opacity);
const contrast = ratio(foreground, background);
checked++;
if (contrast < 4.5) failures.push(`${el.tagName} ${el.id || el.textContent.trim().slice(0,40)}: ${contrast.toFixed(2)}:1`);
if (dark && el.matches('input:not([type=checkbox]),select,textarea')) {
const border = ratio(blend(parse(style.borderTopColor), background), background);
if (border < 3) failures.push(`${el.id} border: ${border.toFixed(2)}:1`);
if (el.placeholder) {
const placeholder = getComputedStyle(el, '::placeholder');
const contrast = ratio(blend(parse(placeholder.color), background, Number(placeholder.opacity)), background);
if (contrast < 4.5) failures.push(`${el.id} placeholder: ${contrast.toFixed(2)}:1`);
}
}
}
return { failures, checked, color: getComputedStyle(settings).color };
}, { root, dark });
assert.ok(result.checked >= 5, 'Contrast check must exercise rendered settings');
assert.deepEqual(result.failures, [], classes);
assert.equal(result.color, dark ? 'rgb(241, 241, 241)' : 'rgb(33, 37, 41)');
}
// Capture night-mode desktop and mobile after switching back from light mode.
await page.evaluate(() => { document.body.className = 'config-ui-x-dark-mode-orange dark-mode modal-content'; });
await page.emulateMedia({ colorScheme: 'light' });
if (screenshot) await page.screenshot({ path: screenshot + '-night-desktop.png', fullPage: true });
const viewport = page.viewportSize();
await page.setViewportSize({ width: 375, height: 850 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
if (screenshot) await page.screenshot({ path: screenshot + '-night-mobile.png', fullPage: true });
await page.setViewportSize(viewport);
}
module.exports = { checkThemeContrast };
9 changes: 9 additions & 0 deletions test/ui.browser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,12 @@ test('rapid edits during validation persist newest snapshot and preserve metadat
test('stateless trigger-on-Off and zero reset work on mobile without overflow',async()=>{
const page=await pageWith([]);await page.setViewportSize({width:375,height:850});await page.getByRole('button',{name:'Add Stateless Switch',exact:true}).click();await page.getByLabel('Switch name',{exact:true}).fill('Reboot');await page.getByLabel('Trigger command',{exact:true}).fill('/opt/reboot');await page.getByLabel('Run command when',{exact:true}).selectOption('off');await page.getByLabel('Reset delay (seconds)',{exact:true}).fill('0');await page.waitForFunction(()=>window._hb.enabled);const saved=await page.evaluate(()=>window._hb.saved[0].stateless_switches[0]);assert.equal(saved.auto_reset_ms,0);assert.equal(saved.stateless_trigger_on,'off');assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:path.join(__dirname,'../.test-artifacts/settings-mobile.png'),fullPage:true});await page.close();
});

test('Homebridge night and light themes keep settings and form controls readable', async t => {
const page = await pageWith([]);
t.after(() => page.close());
await page.getByRole('button', { name: 'Add On/Off Switch', exact: true }).click();
await page.getByText('Advanced timing and behavior', { exact: true }).click();
fs.mkdirSync(path.join(__dirname, '../.test-artifacts'), { recursive: true });
await require('./theme-fixture.cjs').checkThemeContrast(page, '.script2-settings', path.join(__dirname, '../.test-artifacts/settings'));
});
Loading