"use client"
/**
* Leo ambience settings — floating window via shared `FloatingWindow`.
* Apply-on-change; prefs live in `LeoAmbienceProvider`.
* Free-drag (no corner snap). Tabs: Ask Leo panel | Leo search bar | Motion.
*/
import * as React from "react"
import { useLeoAmbience } from "@/components/leo-ambience-context"
import { Button } from "@/components/ui/button"
import {
FloatingWindow,
defaultFloatingWindowRect,
type FloatingWindowRect,
type FloatingWindowViewport,
} from "@/components/ui/floating-window"
import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ToggleSwitch } from "@/components/ui/toggle-switch"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
LEO_AMBIENCE_DEFAULTS,
LEO_AMBIENCE_WINDOW_RECT_KEY,
LEO_SEARCH_BAR_OFFSET_MAX,
clampSearchBarOffset,
type LeoBlobAnimationSpeed,
type LeoBlobIntensity,
type LeoComposerVeil,
type LeoSearchBarWashMode,
type LeoThinkingAnimationStyle,
} from "@/lib/leo-ambience"
import { usePersistedState } from "@exxatdesignux/ui/lib/persisted-state"
import { cn } from "@/lib/utils"
const SETTINGS_DEFAULT_WIDTH = 380
const SETTINGS_DEFAULT_HEIGHT = 640
function defaultSettingsRect(viewport: FloatingWindowViewport): FloatingWindowRect {
return defaultFloatingWindowRect(viewport, {
width: SETTINGS_DEFAULT_WIDTH,
height: SETTINGS_DEFAULT_HEIGHT,
anchor: "center-right",
})
}
function SettingRow({
label,
htmlFor,
children,
}: {
label: string
htmlFor?: string
children: React.ReactNode
}) {
return (
)
}
/** Free 2D offset pad — no snap; maps pointer to ±LEO_SEARCH_BAR_OFFSET_MAX. */
function SearchBarOffsetPad({
offsetX,
offsetY,
disabled,
onChange,
}: {
offsetX: number
offsetY: number
disabled?: boolean
onChange: (x: number, y: number) => void
}) {
const padRef = React.useRef(null)
const draggingRef = React.useRef(false)
const applyFromClient = React.useCallback(
(clientX: number, clientY: number) => {
const el = padRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const nx = (clientX - rect.left) / rect.width
const ny = (clientY - rect.top) / rect.height
const x = clampSearchBarOffset((nx * 2 - 1) * LEO_SEARCH_BAR_OFFSET_MAX)
const y = clampSearchBarOffset((ny * 2 - 1) * LEO_SEARCH_BAR_OFFSET_MAX)
onChange(x, y)
},
[onChange],
)
const onPointerDown = (event: React.PointerEvent) => {
if (disabled || event.button !== 0) return
event.preventDefault()
event.stopPropagation()
draggingRef.current = true
event.currentTarget.setPointerCapture(event.pointerId)
applyFromClient(event.clientX, event.clientY)
}
const onPointerMove = (event: React.PointerEvent) => {
if (!draggingRef.current) return
event.stopPropagation()
applyFromClient(event.clientX, event.clientY)
}
const endDrag = (event: React.PointerEvent) => {
if (!draggingRef.current) return
draggingRef.current = false
try {
event.currentTarget.releasePointerCapture(event.pointerId)
} catch {
/* already released */
}
}
const leftPct = ((offsetX / LEO_SEARCH_BAR_OFFSET_MAX + 1) / 2) * 100
const topPct = ((offsetY / LEO_SEARCH_BAR_OFFSET_MAX + 1) / 2) * 100
return (
Placement
onChange(0, 0)}
>
Center
{
if (disabled) return
const step = event.shiftKey ? 12 : 4
let x = offsetX
let y = offsetY
if (event.key === "ArrowLeft") x -= step
else if (event.key === "ArrowRight") x += step
else if (event.key === "ArrowUp") y -= step
else if (event.key === "ArrowDown") y += step
else return
event.preventDefault()
onChange(clampSearchBarOffset(x), clampSearchBarOffset(y))
}}
className={cn(
"relative aspect-square w-full touch-none rounded-xl border border-border bg-muted/40",
disabled ? "cursor-not-allowed" : "cursor-crosshair",
)}
>
X {offsetX > 0 ? "+" : ""}
{offsetX}px
Y {offsetY > 0 ? "+" : ""}
{offsetY}px
)
}
function AmbienceSettingsForm() {
const {
prefs,
patchPrefs,
resetPrefs,
previewThinking,
setPreviewThinking,
} = useLeoAmbience()
const washEnabled = prefs.thinkingBlob
const isPulse = prefs.thinkingAnimationStyle === "pulse"
return (
Ask Leo panel
Leo search bar
Motion
patchPrefs({ idleDots })}
/>
Dot density
patchPrefs({ idleDensity: v[0] ?? prefs.idleDensity })}
/>
Sparse
{prefs.idleDensity.toFixed(2)}
Dense
Spotlight size
patchPrefs({ idleGlowRadius: v[0] ?? prefs.idleGlowRadius })
}
/>
Tight
{prefs.idleGlowRadius}px
Wide
patchPrefs({ thinkingBlob })}
/>
Thinking style
{
if (!v) return
patchPrefs({
thinkingAnimationStyle: v as LeoThinkingAnimationStyle,
})
}}
variant="outline"
size="sm"
className="justify-start"
>
Blobs
Pulse
{isPulse ? "Pulse strength" : "Wash opacity"}
patchPrefs({
thinkingBlobOpacity: v[0] ?? prefs.thinkingBlobOpacity,
})
}
/>
Faint
{Math.round(prefs.thinkingBlobOpacity * 100)}%
Strong
{!isPulse ? (
Blob intensity
{
if (!v) return
patchPrefs({ thinkingBlobIntensity: v as LeoBlobIntensity })
}}
variant="outline"
size="sm"
className="justify-start"
>
Low
Normal
High
) : null}
patchPrefs({ thinkingOverlayDots })}
/>
Thinking dot opacity
patchPrefs({
thinkingOverlayDotsOpacity:
v[0] ?? prefs.thinkingOverlayDotsOpacity,
})
}
/>
Faint
{Math.round(prefs.thinkingOverlayDotsOpacity * 100)}%
Strong
Composer veil
{
if (!v) return
patchPrefs({ composerVeil: v as LeoComposerVeil })
}}
variant="outline"
size="sm"
className="justify-start"
>
Off
Soft
Strong
patchPrefs({ searchBarWash })}
/>
Wash mode
{
if (!v) return
patchPrefs({ searchBarWashMode: v as LeoSearchBarWashMode })
}}
variant="outline"
size="sm"
className="justify-start"
>
Inside
Outside
patchPrefs({ searchBarOffsetX, searchBarOffsetY })
}
/>
{
if (!prefs.searchBarWash) return
patchPrefs({ searchBarSheen })
}}
/>
{
if (!prefs.searchBarWash) return
patchPrefs({ searchBarDots })
}}
/>
Double-click the Leo search bar to open this window. Preview thinking
on the Motion tab shows the wash while you tune placement.
patchPrefs({ blobAnimations })}
/>
Animation speed
{
if (!v) return
patchPrefs({ blobAnimationSpeed: v as LeoBlobAnimationSpeed })
}}
variant="outline"
size="sm"
className="justify-start"
>
Slow
Normal
Fast
Speed drives blob drift, pulse breathe, and thinking cursor waves.
Search bar wash uses the same speed. Thinking style lives on Ask Leo
panel; search bar stays on bar blobs.
Reset to defaults
Defaults: {LEO_AMBIENCE_DEFAULTS.thinkingAnimationStyle}, density{" "}
{LEO_AMBIENCE_DEFAULTS.idleDensity}, wash{" "}
{Math.round(LEO_AMBIENCE_DEFAULTS.thinkingBlobOpacity * 100)}%,{" "}
{LEO_AMBIENCE_DEFAULTS.blobAnimationSpeed}
)
}
export function LeoAmbienceWindow() {
const { settingsOpen, setSettingsOpen } = useLeoAmbience()
const [storedRect, setStoredRect] = usePersistedState(
LEO_AMBIENCE_WINDOW_RECT_KEY,
null,
)
const close = React.useCallback(() => setSettingsOpen(false), [setSettingsOpen])
return (
Leo appearance
}
toolbar={
Close
}
>
)
}
/*
* There is deliberately no header gear here. Appearance is reached by
* double-clicking an Ask Leo header, or by the Appearance item in
* `AskLeoViewToggle` (the keyboard route). A third entry point in the header
* would spend a toolbar slot on a preference the reader opens once.
*/