puck / frontend /src /ui /SettingsPanel.tsx
vu1n's picture
Puck — desktop fairy familiar (HF Build Small)
3c124f3
Raw
History Blame Contribute Delete
11.5 kB
// Puck's settings — the prototype's "Tweaks" reborn as product UI,
// styled with the app's own theme tokens.
import * as React from "react";
import { requestNotifyPermission } from "../lib/notify";
import { ACCENTS, type Settings } from "../lib/settings";
import { type Pos, useDrag } from "../lib/useDrag";
import { KOKORO_VOICES, listVoices, previewVoice, sayVoices, type VoiceEngine } from "../lib/voice";
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="set-row">
<span className="set-label">{label}</span>
<div className="set-ctl">{children}</div>
</div>
);
}
function Pills<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div className="set-pills">
{options.map((o) => (
<button
type="button"
key={o.value}
className={`mode-pill${value === o.value ? " on" : ""}`}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
))}
</div>
);
}
function Slider({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return (
<div className="set-slider">
<input
type="range"
min={0}
max={100}
step={5}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<span className="set-val">{value}</span>
</div>
);
}
export function SettingsPanel({
open,
pos,
onPos,
onClose,
settings,
onChange,
overlay = false,
onPatrol,
onSleep,
}: {
open: boolean;
pos: Pos;
onPos: (p: Pos) => void;
onClose: () => void;
settings: Settings;
onChange: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
/** Overlay (WKWebView) forces the Mac `say` engine — browser audio is dead there. */
overlay?: boolean;
onPatrol: () => void;
onSleep: () => void;
}) {
const drag = useDrag(pos, onPos, { marginR: 120, marginB: 80 });
// the overlay can only use the Mac voice; elsewhere honor the chosen engine
const engine: VoiceEngine = overlay ? "say" : settings.voiceEngine;
const [sayList, setSayList] = React.useState<{ id: string; lang: string }[]>([]);
React.useEffect(() => {
if (open && engine === "say") void sayVoices().then(setSayList);
}, [open, engine]);
if (!open) return null;
return (
<div className="settings panel" style={{ left: pos.x, top: pos.y }}>
<div
className="comp-head"
onMouseDown={(e) => {
if ((e.target as HTMLElement).closest(".comp-x")) return;
drag(e);
}}
>
<div className="comp-id">
<b>Settings</b>
<span>how Puck looks, sounds, behaves</span>
</div>
<button type="button" className="comp-x" onClick={onClose}>
</button>
</div>
<div className="comp-body scroll set-body">
<div className="sect-label">Look</div>
<Row label="Theme">
<Pills
value={settings.theme}
options={[
{ value: "terrarium", label: "Terrarium" },
{ value: "candlelight", label: "Candle" },
{ value: "nocturne", label: "Glass" },
]}
onChange={(v) => onChange("theme", v)}
/>
</Row>
<Row label="Accent">
<div className="set-swatches">
{ACCENTS.map((c) => (
<button
type="button"
key={c}
className={`set-swatch${settings.accent === c ? " on" : ""}`}
style={{ background: c }}
onClick={() => onChange("accent", c)}
/>
))}
</div>
</Row>
<div className="sect-label">Creature</div>
<Row label="Form">
<Pills
value={settings.form}
options={[
{ value: "mossling", label: "Mossling" },
{ value: "wisp", label: "Wisp" },
{ value: "gremlin", label: "Gremlin" },
{ value: "moth", label: "Moth" },
]}
onChange={(v) => onChange("form", v)}
/>
</Row>
<Row label="Presence">
<Slider value={settings.presence} onChange={(v) => onChange("presence", v)} />
</Row>
<Row label="Mischief">
<Slider value={settings.mischief} onChange={(v) => onChange("mischief", v)} />
</Row>
<div className="sect-label">Voice & behaviour</div>
<Row label="Spoken voice">
<Pills
value={settings.voiceSound}
options={[
{ value: "off", label: "Silent" },
{ value: "whisper", label: "Whisper" },
{ value: "full", label: "Full" },
]}
onChange={(v) => onChange("voiceSound", v)}
/>
</Row>
{settings.voiceSound !== "off" && (
<>
{!overlay && (
<Row label="Engine">
<Pills
value={settings.voiceEngine}
options={[
{ value: "say", label: "Mac" },
{ value: "kokoro", label: "Neural" },
{ value: "browser", label: "Web" },
]}
onChange={(v) => onChange("voiceEngine", v)}
/>
</Row>
)}
<Row label="Which voice">
<div className="set-voicepick">
{engine === "say" ? (
<select
className="set-select"
value={settings.sayVoice}
onChange={(e) => {
onChange("sayVoice", e.target.value);
previewVoice("say", e.target.value); // audition on pick
}}
>
<option value="">System default</option>
{sayList.map((v) => (
<option key={v.id} value={v.id}>
{v.id} · {v.lang}
</option>
))}
</select>
) : engine === "kokoro" ? (
<select
className="set-select"
value={settings.kokoroVoice}
onChange={(e) => {
onChange("kokoroVoice", e.target.value);
previewVoice("kokoro", e.target.value);
}}
>
{KOKORO_VOICES.map((v) => (
<option key={v.id} value={v.id}>
{v.label}
</option>
))}
</select>
) : (
<select
className="set-select"
value={settings.voiceName}
onChange={(e) => {
onChange("voiceName", e.target.value);
previewVoice("browser", e.target.value);
}}
>
<option value="">Auto (soft english)</option>
{listVoices().map((v) => (
<option key={v.name} value={v.name}>
{v.name} · {v.lang}
</option>
))}
</select>
)}
<button
type="button"
className="btn"
onClick={() =>
previewVoice(
engine,
engine === "say"
? settings.sayVoice
: engine === "kokoro"
? settings.kokoroVoice
: settings.voiceName,
)
}
>
▸ test
</button>
</div>
</Row>
{engine === "say" && (
<div className="set-note">
Mac's built-in voice — works everywhere, including the overlay. For much better voices,
install Premium/Siri voices in System Settings → Accessibility → Spoken Content → System
Voice.
</div>
)}
{engine === "kokoro" && (
<div className="set-note">
Neural voice runs locally in your browser. First use downloads the ~80MB model once (cached
after); the system voice covers the gap while it loads.
</div>
)}
<Row label="Speak shouts">
<Pills
value={settings.speakShouts ? "on" : "off"}
options={[
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
]}
onChange={(v) => onChange("speakShouts", v === "on")}
/>
</Row>
</>
)}
<Row label="Comments">
<Pills
value={settings.voice}
options={[
{ value: "voice", label: "Fairy" },
{ value: "plain", label: "Plain" },
{ value: "hand", label: "Hand" },
]}
onChange={(v) => onChange("voice", v)}
/>
</Row>
<Row label="Notifications">
<Pills
value={settings.notify}
options={[
{ value: "adaptive", label: "Adaptive" },
{ value: "bubble", label: "Bubble" },
{ value: "toast", label: "Toast" },
{ value: "interrupt", label: "Interrupt" },
]}
onChange={(v) => onChange("notify", v)}
/>
</Row>
<Row label="When hidden">
<Pills
value={settings.osNotify ? "on" : "off"}
options={[
{ value: "on", label: "OS notification" },
{ value: "off", label: "Stay quiet" },
]}
onChange={async (v) => {
// permission request must ride a user gesture; only persist on if granted
if (v === "on") onChange("osNotify", await requestNotifyPermission());
else onChange("osNotify", false);
}}
/>
</Row>
<div className="sect-label">Eyes (vision)</div>
<Row label="Mode">
<Pills
value={settings.visionMode}
options={[
{ value: "off", label: "Off" },
{ value: "ondemand", label: "On demand" },
{ value: "ambient", label: "Ambient" },
]}
onChange={(v) => onChange("visionMode", v)}
/>
</Row>
<div className="set-note">
{settings.visionMode === "off"
? "Puck's eyes are shut."
: settings.visionMode === "ondemand"
? "Looks only when you pick “Look around”. No cost unattended."
: "Looks every ~45s while you're active; pauses when idle so the cloud GPU scales to zero."}
</div>
<div className="set-actions">
<button type="button" className="btn primary" onClick={onPatrol}>
Patrol now
</button>
<button type="button" className="btn" onClick={onSleep}>
Night Bloom
</button>
</div>
</div>
</div>
);
}