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
52 changes: 44 additions & 8 deletions client/src/themes/engine/layers/particles.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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]);
Expand All @@ -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 = <Box sx={innerSx} />;
if (item.swing <= 0) {
// No swing wrapper: the item itself carries the inner look, positioned.
return (
<Box
key={i}
sx={{
position: 'absolute',
left: `${item.left}%`,
...where,
...innerSx,
}}
/>
);
}
return (
<Box
key={i}
Expand All @@ -43,14 +79,14 @@ function Particles({ options, assets, random, mode }) {
...where,
width: item.size,
height: item.size,
...look,
opacity: motion === 'twinkle' ? undefined : options.opacity ?? 1,
'--drift': `${Math.round(item.drift)}px`,
animation: `${keyframes} ${item.seconds.toFixed(2)}s ${motion === 'twinkle' ? 'ease-in-out' : 'linear'} ${item.delay.toFixed(2)}s infinite${motion === 'twinkle' ? ' alternate' : ''}`,
willChange: 'transform, opacity',
'--swing': `${Math.round(item.swing)}px`,
animation: `${swingX} ${item.swingSeconds.toFixed(2)}s ease-in-out infinite alternate`,
willChange: 'transform',
...reducedMotion,
}}
/>
>
{inner}
</Box>
);
});
}
Expand Down
9 changes: 9 additions & 0 deletions client/src/themes/engine/motion.js
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
3 changes: 3 additions & 0 deletions client/src/themes/engine/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
12 changes: 9 additions & 3 deletions client/src/utils/themes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] || {}));
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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';
Expand Down
8 changes: 7 additions & 1 deletion client/src/utils/themes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,20 @@ 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);
expect(validateThemePackage({ ...base, mui: { radius: 500, wobble: true } })).toHaveLength(2);
});

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);
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/theme-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...". |
Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion server/services/themeStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading