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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user