"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 => (