import { all } from '@proj-airi/i18n'
+import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { isPosthogAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
-import { useSettings } from '@proj-airi/stage-ui/stores/settings'
-import { FieldCheckbox, FieldCombobox, useTheme } from '@proj-airi/ui'
+import { useSettings, useSettingsControlsIsland } from '@proj-airi/stage-ui/stores/settings'
+import { FieldCheckbox, FieldCombobox, FieldRange, useTheme } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults(defineProps<{
- needsControlsIslandIconSizeSetting?: boolean
+ needsAutoHideControlsIslandSetting?: boolean
}>(), {
- needsControlsIslandIconSizeSetting: import.meta.env.RUNTIME_ENVIRONMENT === 'electron',
+ needsAutoHideControlsIslandSetting: isStageTamagotchi(),
})
const settings = useSettings()
+const settingsControlsIsland = useSettingsControlsIsland()
-const showControlsIsland = computed(() => props.needsControlsIslandIconSizeSetting)
+const showAutoHideControlsIsland = computed(() => props.needsAutoHideControlsIslandSetting)
const showAnalyticsSettings = computed(() => isPosthogAvailableInBuild())
const analyticsToggleValue = computed({
get: () => showAnalyticsSettings.value ? settings.analyticsEnabled : false,
@@ -59,24 +61,62 @@ const languages = computed(() => {
:options="languages"
/>
-
+
+
+
+
+
+
+
+
new Promise(resolve => setTimeout(resolve, ms))
+
+describe('useControlsIslandAutoHide', () => {
+ beforeEach(() => {
+ vi.useRealTimers()
+ })
+
+ // =============================================================================
+ // Basic Functionality Tests
+ // =============================================================================
+
+ describe('hiddenOpacity calculation', () => {
+ it('should convert 0-100 to 0-1', () => {
+ const autoHideOpacity = ref(30)
+ const isOutside = ref(true)
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ const { hiddenOpacity } = useControlsIslandAutoHide({
+ autoHideControlsIsland: ref(true),
+ autoHideDelay: ref(0.5),
+ autoShowDelay: ref(0.5),
+ autoHideOpacity,
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ expect(hiddenOpacity.value).toBe(0.3)
+
+ autoHideOpacity.value = 100
+ expect(hiddenOpacity.value).toBe(1)
+
+ autoHideOpacity.value = 0
+ expect(hiddenOpacity.value).toBe(0)
+ })
+ })
+
+ describe('initial state', () => {
+ it('should initialize delayed states based on isOutside', () => {
+ const isOutside = ref(true)
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ const { isOutsideDelayed, isInsideDelayed } = useControlsIslandAutoHide({
+ autoHideControlsIsland: ref(true),
+ autoHideDelay: ref(0.5),
+ autoShowDelay: ref(0.5),
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ expect(isOutsideDelayed.value).toBe(true)
+ expect(isInsideDelayed.value).toBe(false)
+
+ // Test with isOutside = false
+ const isOutside2 = ref(false)
+ const { isOutsideDelayed: d2, isInsideDelayed: i2 } = useControlsIslandAutoHide({
+ autoHideControlsIsland: ref(true),
+ autoHideDelay: ref(0.5),
+ autoShowDelay: ref(0.5),
+ autoHideOpacity: ref(30),
+ isOutside: isOutside2,
+ isBlocked: ref(false),
+ expanded: ref(false),
+ })
+
+ expect(d2.value).toBe(false)
+ expect(i2.value).toBe(true)
+ })
+ })
+
+ // =============================================================================
+ // isHidden Computation Tests
+ // =============================================================================
+
+ describe('isHidden computation', () => {
+ it('should return false when autoHideControlsIsland is false', () => {
+ const isOutside = ref(true)
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ const { isHidden } = useControlsIslandAutoHide({
+ autoHideControlsIsland: ref(false),
+ autoHideDelay: ref(0.5),
+ autoShowDelay: ref(0.5),
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ expect(isHidden.value).toBe(false)
+ })
+
+ it('should return false when isBlocked is true', () => {
+ const isOutside = ref(true)
+ const isBlocked = ref(true) // Blocked
+ const expanded = ref(false)
+
+ const { isHidden } = useControlsIslandAutoHide({
+ autoHideControlsIsland: ref(true),
+ autoHideDelay: ref(0.5),
+ autoShowDelay: ref(0.5),
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ expect(isHidden.value).toBe(false)
+ })
+
+ it('should return false when expanded is true', () => {
+ const isOutside = ref(true)
+ const isBlocked = ref(false)
+ const expanded = ref(true) // Expanded
+
+ const { isHidden } = useControlsIslandAutoHide({
+ autoHideControlsIsland: ref(true),
+ autoHideDelay: ref(0.5),
+ autoShowDelay: ref(0.5),
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ expect(isHidden.value).toBe(false)
+ })
+
+ it('should return false immediately when mouse is inside and autoShowDelay is 0', () => {
+ const isOutside = ref(false) // Mouse inside
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ const { isHidden } = useControlsIslandAutoHide({
+ autoHideControlsIsland: ref(true),
+ autoHideDelay: ref(0.5),
+ autoShowDelay: ref(0), // No delay
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ expect(isHidden.value).toBe(false)
+ })
+
+ it('should return true when mouse is outside and delay reached', () => {
+ const isOutside = ref(true) // Mouse outside
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ // Create composable and manually control delayed state
+ const autoHideControlsIsland = ref(true)
+ const autoHideDelay = ref(0.5)
+ const autoShowDelay = ref(0.5)
+ const autoHideOpacity = ref(30)
+
+ const { isHidden, isOutsideDelayed: delayedRef } = useControlsIslandAutoHide({
+ autoHideControlsIsland,
+ autoHideDelay,
+ autoShowDelay,
+ autoHideOpacity,
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ // Simulate delay reached
+ delayedRef.value = true
+
+ expect(isHidden.value).toBe(true)
+ })
+ })
+
+ // =============================================================================
+ // useTimeoutFn Integration Tests
+ // =============================================================================
+
+ describe('useTimeoutFn responds to delay changes', () => {
+ it('should use new delay when delay changes', async () => {
+ const autoHideDelay = ref(0.2) // 200ms
+
+ // Test the timeout mechanism separately
+ const timeoutFlag = ref(false)
+ const autoHideDelayMs = computed(() => autoHideDelay.value * 1000)
+
+ const { start, stop } = useTimeoutFn(() => {
+ timeoutFlag.value = true
+ }, () => autoHideDelayMs.value, { immediate: false })
+
+ start()
+
+ // Before timer fires, change delay
+ await wait(100)
+ autoHideDelay.value = 0.5 // Change to 500ms
+ stop()
+ start()
+
+ // At 400ms (old delay 200ms) - should still be false
+ await wait(300)
+ expect(timeoutFlag.value).toBe(false)
+
+ // At 600ms total (new delay 500ms from change) - should be true
+ await wait(300)
+ expect(timeoutFlag.value).toBe(true)
+ })
+
+ it('should stop and restart correctly when isOutside changes', async () => {
+ const autoHideDelay = ref(0.2)
+ const autoShowDelay = ref(0.2)
+ const isOutside = ref(false)
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ // Create composable instance (not using its values, just for setup)
+ useControlsIslandAutoHide({
+ autoHideControlsIsland: ref(true),
+ autoHideDelay,
+ autoShowDelay,
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ // Test timer switching separately
+ const isOutsideDel = ref(false)
+ const isInsideDel = ref(true)
+
+ const autoHideDelayMs = computed(() => autoHideDelay.value * 1000)
+ const autoShowDelayMs = computed(() => autoShowDelay.value * 1000)
+
+ const { start: startOutside, stop: stopOutside } = useTimeoutFn(() => {
+ isOutsideDel.value = true
+ }, () => autoHideDelayMs.value, { immediate: false })
+
+ const { start: startInside, stop: stopInside } = useTimeoutFn(() => {
+ isInsideDel.value = true
+ }, () => autoShowDelayMs.value, { immediate: false })
+
+ // Mouse leaves - start outside timer
+ isOutside.value = true
+ isInsideDel.value = false
+ stopOutside()
+ stopInside()
+ startOutside()
+
+ // Before timer fires, mouse enters - switch timers
+ await wait(100)
+ isOutside.value = false
+ isOutsideDel.value = false
+ stopOutside()
+ stopInside()
+ startInside()
+
+ // At 200ms - outside timer should not have fired
+ await wait(200)
+ expect(isOutsideDel.value).toBe(false)
+
+ // At 300ms - inside timer should have fired
+ await wait(100)
+ expect(isInsideDel.value).toBe(true)
+ })
+ })
+
+ // =============================================================================
+ // Integration: Full Auto-Hide/Show Flow
+ // =============================================================================
+
+ describe('integration: auto-hide island behavior', () => {
+ it('should hide after configured delay when mouse leaves', async () => {
+ const autoHideControlsIsland = ref(true)
+ const autoHideDelay = ref(0.3) // 300ms
+ const autoShowDelay = ref(0.3)
+ const isOutside = ref(false) // Mouse inside
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ const { isOutsideDelayed, isHidden } = useControlsIslandAutoHide({
+ autoHideControlsIsland,
+ autoHideDelay,
+ autoShowDelay,
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ // Simulate watch for isOutside
+ const autoHideDelayMs = computed(() => autoHideDelay.value * 1000)
+ const autoShowDelayMs = computed(() => autoShowDelay.value * 1000)
+
+ const { start: startOutside, stop: stopOutside } = useTimeoutFn(() => {
+ isOutsideDelayed.value = true
+ }, () => autoHideDelayMs.value, { immediate: false })
+
+ const { stop: stopInside } = useTimeoutFn(() => {}, () => autoShowDelayMs.value, { immediate: false })
+
+ watch(isOutside, (val) => {
+ if (!autoHideControlsIsland.value) {
+ stopOutside()
+ stopInside()
+ return
+ }
+ stopOutside()
+ stopInside()
+ if (val) {
+ startOutside()
+ }
+ })
+
+ // Mouse leaves
+ isOutside.value = true
+ startOutside()
+
+ // Before delay - should still be visible
+ await wait(250)
+ expect(isOutsideDelayed.value).toBe(false)
+ expect(isHidden.value).toBe(false)
+
+ // After delay (300ms) - should be hidden
+ await wait(100)
+ expect(isOutsideDelayed.value).toBe(true)
+ expect(isHidden.value).toBe(true)
+ })
+
+ it('should show after configured delay when mouse enters', async () => {
+ const autoHideControlsIsland = ref(true)
+ const autoHideDelay = ref(0.3)
+ const autoShowDelay = ref(0.3)
+ const isOutside = ref(true) // Mouse initially outside
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ const { isInsideDelayed, isHidden } = useControlsIslandAutoHide({
+ autoHideControlsIsland,
+ autoHideDelay,
+ autoShowDelay,
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ const autoHideDelayMs = computed(() => autoHideDelay.value * 1000)
+ const autoShowDelayMs = computed(() => autoShowDelay.value * 1000)
+
+ const { stop: stopOutside } = useTimeoutFn(() => {}, () => autoHideDelayMs.value, { immediate: false })
+
+ const { start: startInside, stop: stopInside } = useTimeoutFn(() => {
+ isInsideDelayed.value = true
+ }, () => autoShowDelayMs.value, { immediate: false })
+
+ watch(isOutside, (val) => {
+ if (!autoHideControlsIsland.value) {
+ stopOutside()
+ stopInside()
+ return
+ }
+ stopOutside()
+ stopInside()
+ if (!val) {
+ startInside()
+ }
+ })
+
+ // Initial: mouse outside, isHidden should be true (delay already passed)
+ // Simulate that delay has passed for being outside
+ expect(isHidden.value).toBe(true)
+
+ // Mouse enters (isOutside = false)
+ isOutside.value = false
+
+ // Before delay - should still be hidden (waiting for show delay)
+ await wait(250)
+ expect(isInsideDelayed.value).toBe(false)
+ expect(isHidden.value).toBe(true)
+
+ // Start the timer
+ startInside()
+
+ // After delay (300ms) - should be shown
+ await wait(100)
+ expect(isInsideDelayed.value).toBe(true)
+ expect(isHidden.value).toBe(false)
+ })
+
+ it('should use 1500ms fallback when autoHideControlsIsland is false', () => {
+ const autoHideControlsIsland = ref(false)
+ const autoHideDelay = ref(0.5)
+
+ const collapseDelayMs = computed(() =>
+ autoHideControlsIsland.value ? autoHideDelay.value * 1000 : 1500,
+ )
+
+ expect(collapseDelayMs.value).toBe(1500)
+ })
+
+ it('should reset state when autoHideControlsIsland changes', () => {
+ const autoHideControlsIsland = ref(true)
+ const autoHideDelay = ref(0.5)
+ const isOutside = ref(true)
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ const { isOutsideDelayed, isInsideDelayed } = useControlsIslandAutoHide({
+ autoHideControlsIsland,
+ autoHideDelay,
+ autoShowDelay: ref(0.5),
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ // Initial state when isOutside = true
+ expect(isOutsideDelayed.value).toBe(true)
+ expect(isInsideDelayed.value).toBe(false)
+
+ // Change autoHideControlsIsland to false
+ autoHideControlsIsland.value = false
+
+ // Should reset to immediate sync (not delayed)
+ expect(isOutsideDelayed.value).toBe(isOutside.value)
+ expect(isInsideDelayed.value).toBe(!isOutside.value)
+ })
+ })
+
+ // =============================================================================
+ // stopAll Function
+ // =============================================================================
+
+ describe('stopAll function', () => {
+ it('should stop all timers', () => {
+ const autoHideDelay = ref(0.5)
+ const isOutside = ref(true)
+ const isBlocked = ref(false)
+ const expanded = ref(false)
+
+ const { stopAll } = useControlsIslandAutoHide({
+ autoHideControlsIsland: ref(true),
+ autoHideDelay,
+ autoShowDelay: ref(0.5),
+ autoHideOpacity: ref(30),
+ isOutside,
+ isBlocked,
+ expanded,
+ })
+
+ // Start timers by triggering isOutside change
+ isOutside.value = false
+ isOutside.value = true
+
+ // stopAll should not throw
+ expect(() => stopAll()).not.toThrow()
+ })
+ })
+})
diff --git a/packages/stage-ui/src/composables/use-controls-island-auto-hide.ts b/packages/stage-ui/src/composables/use-controls-island-auto-hide.ts
new file mode 100644
index 0000000000..181f1ca7dc
--- /dev/null
+++ b/packages/stage-ui/src/composables/use-controls-island-auto-hide.ts
@@ -0,0 +1,136 @@
+import type { Ref } from 'vue'
+
+import { useTimeoutFn } from '@vueuse/core'
+import { computed, ref, watch } from 'vue'
+
+export interface UseControlsIslandAutoHideOptions {
+ /** Whether auto-hide is enabled */
+ autoHideControlsIsland: Ref
+ /** Delay in seconds before hiding when mouse leaves */
+ autoHideDelay: Ref
+ /** Delay in seconds before showing when mouse enters */
+ autoShowDelay: Ref
+ /** Opacity value (0-100) when hidden */
+ autoHideOpacity: Ref
+ /** Whether mouse is outside the element */
+ isOutside: Ref
+ /** Whether there's a blocking overlay */
+ isBlocked: Ref
+ /** Whether the panel is expanded */
+ expanded: Ref
+}
+
+export interface UseControlsIslandAutoHideReturn {
+ /** Delayed state: true after mouse has been outside for configured delay */
+ isOutsideDelayed: Ref
+ /** Delayed state: true after mouse has been inside for configured delay */
+ isInsideDelayed: Ref
+ /** Whether the island is currently hidden */
+ isHidden: Ref
+ /** Opacity value (0-1) when hidden */
+ hiddenOpacity: Ref
+ /** Stop all timers (useful when component unmounts) */
+ stopAll: () => void
+}
+
+/**
+ * Composable for controls island auto-hide/show behavior.
+ * Handles delayed hide/show based on mouse position and configurable delays.
+ */
+export function useControlsIslandAutoHide(options: UseControlsIslandAutoHideOptions): UseControlsIslandAutoHideReturn {
+ const { autoHideControlsIsland, autoHideDelay, autoShowDelay, autoHideOpacity, isOutside, isBlocked, expanded } = options
+
+ // Delayed states that respond to both value AND delay configuration changes
+ const isOutsideDelayed = ref(isOutside.value)
+ const isInsideDelayed = ref(!isOutside.value)
+
+ // --- Auto-hide island (only works when autoHideControlsIsland = true) ---
+ const { start: startOutside, stop: stopOutside } = useTimeoutFn(() => {
+ isOutsideDelayed.value = true
+ }, () => autoHideDelay.value * 1000, { immediate: false })
+
+ const { start: startInside, stop: stopInside } = useTimeoutFn(() => {
+ isInsideDelayed.value = true
+ }, () => autoShowDelay.value * 1000, { immediate: false })
+
+ // Watch mouse position changes
+ watch(isOutside, (val) => {
+ if (!autoHideControlsIsland.value) {
+ // Not in auto-hide mode, reset states
+ stopOutside()
+ stopInside()
+ isOutsideDelayed.value = val
+ isInsideDelayed.value = !val
+ return
+ }
+ // Stop any pending timers
+ stopOutside()
+ stopInside()
+ if (val) {
+ // Mouse left - start hide delay timer
+ // Only update state when delay is met (or delay is 0)
+ if (autoHideDelay.value <= 0) {
+ isOutsideDelayed.value = true
+ }
+ else {
+ startOutside()
+ }
+ }
+ else {
+ // Mouse entered - start show delay timer
+ // Only update state when delay is met (or delay is 0)
+ if (autoShowDelay.value <= 0) {
+ isInsideDelayed.value = true
+ }
+ else {
+ startInside()
+ }
+ }
+ })
+
+ // Reset all states when autoHideControlsIsland toggles
+ watch(autoHideControlsIsland, () => {
+ stopOutside()
+ stopInside()
+ isOutsideDelayed.value = isOutside.value
+ isInsideDelayed.value = !isOutside.value
+ })
+
+ // Calculate opacity when hidden (0-100 range converted to 0-1)
+ const hiddenOpacity = computed(() => autoHideOpacity.value / 100)
+
+ // Determine if island should be hidden
+ const isHidden = computed(() => {
+ if (!autoHideControlsIsland.value)
+ return false
+
+ // Don't hide if there's a blocking overlay or expanded panel should stay
+ if (isBlocked.value || expanded.value)
+ return false
+
+ // When mouse is inside, wait for show delay before fully showing
+ if (!isOutside.value) {
+ if (autoShowDelay.value > 0) {
+ // Wait for mouse to be inside for the configured delay before showing
+ return !isInsideDelayed.value
+ }
+ return false
+ }
+
+ // When mouse is outside, hide after delay
+ return isOutsideDelayed.value
+ })
+
+ const stopAll = () => {
+ stopOutside()
+ stopInside()
+ }
+
+ return {
+ isOutsideDelayed,
+ isInsideDelayed,
+ isHidden,
+ hiddenOpacity,
+ stopAll,
+ }
+}
diff --git a/packages/stage-ui/src/composables/use-controls-island-collapse.test.ts b/packages/stage-ui/src/composables/use-controls-island-collapse.test.ts
new file mode 100644
index 0000000000..b7e907b924
--- /dev/null
+++ b/packages/stage-ui/src/composables/use-controls-island-collapse.test.ts
@@ -0,0 +1,115 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref, watch } from 'vue'
+
+import { useControlsIslandCollapse } from './use-controls-island-collapse'
+
+// Helper to wait for a duration
+const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
+// =============================================================================
+// useControlsIslandCollapse Tests
+// =============================================================================
+
+describe('useControlsIslandCollapse', () => {
+ beforeEach(() => {
+ vi.useRealTimers()
+ })
+
+ describe('collapseDelayMs calculation', () => {
+ it('should use autoHideDelay * 1000 when enabled', () => {
+ const autoHideDelay = ref(0.5)
+ const autoHideControlsIsland = ref(true)
+ const expanded = ref(true)
+ const isBlocked = ref(false)
+
+ const { collapseDelayMs } = useControlsIslandCollapse({
+ autoHideDelay,
+ autoHideControlsIsland,
+ expanded,
+ isBlocked,
+ })
+
+ expect(collapseDelayMs.value).toBe(500)
+ })
+
+ it('should use 1500ms fallback when disabled', () => {
+ const autoHideDelay = ref(0.5)
+ const autoHideControlsIsland = ref(false)
+ const expanded = ref(true)
+ const isBlocked = ref(false)
+
+ const { collapseDelayMs } = useControlsIslandCollapse({
+ autoHideDelay,
+ autoHideControlsIsland,
+ expanded,
+ isBlocked,
+ })
+
+ expect(collapseDelayMs.value).toBe(1500)
+ })
+ })
+
+ describe('collapse behavior', () => {
+ it('should collapse after delay when mouse leaves', async () => {
+ const autoHideControlsIsland = ref(true)
+ const autoHideDelay = ref(0.3)
+ const isOutside = ref(false) // Mouse inside
+ const expanded = ref(true)
+ const isBlocked = ref(false)
+
+ const { startCollapse, stopCollapse } = useControlsIslandCollapse({
+ autoHideDelay,
+ autoHideControlsIsland,
+ expanded,
+ isBlocked,
+ })
+
+ // Simulate watch for isOutside
+ watch(isOutside, (val) => {
+ if (val) {
+ stopCollapse()
+ startCollapse()
+ }
+ })
+
+ // Mouse leaves
+ isOutside.value = true
+
+ // Before delay - should still be expanded
+ await wait(250)
+ expect(expanded.value).toBe(true)
+
+ // After delay - should be collapsed
+ await wait(100)
+ expect(expanded.value).toBe(false)
+ })
+
+ it('should not collapse when blocked', async () => {
+ const autoHideControlsIsland = ref(true)
+ const autoHideDelay = ref(0.3)
+ const isOutside = ref(true)
+ const expanded = ref(true)
+ const isBlocked = ref(true) // Blocked
+
+ const { startCollapse, stopCollapse } = useControlsIslandCollapse({
+ autoHideDelay,
+ autoHideControlsIsland,
+ expanded,
+ isBlocked,
+ })
+
+ watch(isOutside, (val) => {
+ if (val) {
+ stopCollapse()
+ startCollapse()
+ }
+ })
+
+ isOutside.value = true
+
+ await wait(400)
+
+ // Should still be expanded because blocked
+ expect(expanded.value).toBe(true)
+ })
+ })
+})
diff --git a/packages/stage-ui/src/composables/use-controls-island-collapse.ts b/packages/stage-ui/src/composables/use-controls-island-collapse.ts
new file mode 100644
index 0000000000..9ebbb56c16
--- /dev/null
+++ b/packages/stage-ui/src/composables/use-controls-island-collapse.ts
@@ -0,0 +1,46 @@
+import type { Ref } from 'vue'
+
+import { useTimeoutFn } from '@vueuse/core'
+import { computed } from 'vue'
+
+export interface UseControlsIslandCollapseOptions {
+ /** Delay in seconds before collapsing */
+ autoHideDelay: Ref
+ /** Whether auto-hide is enabled */
+ autoHideControlsIsland: Ref
+ /** Whether the panel is expanded */
+ expanded: Ref
+ /** Whether there's a blocking overlay */
+ isBlocked: Ref
+}
+
+export interface UseControlsIslandCollapseReturn {
+ /** Start the collapse timer */
+ startCollapse: () => void
+ /** Stop the collapse timer */
+ stopCollapse: () => void
+ collapseDelayMs: Ref
+}
+
+/**
+ * Composable for controls island auto-collapse behavior.
+ * Collapses the expanded panel after a delay when mouse leaves.
+ */
+export function useControlsIslandCollapse(options: UseControlsIslandCollapseOptions): UseControlsIslandCollapseReturn {
+ const { autoHideDelay, autoHideControlsIsland, expanded, isBlocked } = options
+
+ const collapseDelayMs = computed(() =>
+ autoHideControlsIsland.value ? autoHideDelay.value * 1000 : 1500,
+ )
+
+ const { start: startCollapse, stop: stopCollapse } = useTimeoutFn(() => {
+ if (expanded.value && !isBlocked.value)
+ expanded.value = false
+ }, () => collapseDelayMs.value, { immediate: false })
+
+ return {
+ startCollapse,
+ stopCollapse,
+ collapseDelayMs,
+ }
+}
diff --git a/packages/stage-ui/src/stores/settings/controls-island.test.ts b/packages/stage-ui/src/stores/settings/controls-island.test.ts
new file mode 100644
index 0000000000..7ba5f3c068
--- /dev/null
+++ b/packages/stage-ui/src/stores/settings/controls-island.test.ts
@@ -0,0 +1,67 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useSettingsControlsIsland } from './controls-island'
+
+describe('store settings-controls-island', () => {
+ beforeEach(() => {
+ const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
+ setActivePinia(pinia)
+ })
+
+ it('should have correct default values', () => {
+ const store = useSettingsControlsIsland()
+
+ expect(store.allowVisibleOnAllWorkspaces).toBe(true)
+ expect(store.alwaysOnTop).toBe(true)
+ expect(store.autoHideControlsIsland).toBe(false)
+ expect(store.autoHideDelay).toBe(0.5)
+ expect(store.autoShowDelay).toBe(0.5)
+ expect(store.autoHideOpacity).toBe(30)
+ })
+
+ it('should allow updating values', () => {
+ const store = useSettingsControlsIsland()
+
+ store.allowVisibleOnAllWorkspaces = false
+ store.alwaysOnTop = false
+ store.autoHideControlsIsland = true
+ store.autoHideDelay = 2
+ store.autoShowDelay = 1.5
+ store.autoHideOpacity = 50
+
+ expect(store.allowVisibleOnAllWorkspaces).toBe(false)
+ expect(store.alwaysOnTop).toBe(false)
+ expect(store.autoHideControlsIsland).toBe(true)
+ expect(store.autoHideDelay).toBe(2)
+ expect(store.autoShowDelay).toBe(1.5)
+ expect(store.autoHideOpacity).toBe(50)
+ })
+
+ it('should reset to default values via resetState action', () => {
+ const store = useSettingsControlsIsland()
+
+ // Modify values
+ store.allowVisibleOnAllWorkspaces = false
+ store.alwaysOnTop = false
+ store.autoHideControlsIsland = true
+ store.autoHideDelay = 3
+ store.autoShowDelay = 2
+ store.autoHideOpacity = 80
+
+ // Verify values were modified
+ expect(store.allowVisibleOnAllWorkspaces).toBe(false)
+
+ // Reset via action
+ store.resetState()
+
+ // Check defaults are restored
+ expect(store.allowVisibleOnAllWorkspaces).toBe(true)
+ expect(store.alwaysOnTop).toBe(true)
+ expect(store.autoHideControlsIsland).toBe(false)
+ expect(store.autoHideDelay).toBe(0.5)
+ expect(store.autoShowDelay).toBe(0.5)
+ expect(store.autoHideOpacity).toBe(30)
+ })
+})
diff --git a/packages/stage-ui/src/stores/settings/controls-island.ts b/packages/stage-ui/src/stores/settings/controls-island.ts
index 67ae1d47cc..0c2149cc4d 100644
--- a/packages/stage-ui/src/stores/settings/controls-island.ts
+++ b/packages/stage-ui/src/stores/settings/controls-island.ts
@@ -4,18 +4,27 @@ import { defineStore } from 'pinia'
export const useSettingsControlsIsland = defineStore('settings-controls-island', () => {
const allowVisibleOnAllWorkspaces = useLocalStorageManualReset('settings/allow-visible-on-all-workspaces', true)
const alwaysOnTop = useLocalStorageManualReset('settings/always-on-top', true)
- const controlsIslandIconSize = useLocalStorageManualReset<'auto' | 'large' | 'small'>('settings/controls-island/icon-size', 'auto')
+ const autoHideControlsIsland = useLocalStorageManualReset('settings/controls-island/auto-hide', false)
+ const autoHideDelay = useLocalStorageManualReset('settings/controls-island/auto-hide-delay', 0.5)
+ const autoShowDelay = useLocalStorageManualReset('settings/controls-island/auto-show-delay', 0.5)
+ const autoHideOpacity = useLocalStorageManualReset('settings/controls-island/auto-hide-opacity', 30)
function resetState() {
allowVisibleOnAllWorkspaces.reset()
alwaysOnTop.reset()
- controlsIslandIconSize.reset()
+ autoHideControlsIsland.reset()
+ autoHideDelay.reset()
+ autoShowDelay.reset()
+ autoHideOpacity.reset()
}
return {
allowVisibleOnAllWorkspaces,
alwaysOnTop,
- controlsIslandIconSize,
+ autoHideControlsIsland,
+ autoHideDelay,
+ autoShowDelay,
+ autoHideOpacity,
resetState,
}
})
diff --git a/packages/stage-ui/src/stores/settings/index.ts b/packages/stage-ui/src/stores/settings/index.ts
index c74bc7b629..11cfa0caf3 100644
--- a/packages/stage-ui/src/stores/settings/index.ts
+++ b/packages/stage-ui/src/stores/settings/index.ts
@@ -82,7 +82,10 @@ export const useSettings = defineStore('settings', () => {
// UI settings
allowVisibleOnAllWorkspaces: controlsIslandRefs.allowVisibleOnAllWorkspaces,
alwaysOnTop: controlsIslandRefs.alwaysOnTop,
- controlsIslandIconSize: controlsIslandRefs.controlsIslandIconSize,
+ autoHideControlsIsland: controlsIslandRefs.autoHideControlsIsland,
+ autoHideDelay: controlsIslandRefs.autoHideDelay,
+ autoShowDelay: controlsIslandRefs.autoShowDelay,
+ autoHideOpacity: controlsIslandRefs.autoHideOpacity,
// Methods
setThemeColorsHue: theme.setThemeColorsHue,