/** * Leo ambience prefs — idle dots, thinking wash, composer veil. * Shell-global (same rationale as `shell:ask-leo-dock`). */ export const LEO_AMBIENCE_KEY = "shell:leo-ambience" export const LEO_AMBIENCE_WINDOW_RECT_KEY = "shell:leo-ambience-window-rect" export const LEO_AMBIENCE_WINDOW_OPEN_KEY = "shell:leo-ambience-window-open" export type LeoBlobIntensity = "low" | "normal" | "high" /** * The chip's lobe fills. Two levels, not three, because two is what the blob * field actually paints: `AnimatedBlobBackground` has `normal` and `high`, and * every other surface folds `low` into `normal`. Quieter than normal is the * strength slider's job, so the control offers what it can deliver. */ export type LeoLauncherWashIntensity = "normal" | "high" export type LeoComposerVeil = "off" | "soft" | "strong" export type LeoBlobAnimationSpeed = "slow" | "normal" | "fast" /** Leo Library / mode-switch search bar wash relative to the pill. */ export type LeoSearchBarWashMode = "inside" | "outside" /** * Thinking chrome on the Ask Leo panel / window. * `blobs` — gradient wash. `pulse` — breathing brand perimeter (Beam Pulse IA). * Search bar stays on bar blobs either way. */ export type LeoThinkingAnimationStyle = "blobs" | "pulse" export interface LeoAmbiencePrefs { /** Soft cursor spotlight dots while Leo is idle. */ idleDots: boolean /** Relative density for idle dots (0.5 sparse → 3 dense). */ idleDensity: number /** Spotlight radius in px for idle dots. */ idleGlowRadius: number /** Gradient blob wash while Leo is thinking (and idle wash when enabled). */ thinkingBlob: boolean /** Panel thinking look — blobs wash or pulse border. */ thinkingAnimationStyle: LeoThinkingAnimationStyle thinkingBlobIntensity: LeoBlobIntensity /** Layer opacity for thinking / idle blobs (0.05–1). Also pulse strength. */ thinkingBlobOpacity: number /** Cursor-spotlight dots while thinking (same field as idle). */ thinkingOverlayDots: boolean /** Opacity for thinking cursor dots (0.05–1). */ thinkingOverlayDotsOpacity: number /** Blob float / think keyframe animations. */ blobAnimations: boolean /** Multiplier on blob keyframe durations. */ blobAnimationSpeed: LeoBlobAnimationSpeed /** Blur veil under the composer. */ composerVeil: LeoComposerVeil /** Thinking wash on the Leo search / mode-switch bar. */ searchBarWash: boolean /** Paint wash inside the pill or around it (halo). */ searchBarWashMode: LeoSearchBarWashMode /** * Free offset of the search-bar wash in px (no snap presets). * Positive X → right, positive Y → down. */ searchBarOffsetX: number searchBarOffsetY: number /** Soft brand sheen sweep while the search bar is thinking. */ searchBarSheen: boolean /** Thinking-dot overlay on the Leo search bar. */ searchBarDots: boolean /** * Blob field inside the utility bar's Ask Leo chip (`AskLeoLauncherWash`). * * Its own switch rather than a slice of `searchBarWash`: the chip is on every * page of the shell, the Leo search bar only exists inside Leo, so a reader * who finds the chip busy has no reason to lose the composer's wash too. */ launcherWash: boolean /** Lobe fills for the chip. `high` applies only while Leo is working. */ launcherWashIntensity: LeoLauncherWashIntensity /** * Scales the chip's contrast budget (`--ask-leo-chip-blob-opacity`), rather * than replacing it: the budget is a different number per theme and per * high-contrast mode, and a flat opacity here would strand the label's ink on * one of them. 1 is the shipped field. */ launcherWashStrength: number /** Sheen sweep across the chip while Leo is working. */ launcherWashSheen: boolean /** * Free offset of the chip's lobes in px. Its own range rather than the search * bar's: the composer pad moves the wash by up to 120px, which is more than a * chip is wide, so the same numbers would only ever park the field off it. */ launcherWashOffsetX: number launcherWashOffsetY: number /** * Blob wash on the products-home greeting cover banner (`HomeGreetingShelfCard`). * * Its own switch: the banner only appears on campus / launcher / spotlight * homes, and tuning it should not require touching the Ask Leo panel wash. */ greetingBannerWash: boolean /** * Scales wash opacity on the greeting banner (same range as the Ask Leo chip). */ greetingBannerWashStrength: number /** * Free offset of the greeting-banner lobes in px. Sized like the search bar: * the cover is full width, so the chip's ±14px pad would barely move the field. */ greetingBannerOffsetX: number greetingBannerOffsetY: number } /** Tuned from the Ask Leo appearance panel (dogfood). */ export const LEO_AMBIENCE_DEFAULTS: LeoAmbiencePrefs = { idleDots: true, idleDensity: 0.85, idleGlowRadius: 176, thinkingBlob: true, thinkingAnimationStyle: "blobs", thinkingBlobIntensity: "high", thinkingBlobOpacity: 0.55, thinkingOverlayDots: true, thinkingOverlayDotsOpacity: 0.88, blobAnimations: true, blobAnimationSpeed: "slow", composerVeil: "soft", searchBarWash: true, searchBarWashMode: "inside", // The three lobes are placed symmetrically across the pill, so the shipped // default keeps them centred. Only Y is offset, to sit the wash low in the bar. searchBarOffsetX: 0, searchBarOffsetY: 64, searchBarSheen: true, searchBarDots: true, launcherWash: true, launcherWashIntensity: "high", launcherWashStrength: 1, launcherWashSheen: true, // Centred: the three lobes are placed symmetrically across the chip, and the // label sits over their middle, so the shipped field is the balanced one. launcherWashOffsetX: 0, launcherWashOffsetY: 0, greetingBannerWash: true, greetingBannerWashStrength: 1, greetingBannerOffsetX: 0, greetingBannerOffsetY: 0, } /** Max |offset| for the free search-bar wash pad (px). */ export const LEO_SEARCH_BAR_OFFSET_MAX = 120 /** * Range for `launcherWashStrength`. The floor is a field you can still see * (below ~0.4 of the budget the chip reads as flat, at which point the switch is * the honest control); the ceiling is the point past which the brand fills start * to compete with the label they sit behind. */ export const LEO_LAUNCHER_WASH_STRENGTH_MIN = 0.4 export const LEO_LAUNCHER_WASH_STRENGTH_MAX = 1.5 /** * Max |offset| for the chip's placement pad (px). Sized against the control, not * the composer: the chip is 32px tall, so ±14 is the most that still leaves a * lobe overlapping the box it is meant to fill. Past that the pad would only * offer ways to empty the chip. */ export const LEO_LAUNCHER_OFFSET_MAX = 14 /** CSS `--leo-blob-anim-rate` multipliers (higher = slower). */ export const LEO_BLOB_ANIM_RATE: Record = { slow: 1.75, normal: 1, fast: 0.55, } export function mergeLeoAmbiencePrefs( partial: Partial | null | undefined, ): LeoAmbiencePrefs { const merged = { ...LEO_AMBIENCE_DEFAULTS, ...partial } if (merged.thinkingAnimationStyle !== "pulse") { merged.thinkingAnimationStyle = "blobs" } return merged } export function clampIdleDensity(value: number): number { return Math.min(3, Math.max(0.5, Math.round(value * 100) / 100)) } export function clampIdleGlowRadius(value: number): number { return Math.min(320, Math.max(80, Math.round(value))) } export function clampThinkingBlobOpacity(value: number): number { return Math.min(1, Math.max(0.05, Math.round(value * 100) / 100)) } export function clampThinkingOverlayDotsOpacity(value: number): number { return Math.min(1, Math.max(0.05, Math.round(value * 100) / 100)) } export function clampLauncherWashStrength(value: number): number { return Math.min( LEO_LAUNCHER_WASH_STRENGTH_MAX, Math.max( LEO_LAUNCHER_WASH_STRENGTH_MIN, Math.round((Number.isFinite(value) ? value : 1) * 100) / 100, ), ) } export function clampSearchBarOffset(value: number): number { return Math.min( LEO_SEARCH_BAR_OFFSET_MAX, Math.max(-LEO_SEARCH_BAR_OFFSET_MAX, Math.round(value)), ) } export function clampLauncherWashOffset(value: number): number { return Math.min( LEO_LAUNCHER_OFFSET_MAX, Math.max( -LEO_LAUNCHER_OFFSET_MAX, Math.round(Number.isFinite(value) ? value : 0), ), ) } /** Max |offset| for the greeting-banner placement pad (px). */ export const LEO_GREETING_BANNER_OFFSET_MAX = LEO_SEARCH_BAR_OFFSET_MAX export function clampGreetingBannerOffset(value: number): number { return clampSearchBarOffset(value) } export function clampGreetingBannerWashStrength(value: number): number { return clampLauncherWashStrength(value) }