From cd951b80d108d118bcbcac05eacd30a9350e929d Mon Sep 17 00:00:00 2001 From: pponce Date: Thu, 10 Sep 2026 21:23:42 -0700 Subject: [PATCH 1/2] fix: make custom settings readable in Homebridge night mode --- CHANGELOG.md | 5 ++ homebridge-ui/public/style.css | 59 +++++++++++++++++++++- test/theme-fixture.cjs | 91 ++++++++++++++++++++++++++++++++++ test/ui.browser.cjs | 9 ++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 test/theme-fixture.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f1aadca..32ec423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** diff --git a/homebridge-ui/public/style.css b/homebridge-ui/public/style.css index 60d84a9..4346a38 100644 --- a/homebridge-ui/public/style.css +++ b/homebridge-ui/public/style.css @@ -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; } @@ -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; } diff --git a/test/theme-fixture.cjs b/test/theme-fixture.cjs new file mode 100644 index 0000000..cb6b6a8 --- /dev/null +++ b/test/theme-fixture.cjs @@ -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 { color:#6c757d; background:#e9ecef; } +.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 }; diff --git a/test/ui.browser.cjs b/test/ui.browser.cjs index 2ed34b5..19694e5 100644 --- a/test/ui.browser.cjs +++ b/test/ui.browser.cjs @@ -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')); +}); From ecaaa6eab89044a8f9d62b0ea1334babdcacfc14 Mon Sep 17 00:00:00 2001 From: pponce Date: Thu, 10 Sep 2026 21:27:09 -0700 Subject: [PATCH 2/2] test: match Bootstrap disabled field text inheritance --- test/theme-fixture.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/theme-fixture.cjs b/test/theme-fixture.cjs index cb6b6a8..b71b949 100644 --- a/test/theme-fixture.cjs +++ b/test/theme-fixture.cjs @@ -9,7 +9,7 @@ const hostStyles = ` 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 { color:#6c757d; background:#e9ecef; } +.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; }