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.
This commit is contained in:
2026-06-03 14:59:01 +00:00
parent 37e0428312
commit ef3b998826
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">