From d078865bc0267d1f6f8d2a6695d7ba68a5739f43 Mon Sep 17 00:00:00 2001 From: adamecker Date: Sun, 25 Oct 2026 15:25:53 -0700 Subject: [PATCH 1/4] feat(themes): particle sway and blur, manifest version 10 Particles gain sway (px, over swaySeconds), swaySeconds and blur (px): the sway runs on a wrapper so it never fights the vertical fall/rise transform. Client and server MANIFEST_VERSION move 9 to 10, and validation refuses sway or blur below version 10. Docs updated. No theme data ships here; a version 10 theme such as Harvest exercises it. --- client/src/themes/engine/layers/particles.jsx | 58 ++++++++++++++++--- client/src/themes/engine/motion.js | 9 +++ client/src/themes/engine/schemas.js | 3 + client/src/utils/themes.js | 12 +++- client/src/utils/themes.test.js | 8 ++- docs/guides/theme-development.md | 2 +- server/services/themeStore.js | 2 +- 7 files changed, 80 insertions(+), 14 deletions(-) diff --git a/client/src/themes/engine/layers/particles.jsx b/client/src/themes/engine/layers/particles.jsx index 59795eaa..163cd468 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, swayX, 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 sway side to side +// (sway, in px, over swaySeconds) and be softly blurred (blur, in px): the +// sway 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'; @@ -20,6 +22,9 @@ function Particles({ options, assets, random, mode }) { seconds, delay: -random() * (motion === 'twinkle' ? 6 : seconds), drift: (random() - 0.5) * 2 * draw(random, options.drift ?? 40), + sway: draw(random, options.sway ?? 0), + swaySeconds: draw(random, options.swaySeconds ?? [3, 6]), + blur: draw(random, options.blur ?? 0), color: options.colors ? pick(random, options.colors) : '#ffffff', }; }); @@ -34,6 +39,43 @@ 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 inner = ( + 0 ? '100%' : item.size, + height: item.sway > 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, + }} + /> + ); + if (item.sway <= 0) { + return ( + 0 ? { filter: `blur(${item.blur.toFixed(1)}px)` } : {}), + '--drift': `${Math.round(item.drift)}px`, + animation: fallAnimation, + willChange: 'transform, opacity', + ...reducedMotion, + }} + /> + ); + } return ( + > + {inner} + ); }); } diff --git a/client/src/themes/engine/motion.js b/client/src/themes/engine/motion.js index 45256502..d61f2299 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 swaying 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 sway, so the two transforms never fight over one element). +export const swayX = keyframes` + from { transform: translateX(calc(-1 * var(--sway))); } + to { transform: translateX(var(--sway)); } +`; + // 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..75dfde0f 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), + sway: range(0, 200), + swaySeconds: 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..769119ad 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 + // sway 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.sway !== undefined || layer.swaySeconds !== undefined)) && 'particle sway', + 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 sway 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..56a0a911 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 sway and blur', () => { + const ambience = [{ layer: 'particles', src: 'assets/leaf.svg', motion: 'fall', count: 5, sway: 40, swaySeconds: 5, blur: 1.2 }]; + expect(validateThemePackage({ ...base, manifestVersion: 9, ambience }, { assets: ['assets/leaf.svg'] })).toEqual(['particle sway, 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..3a5ec482 100644 --- a/docs/guides/theme-development.md +++ b/docs/guides/theme-development.md @@ -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), `sway` (px, manifest version 10) and `swaySeconds` (how fast it sways 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; From 329f08c95501c0b7ee8964d65103750809bcabff Mon Sep 17 00:00:00 2001 From: adamecker Date: Sun, 25 Oct 2026 16:40:46 -0700 Subject: [PATCH 2/4] fix(themes): keep existing particle layouts stable, dedupe no-sway sx Sway/swaySeconds/blur are now drawn after the color pick, and only when the theme sets sway or blur: the swaySeconds [3, 6] default was consuming a draw for every particle even on themes with no sway, reshuffling the seeded color/layout of existing layers (bubbles, snow, stars). The no-sway branch now reuses the inner element's sx instead of repeating it. Addresses mrramam's review on #338; the sway/swing naming question is left open for Adam since Harvest 1.1.0 already uses sway. --- client/src/themes/engine/layers/particles.jsx | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/client/src/themes/engine/layers/particles.jsx b/client/src/themes/engine/layers/particles.jsx index 163cd468..266f9acb 100644 --- a/client/src/themes/engine/layers/particles.jsx +++ b/client/src/themes/engine/layers/particles.jsx @@ -22,10 +22,14 @@ function Particles({ options, assets, random, mode }) { seconds, delay: -random() * (motion === 'twinkle' ? 6 : seconds), drift: (random() - 0.5) * 2 * draw(random, options.drift ?? 40), - sway: draw(random, options.sway ?? 0), - swaySeconds: draw(random, options.swaySeconds ?? [3, 6]), - blur: draw(random, options.blur ?? 0), 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. + sway: options.sway === undefined ? 0 : draw(random, options.sway), + swaySeconds: options.sway === undefined ? 0 : draw(random, options.swaySeconds ?? [3, 6]), + blur: options.blur === undefined ? 0 : draw(random, options.blur), }; }); }, [options, random, motion]); @@ -40,22 +44,20 @@ function Particles({ options, assets, random, mode }) { : 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 inner = ( - 0 ? '100%' : item.size, - height: item.sway > 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 innerSx = { + width: item.sway > 0 ? '100%' : item.size, + height: item.sway > 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.sway <= 0) { + // No sway wrapper: the item itself carries the inner look, positioned. return ( 0 ? { filter: `blur(${item.blur.toFixed(1)}px)` } : {}), - '--drift': `${Math.round(item.drift)}px`, - animation: fallAnimation, - willChange: 'transform, opacity', - ...reducedMotion, + ...innerSx, }} /> ); From 046d444ea4fa93205e01915417f616890cb5c505 Mon Sep 17 00:00:00 2001 From: adamecker Date: Sun, 25 Oct 2026 17:07:27 -0700 Subject: [PATCH 3/4] refactor(themes): rename particle sway to swing Particle sway is a sideways distance in px, while sway already means rotation in degrees for sprites (motion: 'sway' with angle, and the sway keyframe). Renaming the particle fields to swing and swingSeconds keeps the guide unambiguous. Sprite and flex sway are untouched. Client suite 788/788, build clean. --- client/src/themes/engine/layers/particles.jsx | 24 +++++++++---------- client/src/themes/engine/motion.js | 10 ++++---- client/src/themes/engine/schemas.js | 4 ++-- client/src/utils/themes.js | 6 ++--- client/src/utils/themes.test.js | 6 ++--- docs/guides/theme-development.md | 2 +- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/client/src/themes/engine/layers/particles.jsx b/client/src/themes/engine/layers/particles.jsx index 266f9acb..7380acf1 100644 --- a/client/src/themes/engine/layers/particles.jsx +++ b/client/src/themes/engine/layers/particles.jsx @@ -1,12 +1,12 @@ import React, { useMemo } from 'react'; import { Box } from '@mui/material'; -import { draw, fall, forMode, pick, reducedMotion, rise, swayX, 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). A falling or rising item can also sway side to side -// (sway, in px, over swaySeconds) and be softly blurred (blur, in px): the -// sway runs on a wrapper so it never fights the vertical fall/rise transform. +// 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'; @@ -27,8 +27,8 @@ function Particles({ options, assets, random, mode }) { // default here would consume a draw and reshuffle every existing // particle layer's seeded layout, which the per-layer seeds exist // to keep stable. - sway: options.sway === undefined ? 0 : draw(random, options.sway), - swaySeconds: options.sway === undefined ? 0 : draw(random, options.swaySeconds ?? [3, 6]), + 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), }; }); @@ -45,8 +45,8 @@ function Particles({ options, assets, random, mode }) { 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.sway > 0 ? '100%' : item.size, - height: item.sway > 0 ? '100%' : item.size, + 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)` } : {}), @@ -56,8 +56,8 @@ function Particles({ options, assets, random, mode }) { ...reducedMotion, }; const inner = ; - if (item.sway <= 0) { - // No sway wrapper: the item itself carries the inner look, positioned. + if (item.swing <= 0) { + // No swing wrapper: the item itself carries the inner look, positioned. return ( Object.keys(pkg.tokens?.[key] || {})); @@ -345,7 +345,7 @@ export function validateThemePackage(pkg, { assets } = {}) { needs(9, [layers.some((layer) => layer?.layer === 'flyby' && layer.lens !== undefined) && 'lens']); needs(10, [ layers.some((layer) => layer?.layer === 'particles' - && (layer.sway !== undefined || layer.swaySeconds !== undefined)) && 'particle sway', + && (layer.swing !== undefined || layer.swingSeconds !== undefined)) && 'particle swing', layers.some((layer) => layer?.layer === 'particles' && layer.blur !== undefined) && 'particle blur', ]); } @@ -396,7 +396,7 @@ 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. 10: particle sway and blur. 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 = 10; diff --git a/client/src/utils/themes.test.js b/client/src/utils/themes.test.js index 56a0a911..bd676289 100644 --- a/client/src/utils/themes.test.js +++ b/client/src/utils/themes.test.js @@ -124,9 +124,9 @@ describe('validateThemePackage', () => { expect(validateThemePackage({ ...base, manifestVersion: 9, ambience }, { assets: ['assets/hole.svg'] })).toEqual([]); }); - it('needs manifest version 10 for particle sway and blur', () => { - const ambience = [{ layer: 'particles', src: 'assets/leaf.svg', motion: 'fall', count: 5, sway: 40, swaySeconds: 5, blur: 1.2 }]; - expect(validateThemePackage({ ...base, manifestVersion: 9, ambience }, { assets: ['assets/leaf.svg'] })).toEqual(['particle sway, particle blur need manifestVersion 10']); + 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([]); }); diff --git a/docs/guides/theme-development.md b/docs/guides/theme-development.md index 3a5ec482..b26cdfac 100644 --- a/docs/guides/theme-development.md +++ b/docs/guides/theme-development.md @@ -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), `sway` (px, manifest version 10) and `swaySeconds` (how fast it sways side to side), `blur` (px, manifest version 10), `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) | From 0465a3eff140096af0eb4348e472dea3d6878bd2 Mon Sep 17 00:00:00 2001 From: jherforth Date: Tue, 27 Oct 2026 08:40:27 -0400 Subject: [PATCH 4/4] docs(themes): manifest version 10 in the guide's version list Co-Authored-By: Claude Opus 5.5 --- docs/guides/theme-development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/theme-development.md b/docs/guides/theme-development.md index b26cdfac..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 ...". |