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 { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// v1.9: lifted out of pages/Settings.tsx so the SettingsPane Theme tab and
|
// 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.' },
|
{ 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() {
|
export function ThemePicker() {
|
||||||
const { id: currentId, mode: currentMode } = useTheme();
|
const { id: currentId, mode: currentMode } = useTheme();
|
||||||
const animOn = useAnimBg();
|
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
|
// 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.
|
// "applying…" state on the targeted card while the PATCH is in flight.
|
||||||
const [pending, setPending] = useState<
|
const [pending, setPending] = useState<
|
||||||
@@ -97,6 +147,36 @@ export function ThemePicker() {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Disables canvas animations and CSS effects for all themes. Persisted locally.
|
Disables canvas animations and CSS effects for all themes. Persisted locally.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTheme } from '@/lib/theme';
|
import { useTheme } from '@/lib/theme';
|
||||||
import { useAnimBg } from '@/lib/anim';
|
import { useAnimBg, useAnimParams } from '@/lib/anim';
|
||||||
import { MatrixRain } from './MatrixRain';
|
import { MatrixRain } from './MatrixRain';
|
||||||
import { NeonField } from './NeonField';
|
import { NeonField } from './NeonField';
|
||||||
|
|
||||||
@@ -28,9 +28,14 @@ function useAnimGate(): boolean {
|
|||||||
// Mounted once in AppShell. Manages:
|
// Mounted once in AppShell. Manages:
|
||||||
// 1. The bc-anim-on class on <html> (gate for all theme animation CSS).
|
// 1. The bc-anim-on class on <html> (gate for all theme animation CSS).
|
||||||
// 2. The per-theme canvas component (MatrixRain / NeonField / null).
|
// 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() {
|
export function ThemeFx() {
|
||||||
const { id } = useTheme();
|
const { id } = useTheme();
|
||||||
const gateOn = useAnimGate();
|
const gateOn = useAnimGate();
|
||||||
|
const { density, speed, opacity } = useAnimParams();
|
||||||
|
|
||||||
// Sync bc-anim-on class. Effect runs whenever gateOn changes; cleanup
|
// Sync bc-anim-on class. Effect runs whenever gateOn changes; cleanup
|
||||||
// removes the class so a stale gate never persists across unmount.
|
// removes the class so a stale gate never persists across unmount.
|
||||||
@@ -45,7 +50,18 @@ export function ThemeFx() {
|
|||||||
};
|
};
|
||||||
}, [gateOn]);
|
}, [gateOn]);
|
||||||
|
|
||||||
if (id === 'boocode-classic') return <MatrixRain enabled={gateOn} />;
|
if (id === 'boocode-classic') {
|
||||||
if (id === 'boocode-override') return <NeonField enabled={gateOn} />;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,76 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
// Module-singleton animation-background gate — mirrors the useTheme pattern.
|
// Module-singleton animated-background settings — mirrors the useTheme pattern.
|
||||||
// Consumers: ThemeFx (reads) and ThemePicker (reads + writes).
|
// Consumers: ThemeFx (reads), ThemePicker (reads + writes). localStorage only,
|
||||||
// Storage: localStorage only — no server/schema change.
|
// 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 {
|
try {
|
||||||
return localStorage.getItem(ANIM_KEY) !== 'off';
|
return localStorage.getItem(ON_KEY) !== 'off';
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _on = readAnimOn();
|
function readNum(param: NumParam): number {
|
||||||
const _subs = new Set<(on: boolean) => void>();
|
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) {
|
for (const s of _subs) {
|
||||||
try {
|
try {
|
||||||
s(_on);
|
s(_state);
|
||||||
} catch {
|
} catch {
|
||||||
// swallow — bad subscriber shouldn't break others
|
// swallow — bad subscriber shouldn't break others
|
||||||
}
|
}
|
||||||
@@ -28,26 +78,58 @@ function notifyAnimSubs(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAnimBg(): boolean {
|
export function getAnimBg(): boolean {
|
||||||
return _on;
|
return _state.on;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAnimBg(on: boolean): void {
|
export function setAnimBg(on: boolean): void {
|
||||||
_on = on;
|
_state = { ..._state, on };
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(ANIM_KEY, on ? 'on' : 'off');
|
localStorage.setItem(ON_KEY, on ? 'on' : 'off');
|
||||||
} catch {
|
} catch {
|
||||||
// quota / disabled
|
// 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 {
|
export function useAnimBg(): boolean {
|
||||||
const [on, setOn] = useState(_on);
|
return useAnimState().on;
|
||||||
useEffect(() => {
|
}
|
||||||
_subs.add(setOn);
|
|
||||||
return () => {
|
export function useAnimParams(): { density: number; speed: number; opacity: number } {
|
||||||
_subs.delete(setOn);
|
const { density, speed, opacity } = useAnimState();
|
||||||
};
|
return { density, speed, opacity };
|
||||||
}, []);
|
|
||||||
return on;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user