From b3e077aca5c4d2a17f28dbe10aa9a55e4d324659 Mon Sep 17 00:00:00 2001 From: mrramam Date: Tue, 15 Sep 2026 15:41:00 -0700 Subject: [PATCH] fix(client): set --accent-rgb with --accent, and use the variables index.css defines app.jsx and AdminPanel set --accent at runtime but never --accent-rgb, so every rgba(var(--accent-rgb), x) tint stayed on the stylesheet's default purple while solid accent followed the pick. WidgetContainer hardcoded a pink alpha for the selection glow and eleven other sites hardcoded the purple one, so three accent hues could be on screen at once. One helper now writes all four variables in both places, and every alpha tint reads --accent-rgb. --text-color, --text-muted and --text-primary are not defined anywhere; index.css defines --text and --text-secondary. Every use was on `color`, so the invalid declaration computed to inherit and mostly looked right by accident. The one visible case was the color picker's hex input, whose #000 fallback sat on the dark card. Same root cause as the chore-metrics fix in HomeGlowPlugins: the vocabulary of the retired substitute stylesheet had leaked into core. --- client/src/app.jsx | 7 ++- client/src/components/AdminPanel.jsx | 11 ++--- client/src/components/CalendarWidget.jsx | 16 +++---- client/src/components/ChoreIconPicker.jsx | 2 +- client/src/components/ChoreWidget.jsx | 6 +-- client/src/components/ClamValueModal.jsx | 12 ++--- client/src/components/ColorPickerPopover.jsx | 2 +- client/src/components/CountdownCircle.jsx | 2 +- client/src/components/DraggableWidget.jsx | 10 ++-- client/src/components/PhotoWidget.jsx | 8 ++-- client/src/components/PinModal.jsx | 12 ++--- client/src/components/PrizeCelebration.jsx | 2 +- client/src/components/TabIconModal.jsx | 2 +- client/src/components/VacationScreensaver.jsx | 2 +- client/src/components/WeatherWidget.jsx | 2 +- client/src/components/WidgetContainer.jsx | 6 +-- client/src/pages/PhotosUpload.jsx | 2 +- client/src/utils/interfaceSettings.js | 34 ++++++++++++++ client/src/utils/interfaceSettings.test.js | 47 +++++++++++++++++++ 19 files changed, 132 insertions(+), 53 deletions(-) diff --git a/client/src/app.jsx b/client/src/app.jsx index e6cce734..108e30f8 100644 --- a/client/src/app.jsx +++ b/client/src/app.jsx @@ -18,6 +18,7 @@ import useIsMobile from './hooks/useIsMobile.js'; import useScreenActivity from './hooks/useScreenActivity.js'; import { readLocalInterfaceColors, + applyInterfaceColors, readLocalScreensaverSettings, readLocalAutoDarkModeSettings, readLocalVacationModeSettings, @@ -592,9 +593,7 @@ const App = () => { }, [resolveAutoTheme, applyTheme]); useEffect(() => { - document.documentElement.style.setProperty('--primary', interfaceColors.primary); - document.documentElement.style.setProperty('--secondary', interfaceColors.secondary); - document.documentElement.style.setProperty('--accent', interfaceColors.accent); + applyInterfaceColors(document.documentElement, interfaceColors); }, [interfaceColors]); useEffect(() => { @@ -1158,7 +1157,7 @@ const App = () => { borderRadius: '16px', backgroundColor: 'var(--card-bg)', border: '1px solid var(--card-border)', - color: 'var(--text-color)', + color: 'var(--text)', fontSize: '0.8rem', fontWeight: 600, boxShadow: 'var(--shadow)', diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index 674de363..fa6a3c36 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -104,6 +104,7 @@ import { readLocalScreensaverSettings, readLocalAutoDarkModeSettings, readLocalVacationModeSettings, + applyInterfaceColors, } from '../utils/interfaceSettings.js'; import { CONTROL_LIMITS_DEFAULT_KEY } from '../utils/displayControls.js'; import { useTranslation } from 'react-i18next'; @@ -1335,9 +1336,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ const root = document.documentElement; const isLight = root.getAttribute('data-theme') === 'light'; - root.style.setProperty('--primary', interfaceColors.primary); - root.style.setProperty('--secondary', interfaceColors.secondary); - root.style.setProperty('--accent', interfaceColors.accent); + applyInterfaceColors(root, interfaceColors); if (isLight) { root.style.setProperty('--background', interfaceColors.primary); @@ -2246,7 +2245,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ ); })} - + {t('admin:widgets.weatherWidget')} @@ -2482,7 +2481,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ a made-up line. */} {plugin.manifest?.description || t('admin:plugins.noDescription')} @@ -3896,7 +3895,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ {t('admin:security.help')} - + {t('admin:security.pinProtection')} diff --git a/client/src/components/CalendarWidget.jsx b/client/src/components/CalendarWidget.jsx index ab1cb45f..89eb67d1 100644 --- a/client/src/components/CalendarWidget.jsx +++ b/client/src/components/CalendarWidget.jsx @@ -1303,7 +1303,7 @@ const CalendarWidget = ({ @@ -1319,7 +1319,7 @@ const CalendarWidget = ({ borderRadius: 1, px: 1, py: 0.5, - color: 'var(--text-color)', + color: 'var(--text)', '&:hover': { backgroundColor: 'rgba(var(--accent-rgb), 0.08)', }, @@ -1333,7 +1333,7 @@ const CalendarWidget = ({ @@ -1345,7 +1345,7 @@ const CalendarWidget = ({ @@ -1359,8 +1359,8 @@ const CalendarWidget = ({ onChange={handleViewModeChange} size="small" sx={{ - '& .MuiToggleButton-root': { color: 'var(--text-color)', borderColor: 'var(--card-border)' }, - '& .MuiToggleButton-root.Mui-selected': { color: 'var(--text-color)', backgroundColor: 'rgba(var(--accent-rgb), 0.15)' }, + '& .MuiToggleButton-root': { color: 'var(--text)', borderColor: 'var(--card-border)' }, + '& .MuiToggleButton-root.Mui-selected': { color: 'var(--text)', backgroundColor: 'rgba(var(--accent-rgb), 0.15)' }, }} > @@ -1373,7 +1373,7 @@ const CalendarWidget = ({ @@ -1671,7 +1671,7 @@ const CalendarWidget = ({ {day.dayNumber} - + {day.monthName} diff --git a/client/src/components/ChoreIconPicker.jsx b/client/src/components/ChoreIconPicker.jsx index 9b6df11e..e5a8c712 100644 --- a/client/src/components/ChoreIconPicker.jsx +++ b/client/src/components/ChoreIconPicker.jsx @@ -73,7 +73,7 @@ const ChoreIconPicker = ({ value, onChange }) => { sx={{ ...cellSx(!value), fontSize: '0.7rem', - color: 'var(--text-color)', + color: 'var(--text)', opacity: 0.8, }} {...cellProps(!value, t('chores:icons.none'), () => onChange(''))} diff --git a/client/src/components/ChoreWidget.jsx b/client/src/components/ChoreWidget.jsx index 0541be09..34450d71 100644 --- a/client/src/components/ChoreWidget.jsx +++ b/client/src/components/ChoreWidget.jsx @@ -1195,7 +1195,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { size="small" aria-label={t('chores:visibility.openSettings')} title={t('chores:visibility.openSettings')} - sx={{ color: 'var(--text-color)' }} + sx={{ color: 'var(--text)' }} > @@ -1280,7 +1280,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { {userChores.length === 0 ? ( - + {t('chores:widget.noChoresToday')} ) : ( @@ -1562,7 +1562,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { textAlign: 'center', fontWeight: 'bold', my: 1, - color: overspent ? 'error.main' : (parsed > 0 ? 'var(--accent)' : 'var(--text-muted)'), + color: overspent ? 'error.main' : (parsed > 0 ? 'var(--accent)' : 'var(--text-secondary)'), transition: 'color 0.2s ease', }} > diff --git a/client/src/components/ClamValueModal.jsx b/client/src/components/ClamValueModal.jsx index c7880f60..7e1d041e 100644 --- a/client/src/components/ClamValueModal.jsx +++ b/client/src/components/ClamValueModal.jsx @@ -115,7 +115,7 @@ const ClamValueModal = ({ open, onClose, onSave, user, isSaving = false }) => { sx={{ display: 'block', textAlign: 'center', - color: 'var(--text-muted)', + color: 'var(--text-secondary)', mb: 1, fontSize: '0.85rem', }} @@ -130,7 +130,7 @@ const ClamValueModal = ({ open, onClose, onSave, user, isSaving = false }) => { fontSize: '2.5rem', lineHeight: 1.2, mb: 2, - color: 'var(--text-color)', + color: 'var(--text)', }} > {value === '' ? '0' : value} 🥟 @@ -169,7 +169,7 @@ const ClamValueModal = ({ open, onClose, onSave, user, isSaving = false }) => { color: 'var(--accent)', '&:hover': { borderColor: 'var(--accent)', - backgroundColor: 'rgba(158, 127, 255, 0.1)', + backgroundColor: 'rgba(var(--accent-rgb), 0.1)', }, }} > @@ -194,7 +194,7 @@ const ClamValueModal = ({ open, onClose, onSave, user, isSaving = false }) => { border: '2px solid var(--accent)', color: 'var(--accent)', '&:hover': { - backgroundColor: 'rgba(158, 127, 255, 0.1)', + backgroundColor: 'rgba(var(--accent-rgb), 0.1)', }, }} > @@ -213,10 +213,10 @@ const ClamValueModal = ({ open, onClose, onSave, user, isSaving = false }) => { disabled={isSaving} sx={{ borderColor: 'var(--card-border)', - color: 'var(--text-color)', + color: 'var(--text)', '&:hover': { borderColor: 'var(--accent)', - backgroundColor: 'rgba(158, 127, 255, 0.05)', + backgroundColor: 'rgba(var(--accent-rgb), 0.05)', }, }} > diff --git a/client/src/components/ColorPickerPopover.jsx b/client/src/components/ColorPickerPopover.jsx index 8815b30e..491fb59c 100644 --- a/client/src/components/ColorPickerPopover.jsx +++ b/client/src/components/ColorPickerPopover.jsx @@ -130,7 +130,7 @@ const ColorPickerInner = ({ hex, hsv, hsl, onChange }) => { fontFamily: 'monospace', outline: 'none', background: 'var(--card-bg, #fff)', - color: 'var(--text-primary, #000)', + color: 'var(--text)', }, label: { display: 'none' }, }} diff --git a/client/src/components/CountdownCircle.jsx b/client/src/components/CountdownCircle.jsx index 7f1d2a34..37a59edf 100644 --- a/client/src/components/CountdownCircle.jsx +++ b/client/src/components/CountdownCircle.jsx @@ -124,7 +124,7 @@ const CountdownCircle = ({ refreshInterval, onRefresh, isActive = true }) => { strokeLinecap="round" style={{ transition: 'stroke-dashoffset 0.1s linear', - filter: 'drop-shadow(0 0 4px rgba(158, 127, 255, 0.5))', + filter: 'drop-shadow(0 0 4px rgba(var(--accent-rgb), 0.5))', }} /> diff --git a/client/src/components/DraggableWidget.jsx b/client/src/components/DraggableWidget.jsx index 02bb7133..80d1ffdd 100644 --- a/client/src/components/DraggableWidget.jsx +++ b/client/src/components/DraggableWidget.jsx @@ -134,7 +134,7 @@ const DraggableWidget = ({ borderRadius: 2, transition: 'border-color 0.2s ease, box-shadow 0.2s ease', boxShadow: isSelected - ? '0 8px 32px rgba(158, 127, 255, 0.3)' + ? '0 8px 32px rgba(var(--accent-rgb), 0.3)' : '0 2px 8px rgba(0, 0, 0, 0.1)', backgroundColor: 'var(--card-bg)', overflow: 'hidden', @@ -142,9 +142,9 @@ const DraggableWidget = ({ '&:hover': { border: isSelected ? '3px solid var(--accent)' - : '3px solid rgba(158, 127, 255, 0.3)', + : '3px solid rgba(var(--accent-rgb), 0.3)', boxShadow: isSelected - ? '0 8px 32px rgba(158, 127, 255, 0.3)' + ? '0 8px 32px rgba(var(--accent-rgb), 0.3)' : '0 4px 16px rgba(0, 0, 0, 0.15)', } }} @@ -159,8 +159,8 @@ const DraggableWidget = ({ right: 0, bottom: 0, backgroundImage: ` - linear-gradient(to right, rgba(158, 127, 255, 0.1) 1px, transparent 1px), - linear-gradient(to bottom, rgba(158, 127, 255, 0.1) 1px, transparent 1px) + linear-gradient(to right, rgba(var(--accent-rgb), 0.1) 1px, transparent 1px), + linear-gradient(to bottom, rgba(var(--accent-rgb), 0.1) 1px, transparent 1px) `, backgroundSize: `${containerWidth / gridCols}px ${rowHeight}px`, pointerEvents: 'none', diff --git a/client/src/components/PhotoWidget.jsx b/client/src/components/PhotoWidget.jsx index 0ac7cdd3..1254585b 100644 --- a/client/src/components/PhotoWidget.jsx +++ b/client/src/components/PhotoWidget.jsx @@ -424,13 +424,13 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { 📷 Photos - + {isPlaying ? : } - + - + @@ -446,7 +446,7 @@ const PhotoWidget = ({ refreshNonce = 0, isActive = true }) => { {!loading && !error && photos.length === 0 && ( - {t('photos:widget.noPhotos')} + {t('photos:widget.noPhotos')} )} diff --git a/client/src/components/PinModal.jsx b/client/src/components/PinModal.jsx index 21581dbe..681b5b16 100644 --- a/client/src/components/PinModal.jsx +++ b/client/src/components/PinModal.jsx @@ -231,7 +231,7 @@ const PinModal = ({ open, onClose, onVerify, mode = 'verify', title, allowRememb sx={{ display: 'block', textAlign: 'center', - color: 'var(--text-muted)', + color: 'var(--text-secondary)', mb: 2, fontSize: '0.85rem' }} @@ -257,7 +257,7 @@ const PinModal = ({ open, onClose, onVerify, mode = 'verify', title, allowRememb {remember && ( This device will stop asking. You can always require the PIN again from Admin → Security. @@ -309,7 +309,7 @@ const PinModal = ({ open, onClose, onVerify, mode = 'verify', title, allowRememb color: 'var(--accent)', '&:hover': { borderColor: 'var(--accent)', - backgroundColor: 'rgba(158, 127, 255, 0.1)' + backgroundColor: 'rgba(var(--accent-rgb), 0.1)' } }} > @@ -344,7 +344,7 @@ const PinModal = ({ open, onClose, onVerify, mode = 'verify', title, allowRememb border: '2px solid var(--accent)', color: 'var(--accent)', '&:hover': { - backgroundColor: 'rgba(158, 127, 255, 0.1)' + backgroundColor: 'rgba(var(--accent-rgb), 0.1)' } }} > @@ -361,10 +361,10 @@ const PinModal = ({ open, onClose, onVerify, mode = 'verify', title, allowRememb sx={{ py: 1.5, borderColor: 'var(--card-border)', - color: 'var(--text-color)', + color: 'var(--text)', '&:hover': { borderColor: 'var(--accent)', - backgroundColor: 'rgba(158, 127, 255, 0.05)' + backgroundColor: 'rgba(var(--accent-rgb), 0.05)' } }} > diff --git a/client/src/components/PrizeCelebration.jsx b/client/src/components/PrizeCelebration.jsx index ef36ed7c..79b7b296 100644 --- a/client/src/components/PrizeCelebration.jsx +++ b/client/src/components/PrizeCelebration.jsx @@ -83,7 +83,7 @@ const PrizeCelebration = ({ username, prizeName, onDismiss }) => { { transform: 'translate(-50%, -50%)', fontSize: '1.4rem', fontWeight: 600, - color: 'var(--text-color)', + color: 'var(--text)', opacity: 0.35, pointerEvents: 'none', userSelect: 'none', diff --git a/client/src/components/WeatherWidget.jsx b/client/src/components/WeatherWidget.jsx index 4c0305ec..dbaa4c3d 100644 --- a/client/src/components/WeatherWidget.jsx +++ b/client/src/components/WeatherWidget.jsx @@ -1126,7 +1126,7 @@ const WeatherWidget = ({ top: 8, right: 8, zIndex: 10, - color: 'var(--text-color)', + color: 'var(--text)', }} > diff --git a/client/src/components/WidgetContainer.jsx b/client/src/components/WidgetContainer.jsx index db52f88f..df7a64e8 100644 --- a/client/src/components/WidgetContainer.jsx +++ b/client/src/components/WidgetContainer.jsx @@ -617,7 +617,7 @@ const WidgetContainer = ({ borderRadius: 2, transition: 'border-color 0.2s ease, box-shadow 0.2s ease', boxShadow: isSelected - ? '0 8px 32px rgba(244, 114, 182, 0.3)' + ? '0 8px 32px rgba(var(--accent-rgb), 0.3)' : '0 2px 8px rgba(0, 0, 0, 0.1)', backgroundColor: 'var(--card-bg)', overflow: 'hidden', @@ -629,11 +629,11 @@ const WidgetContainer = ({ ? '3px solid transparent' : (isSelected ? '3px solid var(--accent)' - : '3px solid rgba(244, 114, 182, 0.3)'), + : '3px solid rgba(var(--accent-rgb), 0.3)'), boxShadow: locked ? '0 2px 8px rgba(0, 0, 0, 0.1)' : (isSelected - ? '0 8px 32px rgba(244, 114, 182, 0.3)' + ? '0 8px 32px rgba(var(--accent-rgb), 0.3)' : '0 4px 16px rgba(0, 0, 0, 0.15)'), } } diff --git a/client/src/pages/PhotosUpload.jsx b/client/src/pages/PhotosUpload.jsx index 7eb3041b..90f9719d 100644 --- a/client/src/pages/PhotosUpload.jsx +++ b/client/src/pages/PhotosUpload.jsx @@ -148,7 +148,7 @@ const PhotosUpload = () => { sx={{ minHeight: '100vh', background: 'var(--background, #f4f4f9)', - color: 'var(--text-, #bfbfbf)', + color: 'var(--text)', px: { xs: 2, sm: 3 }, py: { xs: 2, sm: 3 }, boxSizing: 'border-box', diff --git a/client/src/utils/interfaceSettings.js b/client/src/utils/interfaceSettings.js index a9240fe3..c4c3fa3b 100644 --- a/client/src/utils/interfaceSettings.js +++ b/client/src/utils/interfaceSettings.js @@ -44,6 +44,40 @@ export const DEFAULT_VACATION_MODE_SETTINGS = { muteSounds: true, }; +/** + * "r, g, b" for a 3- or 6-digit hex color, or null for anything else. The + * stylesheet's alpha tints are written as rgba(var(--accent-rgb), x), which + * needs the bare triplet; a hex value cannot be dropped into rgba(). + */ +export const hexToRgbTriplet = (hex) => { + if (typeof hex !== 'string') return null; + const match = hex.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!match) return null; + const digits = match[1].length === 3 + ? match[1].split('').map((d) => d + d).join('') + : match[1]; + const value = parseInt(digits, 16); + return `${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}`; +}; + +/** + * Write the interface colors onto a root element as the CSS variables the app + * and its widgets read. --accent-rgb travels with --accent so the alpha tints + * follow the picked accent instead of staying on index.css's default; an + * accent the triplet cannot be derived from leaves the stylesheet default. + */ +export const applyInterfaceColors = (root, colors) => { + root.style.setProperty('--primary', colors.primary); + root.style.setProperty('--secondary', colors.secondary); + root.style.setProperty('--accent', colors.accent); + const accentRgb = hexToRgbTriplet(colors.accent); + if (accentRgb) { + root.style.setProperty('--accent-rgb', accentRgb); + } else { + root.style.removeProperty('--accent-rgb'); + } +}; + export const normalizeInterfaceColors = (raw) => ({ ...DEFAULT_INTERFACE_COLORS, ...(raw && typeof raw === 'object' ? raw : {}), diff --git a/client/src/utils/interfaceSettings.test.js b/client/src/utils/interfaceSettings.test.js index 45578589..d8fb08ab 100644 --- a/client/src/utils/interfaceSettings.test.js +++ b/client/src/utils/interfaceSettings.test.js @@ -82,3 +82,50 @@ describe('isVacationModeActiveToday', () => { expect(isVacationModeActiveToday({ enabled: true, startDate: '2026-08-01', endDate: '' }, TODAY)).toBe(false); }); }); + +import { hexToRgbTriplet, applyInterfaceColors } from './interfaceSettings.js'; + +describe('hexToRgbTriplet', () => { + it('expands 6- and 3-digit hex into the bare triplet rgba() needs', () => { + expect(hexToRgbTriplet('#f472b6')).toBe('244, 114, 182'); + expect(hexToRgbTriplet('#9E7FFF')).toBe('158, 127, 255'); + expect(hexToRgbTriplet('#fff')).toBe('255, 255, 255'); + expect(hexToRgbTriplet(' #000 ')).toBe('0, 0, 0'); + }); + + it('returns null for anything that is not a hex color', () => { + for (const bad of ['f472b6', '#f472b', '#gggggg', 'rgb(1, 2, 3)', '', null, undefined, 42]) { + expect(hexToRgbTriplet(bad)).toBeNull(); + } + }); +}); + +describe('applyInterfaceColors', () => { + const fakeRoot = () => { + const vars = new Map(); + return { + vars, + style: { + setProperty: (name, value) => vars.set(name, value), + removeProperty: (name) => vars.delete(name), + }, + }; + }; + + it('sets --accent-rgb alongside --accent so alpha tints follow the pick', () => { + const root = fakeRoot(); + applyInterfaceColors(root, { primary: '#f5f5f5', secondary: '#38bdf8', accent: '#f472b6' }); + expect(root.vars.get('--primary')).toBe('#f5f5f5'); + expect(root.vars.get('--secondary')).toBe('#38bdf8'); + expect(root.vars.get('--accent')).toBe('#f472b6'); + expect(root.vars.get('--accent-rgb')).toBe('244, 114, 182'); + }); + + it('drops a stale --accent-rgb when the accent is not a hex color', () => { + const root = fakeRoot(); + applyInterfaceColors(root, { primary: '#f5f5f5', secondary: '#38bdf8', accent: '#f472b6' }); + applyInterfaceColors(root, { primary: '#f5f5f5', secondary: '#38bdf8', accent: 'hotpink' }); + expect(root.vars.get('--accent')).toBe('hotpink'); + expect(root.vars.has('--accent-rgb')).toBe(false); + }); +});