Files
boocode/apps/web/src/components/ThemePicker.tsx
indifferentketchup ef3b998826 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.
2026-06-03 14:59:01 +00:00

236 lines
8.3 KiB
TypeScript

import { useState } from 'react';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
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,
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
// the standalone /settings route render the same picker. Theme is global —
// not per-project, not per-session — so no contextual props are needed.
const MODES: { value: ThemeMode; label: string; hint: string }[] = [
{ value: 'dark', label: 'Dark', hint: 'Use the dark variant.' },
{ value: 'light', label: 'Light', hint: 'Use the light variant.' },
{ 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<
{ kind: 'theme'; id: ThemeId } | { kind: 'mode'; mode: ThemeMode } | null
>(null);
async function pickTheme(id: ThemeId) {
if (id === currentId || pending) return;
setPending({ kind: 'theme', id });
try {
await setTheme(id, currentMode);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to apply theme');
} finally {
setPending(null);
}
}
async function pickMode(mode: ThemeMode) {
if (mode === currentMode || pending) return;
setPending({ kind: 'mode', mode });
try {
await setTheme(currentId, mode);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to apply mode');
} finally {
setPending(null);
}
}
return (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-sm font-medium">Mode</h2>
<RadioGroup
value={currentMode}
onValueChange={(v) => void pickMode(v as ThemeMode)}
className="flex flex-wrap gap-4"
>
{MODES.map((m) => (
<div key={m.value} className="flex items-center gap-2">
<RadioGroupItem id={`mode-${m.value}`} value={m.value} />
<Label htmlFor={`mode-${m.value}`} className="cursor-pointer">
<span className="font-medium">{m.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{m.hint}</span>
</Label>
</div>
))}
</RadioGroup>
</section>
<section className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h2 className="text-sm font-medium">Animated background</h2>
<button
type="button"
role="switch"
aria-checked={animOn}
onClick={() => setAnimBg(!animOn)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
animOn ? 'bg-primary' : 'bg-input',
)}
>
<span
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200',
animOn ? 'translate-x-4' : 'translate-x-0',
)}
/>
</button>
</div>
<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">
<h2 className="text-sm font-medium">Theme</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{THEMES.map((t) => {
const isActive = t.id === currentId;
const isPending = pending?.kind === 'theme' && pending.id === t.id;
const isLightOnly = !t.supportsDark;
const isDarkOnly = !t.supportsLight;
return (
<Card
key={t.id}
onClick={() => void pickTheme(t.id)}
className={cn(
'p-3 cursor-pointer transition-colors',
'hover:bg-accent/10',
isActive && 'ring-2 ring-ring',
isPending && 'opacity-60',
)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-sm truncate">{t.name}</div>
<div className="text-xs text-muted-foreground">{t.family}</div>
</div>
{isActive && (
<span className="inline-flex items-center gap-1 text-xs text-primary shrink-0">
<Check className="size-3" /> Selected
</span>
)}
</div>
<div className="flex mt-2 rounded overflow-hidden border border-border/40">
{t.anchors.map((hex, i) => (
<div
key={i}
className="flex-1 h-6"
style={{ backgroundColor: hex }}
aria-hidden="true"
/>
))}
</div>
{isLightOnly && (
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div>
)}
{isDarkOnly && (
<div className="mt-2 text-xs text-muted-foreground italic">Dark only</div>
)}
</Card>
);
})}
</div>
</section>
</div>
);
}