diff --git a/client/src/themes/engine/layers/particles.jsx b/client/src/themes/engine/layers/particles.jsx
index 59795eaa..7380acf1 100644
--- a/client/src/themes/engine/layers/particles.jsx
+++ b/client/src/themes/engine/layers/particles.jsx
@@ -1,10 +1,12 @@
import React, { useMemo } from 'react';
import { Box } from '@mui/material';
-import { draw, fall, forMode, pick, reducedMotion, rise, twinkle } from '../motion.js';
+import { draw, fall, forMode, pick, reducedMotion, rise, swingX, twinkle } from '../motion.js';
// Many small things: a picture from the theme's folder, or plain glowing dots
// in the theme's colors. They rise (bubbles), fall (snow, leaves) or twinkle
-// in place (stars).
+// in place (stars). A falling or rising item can also swing side to side
+// (swing, in px, over swingSeconds) and be softly blurred (blur, in px): the
+// swing runs on a wrapper so it never fights the vertical fall/rise transform.
function Particles({ options, assets, random, mode }) {
const url = options.src ? assets[forMode(options.src, mode)] : null;
const motion = options.motion || 'rise';
@@ -21,6 +23,13 @@ function Particles({ options, assets, random, mode }) {
delay: -random() * (motion === 'twinkle' ? 6 : seconds),
drift: (random() - 0.5) * 2 * draw(random, options.drift ?? 40),
color: options.colors ? pick(random, options.colors) : '#ffffff',
+ // Drawn after color, and only when the theme sets them: a range
+ // default here would consume a draw and reshuffle every existing
+ // particle layer's seeded layout, which the per-layer seeds exist
+ // to keep stable.
+ swing: options.swing === undefined ? 0 : draw(random, options.swing),
+ swingSeconds: options.swing === undefined ? 0 : draw(random, options.swingSeconds ?? [3, 6]),
+ blur: options.blur === undefined ? 0 : draw(random, options.blur),
};
});
}, [options, random, motion]);
@@ -34,6 +43,33 @@ function Particles({ options, assets, random, mode }) {
? { top: `${item.top}%` }
: motion === 'fall' ? { top: -item.size - 10 } : { bottom: -item.size - 10 };
const keyframes = motion === 'twinkle' ? twinkle : motion === 'fall' ? fall : rise;
+ const fallAnimation = `${keyframes} ${item.seconds.toFixed(2)}s ${motion === 'twinkle' ? 'ease-in-out' : 'linear'} ${item.delay.toFixed(2)}s infinite${motion === 'twinkle' ? ' alternate' : ''}`;
+ const innerSx = {
+ width: item.swing > 0 ? '100%' : item.size,
+ height: item.swing > 0 ? '100%' : item.size,
+ ...look,
+ opacity: motion === 'twinkle' ? undefined : options.opacity ?? 1,
+ ...(item.blur > 0 ? { filter: `blur(${item.blur.toFixed(1)}px)` } : {}),
+ '--drift': `${Math.round(item.drift)}px`,
+ animation: fallAnimation,
+ willChange: 'transform, opacity',
+ ...reducedMotion,
+ };
+ const inner = ;
+ if (item.swing <= 0) {
+ // No swing wrapper: the item itself carries the inner look, positioned.
+ return (
+
+ );
+ }
return (
+ >
+ {inner}
+
);
});
}
diff --git a/client/src/themes/engine/motion.js b/client/src/themes/engine/motion.js
index 45256502..5789f558 100644
--- a/client/src/themes/engine/motion.js
+++ b/client/src/themes/engine/motion.js
@@ -112,6 +112,15 @@ export const sway = keyframes`
to { transform: rotate(var(--angle-to)); }
`;
+// A particle swinging side to side while it falls or rises: a horizontal
+// oscillation around its path, run as a second animation alongside the
+// vertical one (particles.jsx wraps the falling item in this when a theme
+// sets swing, so the two transforms never fight over one element).
+export const swingX = keyframes`
+ from { transform: translateX(calc(-1 * var(--swing))); }
+ to { transform: translateX(var(--swing)); }
+`;
+
// A bending picture's slices (flex.jsx): each narrows toward its joint.
export const beatX = keyframes`
from { transform: scaleX(1); }
diff --git a/client/src/themes/engine/schemas.js b/client/src/themes/engine/schemas.js
index 08f40392..553ca90e 100644
--- a/client/src/themes/engine/schemas.js
+++ b/client/src/themes/engine/schemas.js
@@ -68,6 +68,9 @@ export const LAYER_SCHEMAS = {
size: range(1, 120),
seconds,
drift: range(0, 400),
+ swing: range(0, 200),
+ swingSeconds: range(0.5, 30),
+ blur: range(0, 10),
colors: { type: 'colors' },
span: { type: 'span' },
opacity: num(0, 1),
diff --git a/client/src/utils/themes.js b/client/src/utils/themes.js
index 7b032445..96fcf47c 100644
--- a/client/src/utils/themes.js
+++ b/client/src/utils/themes.js
@@ -301,7 +301,8 @@ export function validateThemePackage(pkg, { assets } = {}) {
// and clumped sprites; 3, the button roles and curved flyby crossings
// (path, count, begin and the rest); 4, weather scenes and lightning
// flashes; 5, orbits; 6, a confetti cast; 7, bending pictures (flex);
- // 8, a frame backdrop that saturates; 9, a flyby lens.
+ // 8, a frame backdrop that saturates; 9, a flyby lens; 10, particle
+ // swing and blur.
// A theme that uses them says so, so a core too old
// to draw them refuses it plainly.
const tokenNames = ['all', ...MODES].flatMap((key) => Object.keys(pkg.tokens?.[key] || {}));
@@ -342,6 +343,11 @@ export function validateThemePackage(pkg, { assets } = {}) {
}) && 'saturate',
]);
needs(9, [layers.some((layer) => layer?.layer === 'flyby' && layer.lens !== undefined) && 'lens']);
+ needs(10, [
+ layers.some((layer) => layer?.layer === 'particles'
+ && (layer.swing !== undefined || layer.swingSeconds !== undefined)) && 'particle swing',
+ layers.some((layer) => layer?.layer === 'particles' && layer.blur !== undefined) && 'particle blur',
+ ]);
}
if (typeof pkg.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(pkg.id)) errors.push('id must be a lowercase slug (a-z, 0-9, hyphens, max 64)');
if (typeof pkg.name !== 'string' || !pkg.name.trim() || pkg.name.length > 60) errors.push('name is required');
@@ -390,10 +396,10 @@ export const DEFAULT_THEME_ID = 'classic';
* theme it cannot draw rather than half-applying it (theme-architecture.md
* §5). 2: ornaments and the meter roles. 3: the button roles and curved
* flybys. 4: weather scenes and the flash layer. 5: orbits. 6: a confetti
- * cast. 7: bending pictures (flex). 8: saturate() in the frame backdrop. 9: a flyby lens. The server's MANIFEST_VERSION (services/themeStore.js) matches; a
+ * cast. 7: bending pictures (flex). 8: saturate() in the frame backdrop. 9: a flyby lens. 10: particle swing and blur. The server's MANIFEST_VERSION (services/themeStore.js) matches; a
* server test holds them equal.
*/
-export const MANIFEST_VERSION = 9;
+export const MANIFEST_VERSION = 10;
/** The problem reported for a theme newer than this core; the admin page words it for people. */
export const NEEDS_NEWER_HOMEGLOW = 'needs a newer version of HomeGlow';
diff --git a/client/src/utils/themes.test.js b/client/src/utils/themes.test.js
index b7a793ca..bd676289 100644
--- a/client/src/utils/themes.test.js
+++ b/client/src/utils/themes.test.js
@@ -124,6 +124,12 @@ describe('validateThemePackage', () => {
expect(validateThemePackage({ ...base, manifestVersion: 9, ambience }, { assets: ['assets/hole.svg'] })).toEqual([]);
});
+ it('needs manifest version 10 for particle swing and blur', () => {
+ const ambience = [{ layer: 'particles', src: 'assets/leaf.svg', motion: 'fall', count: 5, swing: 40, swingSeconds: 5, blur: 1.2 }];
+ expect(validateThemePackage({ ...base, manifestVersion: 9, ambience }, { assets: ['assets/leaf.svg'] })).toEqual(['particle swing, particle blur need manifestVersion 10']);
+ expect(validateThemePackage({ ...base, manifestVersion: 10, ambience }, { assets: ['assets/leaf.svg'] })).toEqual([]);
+ });
+
it('checks the package shape', () => {
expect(validateThemePackage({ manifestVersion: 1, id: 'Bad Id', name: '' })).toHaveLength(2);
expect(validateThemePackage({ ...base, modes: ['sepia'] })).toHaveLength(1);
@@ -131,7 +137,7 @@ describe('validateThemePackage', () => {
});
it('follows the plugin manifest rules for manifestVersion, author and version', () => {
- expect(validateThemePackage({ id: 'probe', name: 'Probe' })).toEqual(['manifestVersion must be 1 to 9']);
+ expect(validateThemePackage({ id: 'probe', name: 'Probe' })).toEqual(['manifestVersion must be 1 to 10']);
expect(validateThemePackage({ ...base, author: 'Jane Diver', version: '2.1.0' })).toEqual([]);
expect(validateThemePackage({ ...base, author: 'x'.repeat(81) })).toHaveLength(1);
expect(validateThemePackage({ ...base, author: { name: 'Jane' } })).toHaveLength(1);
diff --git a/docs/guides/theme-development.md b/docs/guides/theme-development.md
index e5cd9447..b9a5f1fa 100644
--- a/docs/guides/theme-development.md
+++ b/docs/guides/theme-development.md
@@ -48,7 +48,7 @@ folder name must match the manifest's `id`.
| Field | Required | Meaning |
| --- | --- | --- |
-| `manifestVersion` | yes | `1`; `2` for a theme that uses `ornaments`, the meter tokens or `clumps`; `3` for one that uses the button tokens or curved flybys; `4` for one with `weather` scenes or a `flash` layer; `5` for one that uses orbits; `6` for one with a confetti `cast`; `7` for one with pictures that bend (`flex`); `8` for one whose frames saturate what is behind them (`saturate()` in `--hg-frame-backdrop`); `9` for one with a flyby `lens`. A HomeGlow older than the version refuses the theme. |
+| `manifestVersion` | yes | `1`; `2` for a theme that uses `ornaments`, the meter tokens or `clumps`; `3` for one that uses the button tokens or curved flybys; `4` for one with `weather` scenes or a `flash` layer; `5` for one that uses orbits; `6` for one with a confetti `cast`; `7` for one with pictures that bend (`flex`); `8` for one whose frames saturate what is behind them (`saturate()` in `--hg-frame-backdrop`); `9` for one with a flyby `lens`; `10` for one whose particles `swing` or `blur`. A HomeGlow older than the version refuses the theme. |
| `id` | yes | Lowercase slug (a-z, 0-9, hyphens), the same as the folder name. |
| `name` | yes | Shown in the theme picker. |
| `version`, `author`, `description` | | As in a plugin manifest. The author shows as "by ...". |
@@ -120,7 +120,7 @@ stars.
| --- | --- | --- |
| `image` | A still picture, or one of a list | `src` (a picture or a list), `anchor` (`bottom`, `top`, `center`, `fill`), `height` (e.g. `"42vh"`), `angle` (turns it, growing it to cover the screen), `flip`, `opacity`, `flex` (see [Pictures that bend](#4d-pictures-that-bend)) |
| `sprites` | Copies of a picture, or a mix of pictures, spread across the screen | `src` or `pictures`, `count`, `height` (vh), `aspect` (width ÷ height), `span` (`[from, to]` %), `base` (distance from the bottom), `clumps` and `clumpWidth` (gather copies into that many patches, each that many percent wide, with open ground between, as plants grow), `lift` (percent of the screen above `base`, drawn per copy), `hue` (degrees to turn each copy's colors), `motion` (`sway`, `bob`, `pulse`, `none`), `angle` (sway degrees) or `distance` (bob px), `seconds`, `tint`, `flip`, `current`, `opacity`. Each of its `pictures` can bend with `flex` (see [Pictures that bend](#4d-pictures-that-bend)). |
-| `particles` | Many small things | `src` or `colors` (plain dots), `motion` (`rise`, `fall`, `twinkle`), `count`, `size` (px), `seconds`, `drift` (px), `span`, `opacity` |
+| `particles` | Many small things | `src` or `colors` (plain dots), `motion` (`rise`, `fall`, `twinkle`), `count`, `size` (px), `seconds`, `drift` (px), `swing` (px, manifest version 10) and `swingSeconds` (how fast it swings side to side), `blur` (px, manifest version 10), `span`, `opacity` |
| `dots` | A still field of dots, painted once | `count`, `colors`, `size`, `opacity`. Each dot's size and opacity follow one brightness, mostly dim, so dim dots are small; the brightest get a halo. |
| `field` | Drifting light (caustics) | `strength` (`soft`, `bright`), `spacing`, `seconds` |
| `streaks` | An occasional streak on a random path | `color`, `length`, `every` (pause, seconds), `seconds`, `burst` (streaks per pass: a meteor storm) |
diff --git a/server/services/themeStore.js b/server/services/themeStore.js
index 78ce43a1..7eff76b8 100644
--- a/server/services/themeStore.js
+++ b/server/services/themeStore.js
@@ -21,7 +21,7 @@ const THEME_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
// The newest theme manifest this core understands (client/src/utils/themes.js,
// MANIFEST_VERSION; a test holds them equal). Newer themes are refused at
// install and left out of the store's list.
-const MANIFEST_VERSION = 9;
+const MANIFEST_VERSION = 10;
// Classic is the stylesheet itself; no package can replace it.
const RESERVED_IDS = new Set(['classic']);
const MAX_FILES = 64;