Spaces:
Running
Running
| // 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> | |
| ); | |
| } | |