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;