feat(themes): animated-background sliders (density / speed / opacity)

Port the boolab FX controls: localStorage-backed Density, Speed, and
Opacity sliders in the theme settings, shown when a canvas-background
theme is active (Density is matrix-rain-only). Speed is a multiplier over
each field's native base; values feed MatrixRain / NeonField live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 14:59:01 +00:00
parent 163b5b86f7
commit 38a0d47bcc
3 changed files with 204 additions and 26 deletions

View File

@@ -5,7 +5,15 @@ import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
import { useAnimBg, setAnimBg } from '@/lib/anim';
import {
useAnimBg,
setAnimBg,
useAnimParams,
setAnimDensity,
setAnimSpeed,
setAnimOpacity,
ANIM_RANGES,
} from '@/lib/anim';
import { cn } from '@/lib/utils';
// v1.9: lifted out of pages/Settings.tsx so the SettingsPane Theme tab and
@@ -18,9 +26,51 @@ const MODES: { value: ThemeMode; label: string; hint: string }[] = [
{ value: 'system', label: 'System', hint: 'Follow OS preference.' },
];
// Labelled range input for the animated-background params (boolab FX sliders).
// Native <input type=range> tinted with the theme accent — no shadcn Slider
// primitive exists in ui/.
function FxSlider({
label,
value,
min,
max,
step,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
}) {
return (
<label className="block space-y-1">
<span className="flex items-center justify-between text-xs text-muted-foreground">
<span>{label}</span>
<span className="font-mono tabular-nums">{value.toFixed(2)}</span>
</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full cursor-pointer"
style={{ accentColor: 'var(--primary)' }}
/>
</label>
);
}
export function ThemePicker() {
const { id: currentId, mode: currentMode } = useTheme();
const animOn = useAnimBg();
const params = useAnimParams();
// The slider set only applies to the canvas-background themes; density is
// matrix-rain-only (Classic), speed/opacity apply to both fields.
const isCanvasTheme = currentId === 'boocode-classic' || currentId === 'boocode-override';
// Track the most recent in-flight pick so the picker can show a subtle
// "applying…" state on the targeted card while the PATCH is in flight.
const [pending, setPending] = useState<
@@ -97,6 +147,36 @@ export function ThemePicker() {
<p className="text-xs text-muted-foreground">
Disables canvas animations and CSS effects for all themes. Persisted locally.
</p>
{isCanvasTheme && (
<div className={cn('grid gap-x-4 gap-y-3 sm:grid-cols-2 pt-1', !animOn && 'opacity-50')}>
{currentId === 'boocode-classic' && (
<FxSlider
label="Density"
value={params.density}
min={ANIM_RANGES.density.min}
max={ANIM_RANGES.density.max}
step={ANIM_RANGES.density.step}
onChange={setAnimDensity}
/>
)}
<FxSlider
label="Speed"
value={params.speed}
min={ANIM_RANGES.speed.min}
max={ANIM_RANGES.speed.max}
step={ANIM_RANGES.speed.step}
onChange={setAnimSpeed}
/>
<FxSlider
label="Opacity"
value={params.opacity}
min={ANIM_RANGES.opacity.min}
max={ANIM_RANGES.opacity.max}
step={ANIM_RANGES.opacity.step}
onChange={setAnimOpacity}
/>
</div>
)}
</section>
<section className="space-y-3">

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useTheme } from '@/lib/theme';
import { useAnimBg } from '@/lib/anim';
import { useAnimBg, useAnimParams } from '@/lib/anim';
import { MatrixRain } from './MatrixRain';
import { NeonField } from './NeonField';
@@ -28,9 +28,14 @@ function useAnimGate(): boolean {
// Mounted once in AppShell. Manages:
// 1. The bc-anim-on class on <html> (gate for all theme animation CSS).
// 2. The per-theme canvas component (MatrixRain / NeonField / null).
// Native base speeds for each canvas; the user's `speed` slider is a multiplier.
const RAIN_BASE_SPEED = 0.7;
const FIELD_BASE_SPEED = 0.18;
export function ThemeFx() {
const { id } = useTheme();
const gateOn = useAnimGate();
const { density, speed, opacity } = useAnimParams();
// Sync bc-anim-on class. Effect runs whenever gateOn changes; cleanup
// removes the class so a stale gate never persists across unmount.
@@ -45,7 +50,18 @@ export function ThemeFx() {
};
}, [gateOn]);
if (id === 'boocode-classic') return <MatrixRain enabled={gateOn} />;
if (id === 'boocode-override') return <NeonField enabled={gateOn} />;
if (id === 'boocode-classic') {
return (
<MatrixRain
enabled={gateOn}
density={density}
speed={RAIN_BASE_SPEED * speed}
opacity={opacity}
/>
);
}
if (id === 'boocode-override') {
return <NeonField enabled={gateOn} speed={FIELD_BASE_SPEED * speed} opacity={opacity} />;
}
return null;
}

View File

@@ -1,26 +1,76 @@
import { useEffect, useState } from 'react';
// Module-singleton animation-background gate — mirrors the useTheme pattern.
// Consumers: ThemeFx (reads) and ThemePicker (reads + writes).
// Storage: localStorage only — no server/schema change.
// Module-singleton animated-background settings — mirrors the useTheme pattern.
// Consumers: ThemeFx (reads), ThemePicker (reads + writes). localStorage only,
// no server/schema change. Ported from /opt/boolab's boocode FX controls.
const ANIM_KEY = 'boocode.anim-bg';
const ON_KEY = 'boocode.anim-bg';
const DENSITY_KEY = 'boocode.anim-density';
const SPEED_KEY = 'boocode.anim-speed';
const OPACITY_KEY = 'boocode.anim-opacity';
function readAnimOn(): boolean {
// User-facing slider defaults + ranges. `speed` is a MULTIPLIER applied to each
// canvas's native base speed (so 1 = the tuned default); `density` applies to
// the matrix rain only; `opacity` sets the canvas opacity for both fields.
export const ANIM_DEFAULTS = { density: 0.35, speed: 1, opacity: 0.6 } as const;
export const ANIM_RANGES = {
density: { min: 0.1, max: 0.6, step: 0.01 },
speed: { min: 0.3, max: 2, step: 0.05 },
opacity: { min: 0.1, max: 1, step: 0.01 },
} as const;
type NumParam = 'density' | 'speed' | 'opacity';
const NUM_KEYS: Record<NumParam, string> = {
density: DENSITY_KEY,
speed: SPEED_KEY,
opacity: OPACITY_KEY,
};
function clamp(n: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, n));
}
function readOn(): boolean {
try {
return localStorage.getItem(ANIM_KEY) !== 'off';
return localStorage.getItem(ON_KEY) !== 'off';
} catch {
return true;
}
}
let _on = readAnimOn();
const _subs = new Set<(on: boolean) => void>();
function readNum(param: NumParam): number {
const { min, max } = ANIM_RANGES[param];
const def = ANIM_DEFAULTS[param];
try {
const raw = localStorage.getItem(NUM_KEYS[param]);
if (raw === null) return def;
const n = Number(raw);
return Number.isFinite(n) ? clamp(n, min, max) : def;
} catch {
return def;
}
}
function notifyAnimSubs(): void {
interface AnimState {
on: boolean;
density: number;
speed: number;
opacity: number;
}
let _state: AnimState = {
on: readOn(),
density: readNum('density'),
speed: readNum('speed'),
opacity: readNum('opacity'),
};
const _subs = new Set<(s: AnimState) => void>();
function notify(): void {
for (const s of _subs) {
try {
s(_on);
s(_state);
} catch {
// swallow — bad subscriber shouldn't break others
}
@@ -28,26 +78,58 @@ function notifyAnimSubs(): void {
}
export function getAnimBg(): boolean {
return _on;
return _state.on;
}
export function setAnimBg(on: boolean): void {
_on = on;
_state = { ..._state, on };
try {
localStorage.setItem(ANIM_KEY, on ? 'on' : 'off');
localStorage.setItem(ON_KEY, on ? 'on' : 'off');
} catch {
// quota / disabled
}
notifyAnimSubs();
notify();
}
function setNum(param: NumParam, value: number): void {
const { min, max } = ANIM_RANGES[param];
const v = clamp(value, min, max);
_state = { ..._state, [param]: v };
try {
localStorage.setItem(NUM_KEYS[param], String(v));
} catch {
// quota / disabled
}
notify();
}
export function setAnimDensity(v: number): void {
setNum('density', v);
}
export function setAnimSpeed(v: number): void {
setNum('speed', v);
}
export function setAnimOpacity(v: number): void {
setNum('opacity', v);
}
function useAnimState(): AnimState {
const [state, setState] = useState(_state);
useEffect(() => {
_subs.add(setState);
setState(_state); // reconcile in case it changed before mount
return () => {
_subs.delete(setState);
};
}, []);
return state;
}
export function useAnimBg(): boolean {
const [on, setOn] = useState(_on);
useEffect(() => {
_subs.add(setOn);
return () => {
_subs.delete(setOn);
};
}, []);
return on;
return useAnimState().on;
}
export function useAnimParams(): { density: number; speed: number; opacity: number } {
const { density, speed, opacity } = useAnimState();
return { density, speed, opacity };
}