"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 | Ask Leo
* button | Greeting banner | 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,
TabsListScrollRegion,
TabsTrigger,
TabsTriggerIcon,
TabsTriggerLabel,
} from "@/components/ui/tabs"
import { ToggleSwitch } from "@/components/ui/toggle-switch"
import {
ButtonSegmentedControl,
type ButtonSegmentOption,
} from "@/components/ui/button-segmented-control"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
LEO_AMBIENCE_DEFAULTS,
LEO_AMBIENCE_WINDOW_RECT_KEY,
LEO_GREETING_BANNER_OFFSET_MAX,
LEO_LAUNCHER_OFFSET_MAX,
LEO_LAUNCHER_WASH_STRENGTH_MAX,
LEO_LAUNCHER_WASH_STRENGTH_MIN,
LEO_SEARCH_BAR_OFFSET_MAX,
clampGreetingBannerOffset,
clampLauncherWashOffset,
clampSearchBarOffset,
type LeoBlobAnimationSpeed,
type LeoBlobIntensity,
type LeoComposerVeil,
type LeoLauncherWashIntensity,
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 (
{children}
)
}
/**
* One exclusive choice in this window.
*
* `ButtonSegmentedControl` rather than `ToggleGroup`: an ambience pref always
* has exactly one answer, and the outline toggle group let the user deselect
* into a fourth, meaningless state that the handlers then had to swallow
* (`if (!v) return`). The DS control is a `radiogroup` on the same pill chrome as
* the hub view switcher, so these rows read as the product's own segments
* instead of a stack of bordered buttons.
*
* The fade lives here, not in the control: a disabled pref is still legible,
* and the label has to dim with it or the row reads as half-available.
*/
function ChoiceRow({
label,
ariaLabel,
value,
options,
disabled,
onChange,
}: {
label: string
ariaLabel?: string
value: T
options: ButtonSegmentOption[]
disabled?: boolean
onChange: (value: T) => void
}) {
return (
)
}
/**
* Free 2D offset pad — no snap; maps pointer position to ±`max`.
*
* The range is a prop because the two washes are different sizes: the composer's
* pad moves its lobes by up to 120px, and the chip's by 14, so a shared range
* would either be too coarse to place a chip field or too short to reach the
* ends of a composer. Keyboard steps come with it for the same reason.
*/
function WashOffsetPad({
ariaLabel,
max,
clamp,
step = 4,
coarseStep = 12,
offsetX,
offsetY,
disabled,
onChange,
}: {
ariaLabel: string
max: number
clamp: (value: number) => number
/** Arrow-key increment. */
step?: number
/** Shift + arrow increment. */
coarseStep?: number
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
onChange(clamp((nx * 2 - 1) * max), clamp((ny * 2 - 1) * max))
},
[clamp, max, 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 / max + 1) / 2) * 100
const topPct = ((offsetY / max + 1) / 2) * 100
return (
{
if (disabled) return
const increment = event.shiftKey ? coarseStep : step
let x = offsetX
let y = offsetY
if (event.key === "ArrowLeft") x -= increment
else if (event.key === "ArrowRight") x += increment
else if (event.key === "ArrowUp") y -= increment
else if (event.key === "ArrowDown") y += increment
else return
event.preventDefault()
onChange(clamp(x), clamp(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
)
}
/** Icons so the row can shed labels before it sheds whole tabs — this window is resizable to nothing. */
const AMBIENCE_TABS = [
{ value: "panel", label: "Ask Leo panel", icon: "fa-sidebar-flip" },
{ value: "search", label: "Leo search bar", icon: "fa-magnifying-glass" },
{ value: "launcher", label: "Ask Leo button", icon: "fa-sparkles" },
{ value: "greeting", label: "Greeting banner", icon: "fa-sun" },
{ value: "motion", label: "Motion", icon: "fa-waveform-lines" },
] as const
const THINKING_STYLE_OPTIONS: ButtonSegmentOption[] = [
{ value: "blobs", label: "Blobs" },
{ value: "pulse", label: "Pulse" },
]
const BLOB_INTENSITY_OPTIONS: ButtonSegmentOption[] = [
{ value: "low", label: "Low" },
{ value: "normal", label: "Normal" },
{ value: "high", label: "High" },
]
/**
* Two, where the panel offers three: the blob field paints `normal` and `high`,
* and quieter than normal is what Field strength is for. A third segment here
* would be a control that changes nothing.
*/
const LAUNCHER_WASH_INTENSITY_OPTIONS: ButtonSegmentOption[] =
[
{ value: "normal", label: "Normal" },
{ value: "high", label: "High" },
]
const COMPOSER_VEIL_OPTIONS: ButtonSegmentOption[] = [
{ value: "off", label: "Off" },
{ value: "soft", label: "Soft" },
{ value: "strong", label: "Strong" },
]
const SEARCH_BAR_WASH_MODE_OPTIONS: ButtonSegmentOption[] = [
{ value: "inside", label: "Inside" },
{ value: "outside", label: "Outside" },
]
const ANIMATION_SPEED_OPTIONS: ButtonSegmentOption[] = [
{ value: "slow", label: "Slow" },
{ value: "normal", label: "Normal" },
{ value: "fast", label: "Fast" },
]
function AmbienceSettingsForm() {
const {
prefs,
patchPrefs,
resetPrefs,
previewThinking,
setPreviewThinking,
} = useLeoAmbience()
const washEnabled = prefs.thinkingBlob
const isPulse = prefs.thinkingAnimationStyle === "pulse"
return (
{/* Floating window the user can resize down to nothing. */}
{AMBIENCE_TABS.map(tab => (
{tab.label}
))}
patchPrefs({ idleDots })}
/>
{
if (!prefs.launcherWash) return
patchPrefs({ launcherWashSheen })
}}
/>
The Ask Leo button in the utility bar carries the search bar blobs
scaled to a chip. The field stays on and brightens on hover and while
Leo is arriving, open, or working, so strength above 100% is spent on
that brighter state. Placement moves the lobes by a chip's worth of
pixels, not the composer's. Drift and speed live on the Motion tab.
The products home greeting cover on campus, launcher, and spotlight
layouts. Blob vs pulse follows the Ask Leo panel style on the Motion
tab. Drift and speed live there too.
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.
)
}
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.
*/