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
8 changes: 7 additions & 1 deletion client/src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,7 @@ const App = () => {
const dbLayout = getWidgetLayoutForTab('calendar', activeTab);
result.push({
id: 'calendar-widget',
transparent: Boolean(widgetSettings.calendar.transparent),
defaultPosition: { x: 0, y: 0 },
defaultSize: { width: 8, height: 5 },
minWidth: 2,
Expand All @@ -1001,6 +1002,7 @@ const App = () => {
const dbLayout = getWidgetLayoutForTab('weather', activeTab);
result.push({
id: 'weather-widget',
transparent: Boolean(widgetSettings.weather.transparent),
defaultPosition: { x: 8, y: 0 },
defaultSize: { width: 4, height: 3 },
minWidth: 2,
Expand All @@ -1024,6 +1026,7 @@ const App = () => {
const dbLayout = getWidgetLayoutForTab('chores', activeTab);
result.push({
id: 'chores-widget',
transparent: Boolean(widgetSettings.chores.transparent),
defaultPosition: { x: 0, y: 5 },
defaultSize: { width: 6, height: 4 },
minWidth: 2,
Expand All @@ -1041,6 +1044,7 @@ const App = () => {
const dbLayout = getWidgetLayoutForTab('photos', activeTab);
result.push({
id: 'photos-widget',
transparent: Boolean(widgetSettings.photos.transparent),
defaultPosition: { x: 6, y: 5 },
defaultSize: { width: 6, height: 4 },
minWidth: 2,
Expand All @@ -1064,6 +1068,7 @@ const App = () => {
const dbLayout = getWidgetLayoutForTab(pluginWidgetName, activeTab);
result.push({
id: `plugin-${plugin.filename}`,
transparent: Boolean(pSettings.transparent),
defaultPosition: { x: 0, y: 0 },
defaultSize: { width: 6, height: 4 },
minWidth: 2,
Expand All @@ -1073,6 +1078,7 @@ const App = () => {
filename={plugin.filename}
name={plugin.name}
theme={theme}
colors={interfaceColors}
transparentBackground={pSettings.transparent || false}
events={plugin.manifest?.events || []}
hiddenControls={unprefixedHiddenControlsFor(hiddenControls, plugin.manifest?.id)}
Expand All @@ -1081,7 +1087,7 @@ const App = () => {
});

return result;
}, [widgetSettings, pluginSettings, activeTab, widgetAssignments, installedPlugins, theme, demoStatus.demo, hiddenControls]);
}, [widgetSettings, pluginSettings, activeTab, widgetAssignments, installedPlugins, theme, interfaceColors, demoStatus.demo, hiddenControls]);

// Mobile stack (issue #118): same widget content nodes, fixed order, photos
// excluded, grid metadata ignored.
Expand Down
20 changes: 20 additions & 0 deletions client/src/components/AdminPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2198,6 +2198,16 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
}
label={t('common:labels.enabled')}
/>
<FormControlLabel
control={
<Switch
checked={Boolean(config.transparent)}
onChange={() => handleWidgetToggle(widget, 'transparent')}
/>
}
label={t('admin:widgets.transparentBackground')}
sx={{ ml: 2 }}
/>
</Grid>

<Grid size={{ xs: 12, sm: 6 }}>
Expand Down Expand Up @@ -2261,6 +2271,16 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
}
label={t('common:labels.enabled')}
/>
<FormControlLabel
control={
<Switch
checked={Boolean(widgetSettings.weather?.transparent)}
onChange={() => handleWidgetToggle('weather', 'transparent')}
/>
}
label={t('admin:widgets.transparentBackground')}
sx={{ ml: 2 }}
/>
</Grid>

<Grid size={{ xs: 12, sm: 6 }}>
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/MobileDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ const MobileDashboard = ({ widgets }) => {
borderRadius: 2,
overflow: 'hidden',
border: '1px solid var(--card-border)',
backgroundColor: 'var(--card-bg)',
boxShadow: 'var(--shadow)',
backgroundColor: widget.transparent ? 'transparent' : 'var(--card-bg)',
boxShadow: widget.transparent ? 'none' : 'var(--shadow)',
...(needsFixedMobileHeight(widget) ? { height: '60vh', minHeight: 360 } : {}),
}}
>
Expand Down
26 changes: 24 additions & 2 deletions client/src/components/PluginWidgetWrapper.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { Box } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { API_BASE_URL } from '../utils/apiConfig.js';
import { getDeviceName } from '../utils/deviceName.js';
import { subscribePluginEvents } from '../utils/pluginEventBridge.js';
import { acceptPluginDataMessage, emitPluginDataChanged } from '../utils/pluginDataBridge.js';
import { buildPluginThemeMessage } from '../utils/pluginThemeBridge.js';

const PluginWidgetWrapper = ({
filename,
name,
theme,
// The dashboard's interface colors, so the plugin can follow the picked
// accent instead of the stylesheet's default. Null means "send theme only".
colors = null,
transparentBackground = false,
refreshNonce = 0,
events = [],
Expand Down Expand Up @@ -68,6 +72,23 @@ const PluginWidgetWrapper = ({
return () => window.removeEventListener('message', onMessage);
}, [iframeOrigin, filename]);

// Theme and colors travel by postMessage (issue #179). The iframe's document
// only ever receives the static stylesheet, so the picked accent would never
// reach it otherwise. Posted on every load of the frame (the src changes on
// refresh and on a hidden-controls change) and again whenever the theme or
// the colors change while it is up. Same target origin rule as the events.
const themeMessage = useMemo(() => buildPluginThemeMessage(theme, colors), [theme, colors]);
const themeMessageRef = useRef(themeMessage);
themeMessageRef.current = themeMessage;
const postTheme = useCallback(() => {
const target = iframeRef.current?.contentWindow;
if (!target) return;
target.postMessage(themeMessageRef.current, iframeOrigin);
}, [iframeOrigin]);
useEffect(() => {
postTheme();
}, [postTheme, themeMessage]);

// Omitted entirely when nothing is hidden: a plugin (and an older dashboard)
// must read "no hide param" as "hide nothing". Flattened to a string so an
// unchanged hidden set produces a byte-identical src — changing src navigates
Expand All @@ -85,10 +106,11 @@ const PluginWidgetWrapper = ({
<iframe
ref={iframeRef}
key={refreshNonce}
onLoad={postTheme}
// lang rides the same channel as theme (issue #137) so a plugin that
// ships translations can follow the display's language; plugins that
// ignore it are unaffected.
src={`${API_BASE_URL}/widgets/${filename}?theme=${theme}&device=${encodeURIComponent(deviceName)}&lang=${i18n.language || 'en'}${hideParam ? `&hide=${hideParam}` : ''}`}
src={`${API_BASE_URL}/widgets/${filename}?theme=${theme}&device=${encodeURIComponent(deviceName)}&lang=${i18n.language || 'en'}${hideParam ? `&hide=${hideParam}` : ''}${transparentBackground ? '&transparent=true' : ''}`}
title={name}
style={{
width: '100%',
Expand Down
10 changes: 7 additions & 3 deletions client/src/components/WidgetContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,10 @@ const WidgetContainer = ({
>
{widgets.map((widget) => {
const isSelected = !locked && selectedWidget === widget.id;
// A transparent widget shows the page background through its box: no
// card color and no resting shadow, which would otherwise draw the
// rectangle the setting is meant to remove. The selection border stays.
const restingShadow = widget.transparent ? 'none' : '0 2px 8px rgba(0, 0, 0, 0.1)';
const currentLayout = layout.find(l => l.i === widget.id);
const fallbackLayout = {
i: widget.id,
Expand Down Expand Up @@ -618,8 +622,8 @@ const WidgetContainer = ({
transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
boxShadow: isSelected
? '0 8px 32px rgba(var(--accent-rgb), 0.3)'
: '0 2px 8px rgba(0, 0, 0, 0.1)',
backgroundColor: 'var(--card-bg)',
: restingShadow,
backgroundColor: widget.transparent ? 'transparent' : 'var(--card-bg)',
overflow: 'hidden',
cursor: locked ? 'default' : (isSelected ? 'move' : 'pointer'),
touchAction: locked ? 'auto' : (isSelected ? 'none' : 'manipulation'),
Expand All @@ -631,7 +635,7 @@ const WidgetContainer = ({
? '3px solid var(--accent)'
: '3px solid rgba(var(--accent-rgb), 0.3)'),
boxShadow: locked
? '0 2px 8px rgba(0, 0, 0, 0.1)'
? restingShadow
: (isSelected
? '0 8px 32px rgba(var(--accent-rgb), 0.3)'
: '0 4px 16px rgba(0, 0, 0, 0.15)'),
Expand Down
1 change: 1 addition & 0 deletions client/src/i18n/locales/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
"widgets": {
"help": "Enable widgets to show them on the dashboard. Click to select a widget, then drag to move or resize from corners.",
"showOnTabs": "Show on Tabs",
"transparentBackground": "Transparent Background",
"selectTabs": "Select tabs...",
"weatherWidget": "Weather Widget",
"saveSettings": "Save Widget Settings",
Expand Down
1 change: 1 addition & 0 deletions client/src/i18n/locales/es/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
"widgets": {
"help": "Activa widgets para mostrarlos en el panel. Haz clic para seleccionar uno y arrastra para moverlo o cambiar su tamaño desde las esquinas.",
"showOnTabs": "Mostrar en las pestañas",
"transparentBackground": "Fondo transparente",
"selectTabs": "Selecciona pestañas...",
"weatherWidget": "Widget del tiempo",
"saveSettings": "Guardar ajustes de widgets",
Expand Down
6 changes: 0 additions & 6 deletions client/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,6 @@ body {
transform: translateY(-2px);
}

.card.transparent-card {
background: transparent;
backdrop-filter: none;
border: 1px solid rgba(255, 255, 255, 0.1);
}

/* React Grid Layout Styles */
.react-grid-layout {
position: relative;
Expand Down
31 changes: 31 additions & 0 deletions client/src/utils/pluginThemeBridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { hexToRgbTriplet } from './interfaceSettings.js';

// The theme message a PluginWidgetWrapper posts into its iframe. A plugin is a
// separate document: it gets the static /index.css and nothing else, so without
// this it renders the stylesheet's default accent while the dashboard beside
// it shows the picked one. The SDK writes these values onto the plugin's root
// under the same variable names, so a plugin written against var(--accent)
// follows the pick with no change of its own.
export const PLUGIN_THEME_MESSAGE_TYPE = 'homeglow:theme';

const pickColor = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);

/**
* Build the message for a theme and an interface-colors object. Unknown themes
* become 'dark' (the stylesheet's :root), missing colors become null so the SDK
* leaves that variable on its stylesheet default rather than clearing it.
*/
export function buildPluginThemeMessage(theme, colors) {
const source = colors && typeof colors === 'object' ? colors : {};
const accent = pickColor(source.accent);
return {
type: PLUGIN_THEME_MESSAGE_TYPE,
theme: theme === 'light' ? 'light' : 'dark',
colors: {
primary: pickColor(source.primary),
secondary: pickColor(source.secondary),
accent,
accentRgb: hexToRgbTriplet(accent),
},
};
}
30 changes: 30 additions & 0 deletions client/src/utils/pluginThemeBridge.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { PLUGIN_THEME_MESSAGE_TYPE, buildPluginThemeMessage } from './pluginThemeBridge.js';

describe('buildPluginThemeMessage', () => {
it('carries theme, the three colors and the accent triplet', () => {
expect(buildPluginThemeMessage('light', { primary: '#f5f5f5', secondary: '#38bdf8', accent: '#f472b6' })).toEqual({
type: PLUGIN_THEME_MESSAGE_TYPE,
theme: 'light',
colors: { primary: '#f5f5f5', secondary: '#38bdf8', accent: '#f472b6', accentRgb: '244, 114, 182' },
});
});

it('falls back to dark for anything that is not light', () => {
// The stylesheet's :root is the dark palette, so "unknown" must land there.
for (const theme of ['dark', 'auto', '', null, undefined, 3]) {
expect(buildPluginThemeMessage(theme, {}).theme).toBe('dark');
}
});

it('sends null, not a cleared value, for colors it does not have', () => {
// null tells the SDK to leave that variable on its stylesheet default.
const message = buildPluginThemeMessage('dark', { accent: ' ' });
expect(message.colors).toEqual({ primary: null, secondary: null, accent: null, accentRgb: null });
expect(buildPluginThemeMessage('dark', null).colors.accent).toBeNull();
});

it('leaves accentRgb null when the accent is not a hex color', () => {
expect(buildPluginThemeMessage('dark', { accent: 'hotpink' }).colors).toMatchObject({ accent: 'hotpink', accentRgb: null });
});
});
20 changes: 20 additions & 0 deletions docs/guides/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ developing. For external services without CORS headers, use the proxy:
Widgets are stored in HomeGlow's database, so they (and everything below)
survive app upgrades.

### Theme colors arrive by message

`/index.css` gives you the variable names and their defaults, but the household's
picked interface colors are applied by the dashboard at runtime, and your widget
is a separate document. So the dashboard also posts them into your iframe, on
every load and on every change:

```js
{ type: 'homeglow:theme', theme: 'dark', colors: { primary, secondary, accent, accentRgb } }
```

If you load the SDK, there is nothing to do: it writes `--primary`, `--secondary`,
`--accent` and `--accent-rgb` onto your root and sets `data-theme`, so CSS that
reads `var(--accent, #757575)` follows the pick. Keep the fallback; it is what a
direct load of the file gets. For canvas drawing or anything that needs the
value in JavaScript, `HomeGlow.theme` holds the last message and
`HomeGlow.onTheme(handler)` calls you now and on every change. The `?theme=`
param is still on the URL, so a widget without the SDK starts in the right theme
and only misses the colors.

## 2. Becoming a platform plugin: the manifest

Embed a manifest as a JSON script block anywhere in your HTML (conventionally in
Expand Down
62 changes: 62 additions & 0 deletions server/plugin-sdk/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,48 @@
// plugin manifest are forwarded, so HomeGlow.on() never sees undeclared
// events.
var eventHandlers = {};

// Theme (issue #179). The dashboard applies the picked interface colors to
// its own root at runtime; this document only gets the static stylesheet.
// The wrapper posts the current theme and colors on every load of the frame
// and on every change, and they are written here under the same variable
// names, so var(--accent) in plugin CSS follows the pick. Values are
// validated before they touch the stylesheet: a color is a hex literal, the
// triplet is three decimal bytes, the theme is one of the two names.
var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
var RGB_TRIPLET = /^\d{1,3}, \d{1,3}, \d{1,3}$/;
var THEME_VARIABLES = [['primary', '--primary'], ['secondary', '--secondary'], ['accent', '--accent']];
var currentTheme = null;
var themeHandlers = [];
function applyTheme(data) {
var root = document.documentElement;
var theme = data.theme === 'light' ? 'light' : (data.theme === 'dark' ? 'dark' : null);
if (theme) root.setAttribute('data-theme', theme);
var colors = data.colors && typeof data.colors === 'object' ? data.colors : {};
THEME_VARIABLES.forEach(function (pair) {
var value = colors[pair[0]];
if (typeof value === 'string' && HEX_COLOR.test(value)) root.style.setProperty(pair[1], value);
});
if (typeof colors.accentRgb === 'string' && RGB_TRIPLET.test(colors.accentRgb)) {
root.style.setProperty('--accent-rgb', colors.accentRgb);
}
currentTheme = { theme: theme, colors: colors };
themeHandlers.slice().forEach(function (handler) {
try {
handler(currentTheme);
} catch (error) {
console.error('HomeGlow SDK: theme handler failed', error);
}
});
}

window.addEventListener('message', function (messageEvent) {
if (messageEvent.source !== window.parent) return;
var data = messageEvent.data;
if (data && data.type === 'homeglow:theme') {
applyTheme(data);
return;
}
if (!data || data.type !== 'homeglow:event' || typeof data.event !== 'string') return;
var handlers = eventHandlers[data.event];
if (!handlers) return;
Expand Down Expand Up @@ -98,6 +137,29 @@
return function () { self.off(event, handler); };
},

/**
* The last theme the dashboard sent: { theme: 'dark'|'light', colors: {
* primary, secondary, accent, accentRgb } }, or null before the first
* message. CSS that reads var(--accent) needs none of this; it is for
* canvas drawing and the like.
*/
get theme() {
return currentTheme;
},

/**
* Subscribe to theme changes. Called at once with the current theme if one
* has arrived. Returns an unsubscribe function.
*/
onTheme: function (handler) {
themeHandlers.push(handler);
if (currentTheme) handler(currentTheme);
return function () {
var index = themeHandlers.indexOf(handler);
if (index !== -1) themeHandlers.splice(index, 1);
};
},

/** Remove a handler added with on(). */
off: function (event, handler) {
var handlers = eventHandlers[event];
Expand Down
Loading
Loading