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