diff --git a/server/src/wled_controller/static/css/appearance.css b/server/src/wled_controller/static/css/appearance.css
index aba68e7..ddb16e2 100644
--- a/server/src/wled_controller/static/css/appearance.css
+++ b/server/src/wled_controller/static/css/appearance.css
@@ -143,6 +143,94 @@ h1 {
to { opacity: 1; }
}
+/* ── Aurora preview: horizontal gradient bands ── */
+[data-effect="aurora"] .ap-bg-preview-inner {
+ background:
+ linear-gradient(180deg,
+ transparent 0%,
+ color-mix(in srgb, var(--primary-color) 18%, transparent) 25%,
+ transparent 50%,
+ color-mix(in srgb, var(--primary-color) 12%, transparent) 70%,
+ transparent 100%);
+ animation: ap-aurora-sway 6s ease-in-out infinite alternate;
+}
+
+@keyframes ap-aurora-sway {
+ from { transform: translateX(-5%) scaleY(1); opacity: 0.8; }
+ to { transform: translateX(5%) scaleY(1.15); opacity: 1; }
+}
+
+/* ── Plasma preview: color blobs ── */
+[data-effect="plasma"] .ap-bg-preview-inner {
+ background:
+ radial-gradient(circle at 30% 40%,
+ color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 50%),
+ radial-gradient(circle at 70% 60%,
+ color-mix(in srgb, var(--primary-color) 15%, transparent) 0%, transparent 50%),
+ radial-gradient(circle at 50% 20%,
+ color-mix(in srgb, var(--primary-color) 12%, transparent) 0%, transparent 40%);
+ animation: ap-plasma-cycle 5s ease-in-out infinite alternate;
+}
+
+@keyframes ap-plasma-cycle {
+ from { transform: scale(1) rotate(0deg); }
+ to { transform: scale(1.1) rotate(3deg); }
+}
+
+/* ── Digital Rain preview: vertical lines ── */
+[data-effect="rain"] .ap-bg-preview-inner {
+ background:
+ linear-gradient(180deg, var(--primary-color) 0%, transparent 60%) 10% 0 / 1px 70% no-repeat,
+ linear-gradient(180deg, var(--primary-color) 0%, transparent 50%) 25% 20% / 1px 50% no-repeat,
+ linear-gradient(180deg, var(--primary-color) 0%, transparent 60%) 40% 10% / 1px 60% no-repeat,
+ linear-gradient(180deg, var(--primary-color) 0%, transparent 40%) 55% 30% / 1px 40% no-repeat,
+ linear-gradient(180deg, var(--primary-color) 0%, transparent 55%) 70% 5% / 1px 55% no-repeat,
+ linear-gradient(180deg, var(--primary-color) 0%, transparent 50%) 85% 15% / 1px 50% no-repeat;
+ opacity: 0.4;
+ animation: ap-rain-fall 3s linear infinite;
+}
+
+@keyframes ap-rain-fall {
+ from { transform: translateY(-20%); }
+ to { transform: translateY(20%); }
+}
+
+/* ── Stars preview: scattered dots ── */
+[data-effect="stars"] .ap-bg-preview-inner {
+ background:
+ radial-gradient(circle 1px at 15% 20%, #fff 0%, transparent 100%),
+ radial-gradient(circle 1.5px at 40% 60%, var(--primary-color) 0%, transparent 100%),
+ radial-gradient(circle 1px at 65% 30%, #fff 0%, transparent 100%),
+ radial-gradient(circle 0.8px at 80% 70%, #fff 0%, transparent 100%),
+ radial-gradient(circle 1.2px at 30% 85%, var(--primary-color) 0%, transparent 100%),
+ radial-gradient(circle 0.7px at 55% 15%, #fff 0%, transparent 100%),
+ radial-gradient(circle 1px at 90% 45%, #fff 0%, transparent 100%),
+ radial-gradient(circle 0.9px at 10% 55%, #fff 0%, transparent 100%);
+ opacity: 0.7;
+ animation: ap-stars-twinkle 3s ease-in-out infinite alternate;
+}
+
+@keyframes ap-stars-twinkle {
+ from { opacity: 0.5; }
+ to { opacity: 0.8; }
+}
+
+/* ── Warp preview: radial tunnel ── */
+[data-effect="warp"] .ap-bg-preview-inner {
+ background: radial-gradient(circle at 50% 50%,
+ color-mix(in srgb, var(--primary-color) 20%, transparent) 0%,
+ transparent 30%,
+ color-mix(in srgb, var(--primary-color) 10%, transparent) 50%,
+ transparent 70%,
+ color-mix(in srgb, var(--primary-color) 6%, transparent) 90%);
+ animation: ap-warp-pulse 4s ease-in-out infinite;
+}
+
+@keyframes ap-warp-pulse {
+ 0%, 100% { transform: scale(1); opacity: 0.7; }
+ 50% { transform: scale(1.2); opacity: 1; }
+}
+
[data-effect="grid"] .ap-bg-preview-inner {
background-image:
radial-gradient(circle, var(--text-muted) 0.5px, transparent 0.5px);
@@ -168,21 +256,12 @@ h1 {
);
}
-[data-effect="particles"] .ap-bg-preview-inner {
- background:
- radial-gradient(circle 2px at 20% 40%, var(--primary-color) 0%, transparent 100%),
- radial-gradient(circle 1.5px at 60% 25%, var(--primary-color) 0%, transparent 100%),
- radial-gradient(circle 2px at 75% 65%, var(--primary-color) 0%, transparent 100%),
- radial-gradient(circle 1px at 40% 80%, var(--primary-color) 0%, transparent 100%),
- radial-gradient(circle 1.5px at 90% 35%, var(--primary-color) 0%, transparent 100%);
- opacity: 0.6;
-}
-
/* ═══ Full-page background effects ═══
Uses a dedicated
(same pattern as the WebGL canvas).
- The active effect class (e.g. .bg-effect-grid) is set directly on the div. */
+ The active effect class (e.g. .bg-effect-grid) is set directly on the div.
+ Shader effects use instead. */
-/* When a CSS bg effect is active, make body transparent so the layer shows through
+/* When a CSS/shader bg effect is active, make body transparent so the layer shows
(mirrors [data-bg-anim="on"] body { background: transparent } in base.css) */
[data-bg-effect] body {
background: transparent;
@@ -202,7 +281,7 @@ h1 {
background: color-mix(in srgb, var(--bg-color) 60%, transparent);
}
-/* Card translucency for CSS bg effects (match existing bg-anim behaviour) */
+/* Card translucency for bg effects (match existing bg-anim behaviour) */
[data-bg-effect][data-theme="dark"] .card,
[data-bg-effect][data-theme="dark"] .template-card,
[data-bg-effect][data-theme="dark"] .add-device-card,
@@ -227,17 +306,16 @@ h1 {
#bg-effect-layer.bg-effect-grid,
#bg-effect-layer.bg-effect-mesh,
-#bg-effect-layer.bg-effect-scanlines,
-#bg-effect-layer.bg-effect-particles {
+#bg-effect-layer.bg-effect-scanlines {
display: block;
}
/* ── Grid: dot matrix ── */
#bg-effect-layer.bg-effect-grid {
background-image:
- radial-gradient(circle 1px, var(--text-secondary) 0%, transparent 100%);
- background-size: 28px 28px;
- opacity: 0.35;
+ radial-gradient(circle 1.5px, var(--text-color) 0%, transparent 100%);
+ background-size: 24px 24px;
+ opacity: 0.18;
}
/* ── Gradient mesh: ambient blobs ── */
@@ -269,29 +347,6 @@ h1 {
);
}
-/* ── CSS Particles: glowing dots ── */
-#bg-effect-layer.bg-effect-particles {
- background:
- radial-gradient(circle 40px at 10% 20%, color-mix(in srgb, var(--primary-color) 30%, transparent) 0%, transparent 100%),
- radial-gradient(circle 25px at 30% 70%, color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%),
- radial-gradient(circle 50px at 55% 15%, color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 100%),
- radial-gradient(circle 30px at 70% 55%, color-mix(in srgb, var(--primary-color) 28%, transparent) 0%, transparent 100%),
- radial-gradient(circle 35px at 85% 35%, color-mix(in srgb, var(--primary-color) 22%, transparent) 0%, transparent 100%),
- radial-gradient(circle 20px at 45% 45%, color-mix(in srgb, var(--primary-color) 32%, transparent) 0%, transparent 100%),
- radial-gradient(circle 45px at 20% 85%, color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 100%),
- radial-gradient(circle 28px at 92% 78%, color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%),
- radial-gradient(circle 15px at 65% 90%, color-mix(in srgb, var(--primary-color) 35%, transparent) 0%, transparent 100%),
- radial-gradient(circle 35px at 5% 50%, color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 100%);
- animation: bg-particles-float 30s ease-in-out infinite alternate;
-}
-
-@keyframes bg-particles-float {
- 0% { transform: translate(0, 0); }
- 33% { transform: translate(15px, -20px); }
- 66% { transform: translate(-10px, 10px); }
- 100% { transform: translate(5px, -5px); }
-}
-
/* ─── Mobile: 2-column grid ─── */
@media (max-width: 480px) {
.ap-grid {
diff --git a/server/src/wled_controller/static/css/base.css b/server/src/wled_controller/static/css/base.css
index b3116ca..6bda796 100644
--- a/server/src/wled_controller/static/css/base.css
+++ b/server/src/wled_controller/static/css/base.css
@@ -132,7 +132,8 @@ html.modal-open {
}
/* ── Ambient animated background ── */
-#bg-anim-canvas {
+#bg-anim-canvas,
+#bg-effect-canvas {
display: none;
position: fixed;
inset: 0;
diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts
index dd5467d..4c38795 100644
--- a/server/src/wled_controller/static/js/app.ts
+++ b/server/src/wled_controller/static/js/app.ts
@@ -14,6 +14,7 @@ import { t, initLocale, changeLocale } from './core/i18n.ts';
// Layer 1.5: visual effects
import { initCardGlare } from './core/card-glare.ts';
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts';
+import { initBgShaders } from './core/bg-shaders.ts';
import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
// Layer 2: ui
@@ -634,6 +635,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize visual effects
initCardGlare();
initBgAnim();
+ initBgShaders();
initAppearance();
initTabIndicator();
updateBgAnimTheme(document.documentElement.getAttribute('data-theme') !== 'light');
diff --git a/server/src/wled_controller/static/js/core/bg-shaders.ts b/server/src/wled_controller/static/js/core/bg-shaders.ts
new file mode 100644
index 0000000..df1b82f
--- /dev/null
+++ b/server/src/wled_controller/static/js/core/bg-shaders.ts
@@ -0,0 +1,491 @@
+/**
+ * Shader-based background effects engine.
+ *
+ * Renders various full-screen WebGL shader effects on a shared canvas.
+ * Each effect is a fragment shader that receives common uniforms
+ * (time, resolution, accent color, theme brightness).
+ *
+ * The engine reuses the same element
+ * and rebuilds the GL program when switching shaders.
+ */
+
+// ─── Shader library ──────────────────────────────────────────
+
+const VERT = `
+attribute vec2 a_pos;
+void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
+`;
+
+/** Aurora — flowing northern-lights bands */
+const FRAG_AURORA = `
+precision mediump float;
+uniform float u_time;
+uniform vec2 u_res;
+uniform vec3 u_accent;
+uniform vec3 u_bg;
+uniform float u_light;
+
+vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;}
+vec2 mod289(vec2 x){return x-floor(x*(1./289.))*289.;}
+vec3 permute(vec3 x){return mod289(((x*34.)+1.)*x);}
+
+float snoise(vec2 v){
+ const vec4 C=vec4(.211324865,.366025404,-.577350269,.024390244);
+ vec2 i=floor(v+dot(v,C.yy));
+ vec2 x0=v-i+dot(i,C.xx);
+ vec2 i1=(x0.x>x0.y)?vec2(1,0):vec2(0,1);
+ vec4 x12=x0.xyxy+C.xxzz; x12.xy-=i1;
+ i=mod289(i);
+ vec3 p=permute(permute(i.y+vec3(0,i1.y,1))+i.x+vec3(0,i1.x,1));
+ vec3 m=max(.5-vec3(dot(x0,x0),dot(x12.xy,x12.xy),dot(x12.zw,x12.zw)),0.);
+ m=m*m; m=m*m;
+ vec3 x=2.*fract(p*C.www)-1.;
+ vec3 h=abs(x)-.5;
+ vec3 ox=floor(x+.5);
+ vec3 a0=x-ox;
+ m*=1.79284291-.85373472*(a0*a0+h*h);
+ vec3 g;
+ g.x=a0.x*x0.x+h.x*x0.y;
+ g.yz=a0.yz*x12.xz+h.yz*x12.yw;
+ return 130.*dot(m,g);
+}
+
+void main(){
+ vec2 uv=gl_FragCoord.xy/u_res;
+ float t=u_time*.06;
+
+ // Vertical bands that sway horizontally
+ float wave1=sin(uv.x*3.+t*1.2+snoise(vec2(uv.x*2.,t*.3))*.8)*.5+.5;
+ float wave2=sin(uv.x*5.-t*.9+snoise(vec2(uv.x*1.5,t*.2+2.))*.6)*.5+.5;
+ float wave3=sin(uv.x*2.+t*.7+snoise(vec2(uv.x*3.,t*.25+5.))*.7)*.5+.5;
+
+ // Fade toward the top of the screen
+ float yFade=smoothstep(0.,.7,uv.y)*smoothstep(1.,.4,uv.y);
+
+ // Combine bands
+ float band1=smoothstep(.35,.65,wave1)*yFade;
+ float band2=smoothstep(.4,.7,wave2)*yFade*.7;
+ float band3=smoothstep(.3,.6,wave3)*yFade*.5;
+
+ vec3 col1=u_accent;
+ vec3 col2=u_accent.gbr;
+ vec3 col3=mix(u_accent,vec3(1),0.3);
+
+ float intensity=band1*0.12+band2*0.08+band3*0.06;
+ vec3 color=band1*col1+band2*col2+band3*col3;
+ color=color/(band1+band2+band3+.001);
+
+ float boost=1.+u_light*.4;
+ vec3 result=mix(u_bg,u_bg+color*.6,intensity*boost);
+
+ gl_FragColor=vec4(result,1.);
+}
+`;
+
+/** Plasma — classic color-cycling plasma */
+const FRAG_PLASMA = `
+precision mediump float;
+uniform float u_time;
+uniform vec2 u_res;
+uniform vec3 u_accent;
+uniform vec3 u_bg;
+uniform float u_light;
+
+void main(){
+ vec2 uv=gl_FragCoord.xy/u_res;
+ float aspect=u_res.x/u_res.y;
+ vec2 p=vec2(uv.x*aspect,uv.y);
+ float t=u_time*.15;
+
+ float v1=sin(p.x*4.+t);
+ float v2=sin(p.y*4.+t*.7);
+ float v3=sin((p.x+p.y)*3.+t*1.1);
+ float v4=sin(length(p-vec2(aspect*.5,.5))*6.-t*.8);
+
+ float v=(v1+v2+v3+v4)*.25; // -1..1
+
+ vec3 col1=u_accent;
+ vec3 col2=u_accent.brg;
+ vec3 col3=u_accent.gbr;
+
+ float m1=sin(v*3.14159)*.5+.5;
+ float m2=sin(v*3.14159+2.094)*.5+.5;
+ vec3 color=col1*(1.-m1)+col2*m1;
+ color=color*(1.-m2*.4)+col3*m2*.4;
+
+ float boost=1.+u_light*.3;
+ float intensity=smoothstep(-.2,.8,v)*.22*boost;
+ vec3 result=mix(u_bg,u_bg+color*.7,intensity);
+
+ gl_FragColor=vec4(result,1.);
+}
+`;
+
+/** Digital Rain — matrix-style falling columns */
+const FRAG_RAIN = `
+precision mediump float;
+uniform float u_time;
+uniform vec2 u_res;
+uniform vec3 u_accent;
+uniform vec3 u_bg;
+uniform float u_light;
+
+float hash(vec2 p){
+ return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);
+}
+
+void main(){
+ vec2 uv=gl_FragCoord.xy/u_res;
+ float aspect=u_res.x/u_res.y;
+ float t=u_time*.12;
+
+ // Many thin columns
+ float cols=80.*aspect;
+ float col_x=floor(uv.x*cols);
+
+ // Multiple drops per column at staggered phases
+ float totalGlow=0.;
+ for(int d=0;d<3;d++){
+ float seed=float(d)*37.;
+ float speed=.3+hash(vec2(col_x,seed))*1.0;
+ float phase=hash(vec2(col_x,seed+1.))*6.28;
+ float drop_y=1.-fract(t*speed+phase);
+
+ // Trail: fades downward from the drop head
+ float dist=drop_y-uv.y;
+ if(dist<0.) dist+=1.;
+ float trailLen=.15+hash(vec2(col_x,seed+2.))*.25;
+ float trail=smoothstep(trailLen,0.,dist);
+ trail*=trail;
+
+ // Cell flicker within the trail
+ float rows=80.;
+ float row_y=floor(uv.y*rows);
+ float flicker=step(.35,hash(vec2(col_x+seed,row_y+floor(t*4.))));
+ totalGlow+=trail*flicker*.12;
+
+ // Bright head
+ float headDist=abs(uv.y-drop_y);
+ if(headDist>.5) headDist=1.-headDist;
+ totalGlow+=smoothstep(.006,0.,headDist)*.15;
+ }
+
+ totalGlow=min(totalGlow,.35);
+ float boost=1.+u_light*.25;
+ vec3 result=mix(u_bg,u_bg+u_accent,totalGlow*boost);
+
+ gl_FragColor=vec4(result,1.);
+}
+`;
+
+/** Starfield — parallax depth star field */
+const FRAG_STARS = `
+precision mediump float;
+uniform float u_time;
+uniform vec2 u_res;
+uniform vec3 u_accent;
+uniform vec3 u_bg;
+uniform float u_light;
+
+float hash(vec2 p){
+ return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);
+}
+
+void main(){
+ vec2 uv=(gl_FragCoord.xy-.5*u_res)/u_res.y;
+ float t=u_time*.02;
+
+ vec3 result=u_bg;
+
+ // 4 layers of stars at different depths
+ for(int layer=0;layer<4;layer++){
+ float fl=float(layer);
+ float depth=1.+fl*.6;
+ float speed=.05*depth;
+
+ vec2 st=uv*20.*depth;
+ st.y+=t*speed*15.;
+
+ vec2 cell=floor(st);
+ vec2 f=fract(st);
+
+ // Check this cell and its 8 neighbours for stars
+ for(int dy=-1;dy<=1;dy++){
+ for(int dx=-1;dx<=1;dx++){
+ vec2 neighbor=vec2(float(dx),float(dy));
+ vec2 c=cell+neighbor;
+ float h=hash(c+fl*137.);
+
+ // Only ~40% of cells have a star
+ if(h>.6) continue;
+
+ vec2 starPos=vec2(hash(c*1.17+fl*53.),hash(c*2.31+fl*79.));
+ float d=length(f-neighbor-starPos);
+
+ float starSize=.04+h*.06;
+ float brightness=smoothstep(starSize,starSize*.1,d);
+
+ // Soft glow halo
+ float glow=smoothstep(starSize*4.,0.,d)*.3;
+
+ // Twinkle
+ float twinkle=sin(t*8.+h*43.)*.25+.75;
+ float total=(brightness+glow)*twinkle;
+
+ // Color: white or accent-tinted
+ vec3 starColor=mix(vec3(1.),u_accent,step(.3,h));
+ float boost=1.+u_light*.2;
+ result+=starColor*total*.12*boost/depth;
+ }
+ }
+ }
+
+ gl_FragColor=vec4(result,1.);
+}
+`;
+
+/** Warp — smooth tunnel/vortex speed effect */
+const FRAG_WARP = `
+precision mediump float;
+uniform float u_time;
+uniform vec2 u_res;
+uniform vec3 u_accent;
+uniform vec3 u_bg;
+uniform float u_light;
+
+void main(){
+ vec2 uv=(gl_FragCoord.xy-.5*u_res)/u_res.y;
+ float t=u_time*.1;
+
+ float r=length(uv);
+ float a=atan(uv.y,uv.x);
+
+ // Logarithmic tunnel mapping for smooth depth
+ float tunnel=log(r+.001)*-2.;
+
+ // Twist increases with depth (further from center = more twist)
+ float twist=a/3.14159+tunnel*.15+t;
+
+ // Overlapping band patterns at different scales
+ float band1=sin(tunnel*4.-t*2.)*.5+.5;
+ float band2=sin(tunnel*7.+t*1.5)*.5+.5;
+ float rays=sin(twist*6.)*.5+.5;
+
+ float pattern=mix(band1,band2,.5)*rays;
+
+ // Smooth radial fade — no harsh edges
+ float fade=smoothstep(0.,.03,r)*smoothstep(.9,.15,r);
+
+ // Gentle center glow
+ float centerGlow=smoothstep(.3,0.,r)*.08;
+
+ vec3 col1=u_accent;
+ vec3 col2=u_accent.gbr;
+ vec3 color=mix(col1,col2,rays);
+
+ float intensity=pattern*fade*.15+centerGlow;
+ float boost=1.+u_light*.3;
+ vec3 result=mix(u_bg,u_bg+color*.6,intensity*boost);
+
+ gl_FragColor=vec4(result,1.);
+}
+`;
+
+// ─── Shader registry ─────────────────────────────────────────
+
+export type ShaderEffectId = 'aurora' | 'plasma' | 'rain' | 'stars' | 'warp';
+
+const SHADER_MAP: Record = {
+ aurora: FRAG_AURORA,
+ plasma: FRAG_PLASMA,
+ rain: FRAG_RAIN,
+ stars: FRAG_STARS,
+ warp: FRAG_WARP,
+};
+
+// ─── Engine state ────────────────────────────────────────────
+
+let _canvas: HTMLCanvasElement | null = null;
+let _gl: WebGLRenderingContext | null = null;
+let _prog: WebGLProgram | null = null;
+let _raf: number | null = null;
+let _startTime = 0;
+let _activeShader: ShaderEffectId | null = null;
+
+// Uniforms
+let _uTime: WebGLUniformLocation | null = null;
+let _uRes: WebGLUniformLocation | null = null;
+let _uAccent: WebGLUniformLocation | null = null;
+let _uBg: WebGLUniformLocation | null = null;
+let _uLight: WebGLUniformLocation | null = null;
+
+let _accent = [76 / 255, 175 / 255, 80 / 255];
+let _bgColor = [26 / 255, 26 / 255, 26 / 255];
+let _isLight = 0.0;
+
+// ─── GL helpers ──────────────────────────────────────────────
+
+function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
+ const s = gl.createShader(type);
+ if (!s) return null;
+ gl.shaderSource(s, src);
+ gl.compileShader(s);
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
+ console.error('bg-shaders compile:', gl.getShaderInfoLog(s));
+ return null;
+ }
+ return s;
+}
+
+function _buildProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram | null {
+ const vs = _compile(gl, gl.VERTEX_SHADER, VERT);
+ const fs = _compile(gl, gl.FRAGMENT_SHADER, fragSrc);
+ if (!vs || !fs) return null;
+
+ const prog = gl.createProgram()!;
+ gl.attachShader(prog, vs);
+ gl.attachShader(prog, fs);
+ gl.linkProgram(prog);
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
+ console.error('bg-shaders link:', gl.getProgramInfoLog(prog));
+ return null;
+ }
+
+ // Clean up individual shaders
+ gl.deleteShader(vs);
+ gl.deleteShader(fs);
+
+ return prog;
+}
+
+function _ensureCanvas(): boolean {
+ if (_canvas) return true;
+ _canvas = document.getElementById('bg-effect-canvas') as HTMLCanvasElement | null;
+ return !!_canvas;
+}
+
+function _ensureGL(): boolean {
+ if (_gl) return true;
+ if (!_ensureCanvas()) return false;
+ _gl = _canvas!.getContext('webgl', { alpha: false, antialias: false, depth: false });
+ if (!_gl) return false;
+
+ // Full-screen quad (shared across all shaders)
+ const buf = _gl.createBuffer();
+ _gl.bindBuffer(_gl.ARRAY_BUFFER, buf);
+ _gl.bufferData(_gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), _gl.STATIC_DRAW);
+ return true;
+}
+
+function _switchProgram(shaderId: ShaderEffectId): boolean {
+ if (!_ensureGL()) return false;
+ const gl = _gl!;
+
+ // Delete old program
+ if (_prog) {
+ gl.deleteProgram(_prog);
+ _prog = null;
+ }
+
+ const fragSrc = SHADER_MAP[shaderId];
+ if (!fragSrc) return false;
+
+ _prog = _buildProgram(gl, fragSrc);
+ if (!_prog) return false;
+
+ gl.useProgram(_prog);
+
+ // Re-bind vertex attrib
+ const aPos = gl.getAttribLocation(_prog, 'a_pos');
+ gl.enableVertexAttribArray(aPos);
+ gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
+
+ // Cache uniform locations
+ _uTime = gl.getUniformLocation(_prog, 'u_time');
+ _uRes = gl.getUniformLocation(_prog, 'u_res');
+ _uAccent = gl.getUniformLocation(_prog, 'u_accent');
+ _uBg = gl.getUniformLocation(_prog, 'u_bg');
+ _uLight = gl.getUniformLocation(_prog, 'u_light');
+
+ return true;
+}
+
+function _resize(): void {
+ if (!_canvas || !_gl) return;
+ const w = Math.round(window.innerWidth * 0.5);
+ const h = Math.round(window.innerHeight * 0.5);
+ _canvas.width = w;
+ _canvas.height = h;
+ _gl.viewport(0, 0, w, h);
+}
+
+function _draw(time: number): void {
+ _raf = requestAnimationFrame(_draw);
+ if (!_gl || !_prog) return;
+
+ const t = (time - _startTime) * 0.001;
+ _gl.uniform1f(_uTime, t);
+ _gl.uniform2f(_uRes, _canvas!.width, _canvas!.height);
+ _gl.uniform3f(_uAccent, _accent[0], _accent[1], _accent[2]);
+ _gl.uniform3f(_uBg, _bgColor[0], _bgColor[1], _bgColor[2]);
+ _gl.uniform1f(_uLight, _isLight);
+
+ _gl.drawArrays(_gl.TRIANGLE_STRIP, 0, 4);
+}
+
+// ─── Public API ──────────────────────────────────────────────
+
+/** Start a shader effect. Stops any currently running shader. */
+export function startShaderEffect(id: ShaderEffectId): void {
+ stopShaderEffect();
+
+ if (!_switchProgram(id)) return;
+
+ _activeShader = id;
+ _resize();
+ _startTime = performance.now();
+ _raf = requestAnimationFrame(_draw);
+
+ // Show canvas
+ if (_canvas) _canvas.style.display = 'block';
+}
+
+/** Stop the currently running shader effect. */
+export function stopShaderEffect(): void {
+ if (_raf) {
+ cancelAnimationFrame(_raf);
+ _raf = null;
+ }
+ _activeShader = null;
+ if (_canvas) _canvas.style.display = 'none';
+}
+
+/** Update accent color (called when user changes accent). */
+export function updateShaderAccent(hex: string): void {
+ _accent = [
+ parseInt(hex.slice(1, 3), 16) / 255,
+ parseInt(hex.slice(3, 5), 16) / 255,
+ parseInt(hex.slice(5, 7), 16) / 255,
+ ];
+}
+
+/** Update theme brightness (called on theme toggle). */
+export function updateShaderTheme(isDark: boolean): void {
+ _bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255];
+ _isLight = isDark ? 0.0 : 1.0;
+}
+
+/** Get the currently active shader ID (or null). */
+export function getActiveShader(): ShaderEffectId | null {
+ return _activeShader;
+}
+
+/** Check if a given ID is a valid shader effect. */
+export function isShaderEffect(id: string): id is ShaderEffectId {
+ return id in SHADER_MAP;
+}
+
+/** Initialize resize listener. */
+export function initBgShaders(): void {
+ window.addEventListener('resize', () => { if (_raf) _resize(); });
+}
\ No newline at end of file
diff --git a/server/src/wled_controller/static/js/features/appearance.ts b/server/src/wled_controller/static/js/features/appearance.ts
index 35e1b61..d77cf4c 100644
--- a/server/src/wled_controller/static/js/features/appearance.ts
+++ b/server/src/wled_controller/static/js/features/appearance.ts
@@ -2,11 +2,16 @@
* Appearance — style presets (font + colors) and background effect presets.
*
* Persists choices to localStorage. Style presets override CSS variables;
- * background effects toggle CSS classes on .
+ * background effects toggle CSS classes on or start WebGL shaders.
*/
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
+import {
+ startShaderEffect, stopShaderEffect, isShaderEffect,
+ updateShaderAccent, updateShaderTheme,
+ type ShaderEffectId,
+} from '../core/bg-shaders.ts';
// ─── Types ──────────────────────────────────────────────────
@@ -42,10 +47,12 @@ interface ThemeVars {
interface BgEffectPreset {
readonly id: string;
readonly nameKey: string;
- /** CSS class added to . '' = no effect. */
+ /** CSS class added to bg-effect-layer. '' = none (shader or webgl-only). */
readonly cssClass: string;
- /** For the WebGL noise field, we reuse the existing data-bg-anim attr */
+ /** Reuse the existing WebGL noise-field (data-bg-anim). */
readonly useBgAnim: boolean;
+ /** Shader effect ID (from bg-shaders.ts). '' = not a shader. */
+ readonly shaderId: string;
}
// ─── Style preset definitions ───────────────────────────────
@@ -90,10 +97,10 @@ const STYLE_PRESETS: readonly StylePreset[] = [
{
id: 'ember',
nameKey: 'appearance.preset.ember',
- fontBody: "'Space Grotesk', 'DM Sans', -apple-system, sans-serif",
+ fontBody: "'Nunito Sans', 'DM Sans', -apple-system, sans-serif",
fontHeading: "'Orbitron', sans-serif",
accent: '#FF6D00',
- fontUrl: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap',
+ fontUrl: 'https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;600;700&display=swap',
dark: {
bgColor: '#1a1410', bgSecondary: '#241c14', cardBg: '#2c221a',
textColor: '#e8ddd0', textSecondary: '#a89880', textMuted: '#7a6e5e',
@@ -108,10 +115,10 @@ const STYLE_PRESETS: readonly StylePreset[] = [
{
id: 'arctic',
nameKey: 'appearance.preset.arctic',
- fontBody: "'Inter', 'DM Sans', -apple-system, sans-serif",
+ fontBody: "'Outfit', 'DM Sans', -apple-system, sans-serif",
fontHeading: "'Orbitron', sans-serif",
accent: '#0091EA',
- fontUrl: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap',
+ fontUrl: 'https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap',
dark: {
bgColor: '#0e1820', bgSecondary: '#142028', cardBg: '#1a2830',
textColor: '#d0e4f0', textSecondary: '#88a8c0', textMuted: '#607888',
@@ -159,17 +166,111 @@ const STYLE_PRESETS: readonly StylePreset[] = [
borderColor: '#e0c8d8', inputBg: '#f5eaf2',
},
},
+ {
+ id: 'sakura',
+ nameKey: 'appearance.preset.sakura',
+ fontBody: "'Libre Baskerville', 'Georgia', serif",
+ fontHeading: "'Playfair Display', 'Georgia', serif",
+ accent: '#E8607C',
+ fontUrl: 'https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=Playfair+Display:wght@700&display=swap',
+ dark: {
+ bgColor: '#1a1216', bgSecondary: '#241820', cardBg: '#2c1e26',
+ textColor: '#f0dce4', textSecondary: '#b89aa8', textMuted: '#8a6e7c',
+ borderColor: '#3e2c36', inputBg: '#1e1418',
+ },
+ light: {
+ bgColor: '#fdf5f7', bgSecondary: '#f8e8ee', cardBg: '#ffffff',
+ textColor: '#3a1e28', textSecondary: '#6e4a58', textMuted: '#a07888',
+ borderColor: '#ecd0da', inputBg: '#faf0f4',
+ },
+ },
+ {
+ id: 'ocean',
+ nameKey: 'appearance.preset.ocean',
+ fontBody: "'Plus Jakarta Sans', 'DM Sans', -apple-system, sans-serif",
+ fontHeading: "'Orbitron', sans-serif",
+ accent: '#0288D1',
+ fontUrl: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700&display=swap',
+ dark: {
+ bgColor: '#0a1628', bgSecondary: '#0e1e34', cardBg: '#142640',
+ textColor: '#c8ddf0', textSecondary: '#7498b8', textMuted: '#506e88',
+ borderColor: '#1e3858', inputBg: '#0c1a2e',
+ },
+ light: {
+ bgColor: '#eef5fa', bgSecondary: '#deeaf4', cardBg: '#ffffff',
+ textColor: '#0e2840', textSecondary: '#386080', textMuted: '#6890a8',
+ borderColor: '#c0d8ea', inputBg: '#e4eff6',
+ },
+ },
+ {
+ id: 'copper',
+ nameKey: 'appearance.preset.copper',
+ fontBody: "'Barlow', 'DM Sans', -apple-system, sans-serif",
+ fontHeading: "'Barlow Condensed', 'Barlow', sans-serif",
+ accent: '#D4764E',
+ fontUrl: 'https://fonts.googleapis.com/css2?family=Barlow:wght@400;600;700&family=Barlow+Condensed:wght@700&display=swap',
+ dark: {
+ bgColor: '#18120e', bgSecondary: '#221a14', cardBg: '#2a201a',
+ textColor: '#e4d4c4', textSecondary: '#a88e78', textMuted: '#7a6454',
+ borderColor: '#3a2e24', inputBg: '#1c1610',
+ },
+ light: {
+ bgColor: '#faf6f2', bgSecondary: '#f0e6dc', cardBg: '#ffffff',
+ textColor: '#362618', textSecondary: '#6e5440', textMuted: '#9a8268',
+ borderColor: '#dcd0c2', inputBg: '#f6efe6',
+ },
+ },
+ {
+ id: 'vapor',
+ nameKey: 'appearance.preset.vapor',
+ fontBody: "'Quicksand', 'DM Sans', -apple-system, sans-serif",
+ fontHeading: "'Righteous', 'Orbitron', sans-serif",
+ accent: '#E040FB',
+ fontUrl: 'https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600;700&family=Righteous&display=swap',
+ dark: {
+ bgColor: '#0e0818', bgSecondary: '#160e24', cardBg: '#1c1430',
+ textColor: '#e8d0f8', textSecondary: '#a078c0', textMuted: '#705898',
+ borderColor: '#2c1e48', inputBg: '#120a1e',
+ },
+ light: {
+ bgColor: '#faf2fe', bgSecondary: '#f0e4f8', cardBg: '#ffffff',
+ textColor: '#281438', textSecondary: '#604078', textMuted: '#9870b0',
+ borderColor: '#dcc8ee', inputBg: '#f4eaf8',
+ },
+ },
+ {
+ id: 'monolith',
+ nameKey: 'appearance.preset.monolith',
+ fontBody: "'Instrument Sans', 'DM Sans', -apple-system, sans-serif",
+ fontHeading: "'Instrument Sans', sans-serif",
+ accent: '#FFFFFF',
+ fontUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;600;700&display=swap',
+ dark: {
+ bgColor: '#000000', bgSecondary: '#0a0a0a', cardBg: '#111111',
+ textColor: '#e8e8e8', textSecondary: '#888888', textMuted: '#555555',
+ borderColor: '#222222', inputBg: '#080808',
+ },
+ light: {
+ bgColor: '#ffffff', bgSecondary: '#f2f2f2', cardBg: '#fafafa',
+ textColor: '#111111', textSecondary: '#555555', textMuted: '#888888',
+ borderColor: '#dddddd', inputBg: '#f5f5f5',
+ },
+ },
];
// ─── Background effect definitions ──────────────────────────
const BG_EFFECT_PRESETS: readonly BgEffectPreset[] = [
- { id: 'none', nameKey: 'appearance.bg.none', cssClass: '', useBgAnim: false },
- { id: 'noise', nameKey: 'appearance.bg.noise', cssClass: '', useBgAnim: true },
- { id: 'grid', nameKey: 'appearance.bg.grid', cssClass: 'bg-effect-grid', useBgAnim: false },
- { id: 'mesh', nameKey: 'appearance.bg.mesh', cssClass: 'bg-effect-mesh', useBgAnim: false },
- { id: 'scanlines', nameKey: 'appearance.bg.scanlines', cssClass: 'bg-effect-scanlines', useBgAnim: false },
- { id: 'particles', nameKey: 'appearance.bg.particles', cssClass: 'bg-effect-particles', useBgAnim: false },
+ { id: 'none', nameKey: 'appearance.bg.none', cssClass: '', useBgAnim: false, shaderId: '' },
+ { id: 'noise', nameKey: 'appearance.bg.noise', cssClass: '', useBgAnim: true, shaderId: '' },
+ { id: 'aurora', nameKey: 'appearance.bg.aurora', cssClass: '', useBgAnim: false, shaderId: 'aurora' },
+ { id: 'plasma', nameKey: 'appearance.bg.plasma', cssClass: '', useBgAnim: false, shaderId: 'plasma' },
+ { id: 'rain', nameKey: 'appearance.bg.rain', cssClass: '', useBgAnim: false, shaderId: 'rain' },
+ { id: 'stars', nameKey: 'appearance.bg.stars', cssClass: '', useBgAnim: false, shaderId: 'stars' },
+ { id: 'warp', nameKey: 'appearance.bg.warp', cssClass: '', useBgAnim: false, shaderId: 'warp' },
+ { id: 'grid', nameKey: 'appearance.bg.grid', cssClass: 'bg-effect-grid', useBgAnim: false, shaderId: '' },
+ { id: 'mesh', nameKey: 'appearance.bg.mesh', cssClass: 'bg-effect-mesh', useBgAnim: false, shaderId: '' },
+ { id: 'scanlines', nameKey: 'appearance.bg.scanlines', cssClass: 'bg-effect-scanlines', useBgAnim: false, shaderId: '' },
];
// ─── Persistence keys ───────────────────────────────────────
@@ -218,6 +319,9 @@ export function applyStylePreset(id: string): void {
localStorage.setItem('accentColor', preset.accent);
}
+ // Update shader accent if a shader effect is running
+ updateShaderAccent(preset.accent);
+
// Apply theme-specific color overrides
_applyThemeVars(preset);
@@ -237,30 +341,47 @@ export function applyBgEffect(id: string): void {
const html = document.documentElement;
- // Remove all CSS effect classes from the dedicated layer element
+ // ── 1. Stop all previous effects ──
+
+ // Stop shader effects
+ stopShaderEffect();
+
+ // Remove CSS effect classes from the dedicated layer
const layer = document.getElementById('bg-effect-layer');
if (layer) {
BG_EFFECT_PRESETS.forEach(e => {
if (e.cssClass) layer.classList.remove(e.cssClass);
});
- if (effect.cssClass) layer.classList.add(effect.cssClass);
}
- // Set data-bg-effect on so CSS can make body transparent
- if (effect.cssClass) {
+ // Turn off WebGL noise-field
+ html.setAttribute('data-bg-anim', 'off');
+ localStorage.setItem('bgAnim', 'off');
+
+ // Clear data-bg-effect (CSS transparency trigger)
+ html.removeAttribute('data-bg-effect');
+
+ // ── 2. Activate the selected effect ──
+
+ const hasVisualEffect = effect.useBgAnim || !!effect.cssClass || !!effect.shaderId;
+
+ if (effect.useBgAnim) {
+ // Original WebGL noise field
+ html.setAttribute('data-bg-anim', 'on');
+ localStorage.setItem('bgAnim', 'on');
+ } else if (effect.shaderId && isShaderEffect(effect.shaderId)) {
+ // New shader-based effect — needs body transparency via data-bg-effect
+ html.setAttribute('data-bg-effect', effect.id);
+ startShaderEffect(effect.shaderId as ShaderEffectId);
+ } else if (effect.cssClass && layer) {
+ // CSS-based effect
+ layer.classList.add(effect.cssClass);
html.setAttribute('data-bg-effect', effect.id);
- } else {
- html.removeAttribute('data-bg-effect');
}
- // Toggle WebGL bg-anim (only for the "noise" effect)
- html.setAttribute('data-bg-anim', effect.useBgAnim ? 'on' : 'off');
- localStorage.setItem('bgAnim', effect.useBgAnim ? 'on' : 'off');
-
- // Update header toggle button (lit when any effect is active)
- const hasEffect = effect.useBgAnim || !!effect.cssClass;
+ // Update header toggle button opacity
const bgAnimBtn = document.getElementById('bg-anim-btn');
- if (bgAnimBtn) bgAnimBtn.style.opacity = hasEffect ? '1' : '0.5';
+ if (bgAnimBtn) bgAnimBtn.style.opacity = hasVisualEffect ? '1' : '0.5';
_updatePresetSelection('bg', id);
@@ -279,20 +400,35 @@ export function initAppearance(): void {
document.documentElement.style.setProperty('--font-body', preset.fontBody);
document.documentElement.style.setProperty('--font-heading', preset.fontHeading);
_applyThemeVars(preset);
+
+ // Sync accent to shader engine
+ updateShaderAccent(preset.accent);
}
- // Apply background effect silently on the dedicated layer element
+ // Sync theme to shader engine
+ const isDark = (document.documentElement.getAttribute('data-theme') || 'dark') === 'dark';
+ updateShaderTheme(isDark);
+
+ // Apply background effect silently
const effect = BG_EFFECT_PRESETS.find(e => e.id === _activeBgEffectId);
- if (effect) {
- const layer = document.getElementById('bg-effect-layer');
- if (layer && effect.cssClass) layer.classList.add(effect.cssClass);
- if (effect.cssClass) {
- document.documentElement.setAttribute('data-bg-effect', effect.id);
- }
+ if (effect && effect.id !== 'none') {
+ const html = document.documentElement;
+
if (effect.useBgAnim) {
- document.documentElement.setAttribute('data-bg-anim', 'on');
+ html.setAttribute('data-bg-anim', 'on');
localStorage.setItem('bgAnim', 'on');
+ } else if (effect.shaderId && isShaderEffect(effect.shaderId)) {
+ html.setAttribute('data-bg-effect', effect.id);
+ startShaderEffect(effect.shaderId as ShaderEffectId);
+ } else if (effect.cssClass) {
+ const layer = document.getElementById('bg-effect-layer');
+ if (layer) layer.classList.add(effect.cssClass);
+ html.setAttribute('data-bg-effect', effect.id);
}
+
+ // Update header button
+ const bgAnimBtn = document.getElementById('bg-anim-btn');
+ if (bgAnimBtn) bgAnimBtn.style.opacity = '1';
}
}
@@ -416,6 +552,9 @@ const _themeObserver = new MutationObserver((mutations) => {
if (preset && preset.id !== 'default') {
_applyThemeVars(preset);
}
+ // Sync shader theme
+ const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
+ updateShaderTheme(isDark);
break;
}
}
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index 8dd55b2..36a7ec7 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -1749,15 +1749,24 @@
"appearance.preset.arctic": "Arctic",
"appearance.preset.terminal": "Terminal",
"appearance.preset.neon": "Neon",
+ "appearance.preset.sakura": "Sakura",
+ "appearance.preset.ocean": "Ocean",
+ "appearance.preset.copper": "Copper",
+ "appearance.preset.vapor": "Vapor",
+ "appearance.preset.monolith": "Monolith",
"appearance.preset.applied": "Style preset applied",
"appearance.bg.label": "Background Effects",
"appearance.bg.hint": "Add an ambient background layer behind the interface.",
"appearance.bg.none": "None",
"appearance.bg.noise": "Noise Field",
+ "appearance.bg.aurora": "Aurora",
+ "appearance.bg.plasma": "Plasma",
+ "appearance.bg.rain": "Digital Rain",
+ "appearance.bg.stars": "Starfield",
+ "appearance.bg.warp": "Warp Tunnel",
"appearance.bg.grid": "Dot Grid",
"appearance.bg.mesh": "Gradient Mesh",
"appearance.bg.scanlines": "Scanlines",
- "appearance.bg.particles": "Particles",
"appearance.bg.applied": "Background effect applied",
"color_strip": {
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index c8dcb4f..412f9bc 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -1751,15 +1751,24 @@
"appearance.preset.arctic": "Арктика",
"appearance.preset.terminal": "Терминал",
"appearance.preset.neon": "Неон",
+ "appearance.preset.sakura": "Сакура",
+ "appearance.preset.ocean": "Океан",
+ "appearance.preset.copper": "Медь",
+ "appearance.preset.vapor": "Вейпорвейв",
+ "appearance.preset.monolith": "Монолит",
"appearance.preset.applied": "Стиль применён",
"appearance.bg.label": "Фоновые эффекты",
"appearance.bg.hint": "Добавьте фоновый слой за интерфейсом.",
"appearance.bg.none": "Нет",
"appearance.bg.noise": "Шумовое поле",
+ "appearance.bg.aurora": "Северное сияние",
+ "appearance.bg.plasma": "Плазма",
+ "appearance.bg.rain": "Цифровой дождь",
+ "appearance.bg.stars": "Звёздное поле",
+ "appearance.bg.warp": "Тоннель",
"appearance.bg.grid": "Точечная сетка",
"appearance.bg.mesh": "Градиент",
"appearance.bg.scanlines": "Развёртка",
- "appearance.bg.particles": "Частицы",
"appearance.bg.applied": "Фоновый эффект применён",
"color_strip": {
diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json
index e2ea578..9c7ae03 100644
--- a/server/src/wled_controller/static/locales/zh.json
+++ b/server/src/wled_controller/static/locales/zh.json
@@ -1749,15 +1749,24 @@
"appearance.preset.arctic": "极地",
"appearance.preset.terminal": "终端",
"appearance.preset.neon": "霓虹",
+ "appearance.preset.sakura": "樱花",
+ "appearance.preset.ocean": "海洋",
+ "appearance.preset.copper": "铜色",
+ "appearance.preset.vapor": "蒸汽波",
+ "appearance.preset.monolith": "黑白",
"appearance.preset.applied": "样式已应用",
"appearance.bg.label": "背景效果",
"appearance.bg.hint": "在界面后面添加环境背景层。",
"appearance.bg.none": "无",
"appearance.bg.noise": "噪声场",
+ "appearance.bg.aurora": "极光",
+ "appearance.bg.plasma": "等离子",
+ "appearance.bg.rain": "数字雨",
+ "appearance.bg.stars": "星空",
+ "appearance.bg.warp": "隧道",
"appearance.bg.grid": "点阵",
"appearance.bg.mesh": "渐变网格",
"appearance.bg.scanlines": "扫描线",
- "appearance.bg.particles": "粒子",
"appearance.bg.applied": "背景效果已应用",
"color_strip": {
diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html
index 56a1ac2..f0969f1 100644
--- a/server/src/wled_controller/templates/index.html
+++ b/server/src/wled_controller/templates/index.html
@@ -20,6 +20,7 @@
×
+