diff --git a/client/src/app.jsx b/client/src/app.jsx index e6cce73..108e30f 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 674de36..fa6a3c3 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 ab1cb45..89eb67d 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 9b6df11..e5a8c71 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 0541be0..34450d7 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 c7880f6..7e1d041 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 8815b30..491fb59 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 7f1d2a3..37a59ed 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 02bb713..80d1ffd 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 0ac7cdd..1254585 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 21581db..681b5b1 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 ef36ed7..79b7b29 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 4c0305e..dbaa4c3 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 db52f88..df7a64e 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 7eb3041..90f9719 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 a9240fe..c4c3fa3 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 4557858..d8fb08a 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); + }); +});