feat: expand appearance with shader effects and new style presets
Some checks failed
Lint & Test / test (push) Failing after 29s

Add 5 WebGL shader background effects (Aurora, Plasma, Digital Rain,
Starfield, Warp Tunnel) via a new bg-shaders.ts engine that shares
a dedicated canvas. Add 5 style presets (Sakura, Ocean, Copper, Vapor,
Monolith) with distinctive font pairings. Remove CSS particles effect
in favor of shader-based alternatives. Fix dot grid visibility and
tune all shader intensities for subtle ambient appearance.
This commit is contained in:
2026-03-23 18:31:20 +03:00
parent 73b2ee6222
commit b27ac8783b
9 changed files with 795 additions and 79 deletions

View File

@@ -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 <div id="bg-effect-layer"> (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 <canvas id="bg-effect-canvas"> 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 {

View File

@@ -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;

View File

@@ -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');

View File

@@ -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 <canvas id="bg-effect-canvas"> 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<ShaderEffectId, string> = {
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(); });
}

View File

@@ -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 <html>.
* background effects toggle CSS classes on <html> 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 <html>. '' = 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 <html> so CSS can make body transparent
if (effect.cssClass) {
html.setAttribute('data-bg-effect', effect.id);
} else {
// 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);
}
// 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;
}
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -20,6 +20,7 @@
<button class="demo-banner-dismiss" onclick="dismissDemoBanner()" aria-label="Dismiss">&times;</button>
</div>
<canvas id="bg-anim-canvas"></canvas>
<canvas id="bg-effect-canvas"></canvas>
<div id="bg-effect-layer"></div>
<div id="connection-overlay" class="connection-overlay" style="display:none" aria-hidden="true">
<div class="connection-overlay-content">