diff --git a/apps/web/src/components/ThemePicker.tsx b/apps/web/src/components/ThemePicker.tsx
index 0a977e9..dd7565c 100644
--- a/apps/web/src/components/ThemePicker.tsx
+++ b/apps/web/src/components/ThemePicker.tsx
@@ -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 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 (
+
+ );
+}
+
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() {
Disables canvas animations and CSS effects for all themes. Persisted locally.
+ {isCanvasTheme && (
+
+ {currentId === 'boocode-classic' && (
+
+ )}
+
+
+
+ )}
diff --git a/apps/web/src/components/fx/ThemeFx.tsx b/apps/web/src/components/fx/ThemeFx.tsx
index 5f83fa5..de345e1 100644
--- a/apps/web/src/components/fx/ThemeFx.tsx
+++ b/apps/web/src/components/fx/ThemeFx.tsx
@@ -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 (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 ;
- if (id === 'boocode-override') return ;
+ if (id === 'boocode-classic') {
+ return (
+
+ );
+ }
+ if (id === 'boocode-override') {
+ return ;
+ }
return null;
}
diff --git a/apps/web/src/lib/anim.ts b/apps/web/src/lib/anim.ts
index 812a190..1a5eda1 100644
--- a/apps/web/src/lib/anim.ts
+++ b/apps/web/src/lib/anim.ts
@@ -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 = {
+ 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 };
}