diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue index d1f1b1b73..76646f1c5 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue @@ -41,6 +41,7 @@ import { modelSettingsRuntimeSnapshotChannelName } from '../../shared/model-sett import { useChatSyncStore } from '../stores/chat-sync' import { useControlsIslandStore } from '../stores/controls-island' import { useStageWindowLifecycleStore } from '../stores/stage-window-lifecycle' +import { resolveFadeOnHoverInteraction } from '../utils/fade-on-hover' import { shouldSampleStageTransparency } from '../utils/stage-three-transparency' import { createVoiceInputInteractionLifecycle } from '../utils/voice-input-lifecycle' import { @@ -139,10 +140,6 @@ const isAroundWindowBorderFor250Ms = refDebounced(isAroundWindowBorder, 250) const setIgnoreMouseEvents = useElectronEventaInvoke(electron.window.setIgnoreMouseEvents) -const { pause, resume } = watch(isTransparent, (transparent) => { - shouldFadeOnCursorWithin.value = fadeOnHoverEnabled.value && !transparent -}, { immediate: true }) - const hearingDialogOpen = computed(() => controlsIslandRef.value?.hearingDialogOpen ?? false) const modelSettingsRuntimeSnapshot = computed(() => { @@ -236,12 +233,29 @@ const modelSettingsRuntimeSnapshot = computed(() = }) }) -watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, hearingDialogOpen, fadeOnHoverEnabled, stagePaused], () => { +/** + * Keeps the rendered fade state and Electron click-through state synchronized. + * + * Triggering workflow: + * + * {@link watch} + * -> `fade-on-hover reactive state change` + * -> {@link handleFadeOnHoverInteractionChange} + * + * Upstream: + * - {@link isOutsideFor250Ms}, {@link isOutsideStatusIslandFor250Ms}, and {@link isAroundWindowBorderFor250Ms} + * - {@link isOutsideWindow}, {@link isTransparent}, and {@link isTransparentForMouseEvents} + * - {@link hearingDialogOpen}, {@link fadeOnHoverEnabled}, and {@link stagePaused} + * + * Downstream: + * - {@link resolveFadeOnHoverInteraction} + * - {@link setIgnoreMouseEvents} + */ +function handleFadeOnHoverInteractionChange() { if (stagePaused.value) { isIgnoringMouseEvents.value = false shouldFadeOnCursorWithin.value = false setIgnoreMouseEvents([false, { forward: true }]) - pause() return } @@ -250,7 +264,6 @@ watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor isIgnoringMouseEvents.value = false shouldFadeOnCursorWithin.value = false setIgnoreMouseEvents([false, { forward: true }]) - pause() return } @@ -262,23 +275,26 @@ watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor isIgnoringMouseEvents.value = false shouldFadeOnCursorWithin.value = false setIgnoreMouseEvents([false, { forward: true }]) - pause() } else { - const fadeEnabled = fadeOnHoverEnabled.value - // Keep visible model pixels interactive; only the exact transparent pixel under the cursor - // should pass clicks through. The fuzzy transparency value above is intentionally reserved - // for fade stability near model edges. - const shouldIgnoreMouseEvents = fadeEnabled && isTransparentForMouseEvents.value - isIgnoringMouseEvents.value = shouldIgnoreMouseEvents - shouldFadeOnCursorWithin.value = fadeEnabled && !isOutsideWindow.value && !isTransparent.value - setIgnoreMouseEvents([shouldIgnoreMouseEvents, { forward: true }]) - if (fadeEnabled) - resume() - else - pause() + const interaction = resolveFadeOnHoverInteraction({ + cursorInsideWindow: !isOutsideWindow.value, + enabled: fadeOnHoverEnabled.value, + transparentForFade: isTransparent.value, + transparentForPointer: isTransparentForMouseEvents.value, + }) + + isIgnoringMouseEvents.value = interaction.ignoreMouseEvents + shouldFadeOnCursorWithin.value = interaction.fadeStage + setIgnoreMouseEvents([interaction.ignoreMouseEvents, { forward: true }]) } -}) +} + +watch( + [isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, isTransparentForMouseEvents, hearingDialogOpen, fadeOnHoverEnabled, stagePaused], + handleFadeOnHoverInteractionChange, + { immediate: true }, +) // Emit runtime snapshot on change and on request from settings panel /** diff --git a/apps/stage-tamagotchi/src/renderer/utils/fade-on-hover.test.ts b/apps/stage-tamagotchi/src/renderer/utils/fade-on-hover.test.ts new file mode 100644 index 000000000..395db0051 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/utils/fade-on-hover.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { resolveFadeOnHoverInteraction } from './fade-on-hover' + +describe('fade on hover interaction', () => { + it('lets pointer input reach the underlying app when a visible model fades', () => { + const interaction = resolveFadeOnHoverInteraction({ + cursorInsideWindow: true, + enabled: true, + transparentForFade: false, + transparentForPointer: false, + }) + + expect(interaction.fadeStage).toBe(true) + expect(interaction.ignoreMouseEvents).toBe(true) + }) + + it('keeps an unfaded transparent stage click-through', () => { + const interaction = resolveFadeOnHoverInteraction({ + cursorInsideWindow: true, + enabled: true, + transparentForFade: true, + transparentForPointer: true, + }) + + expect(interaction.fadeStage).toBe(false) + expect(interaction.ignoreMouseEvents).toBe(true) + }) + + it('keeps the stage visible and interactive when Auto Hide is disabled', () => { + const interaction = resolveFadeOnHoverInteraction({ + cursorInsideWindow: true, + enabled: false, + transparentForFade: false, + transparentForPointer: false, + }) + + expect(interaction.fadeStage).toBe(false) + expect(interaction.ignoreMouseEvents).toBe(false) + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/utils/fade-on-hover.ts b/apps/stage-tamagotchi/src/renderer/utils/fade-on-hover.ts new file mode 100644 index 000000000..786d4baca --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/utils/fade-on-hover.ts @@ -0,0 +1,28 @@ +/** + * Resolves the visual and native input behavior for Auto Hide outside interactive controls. + * A faded stage must ignore mouse events so its invisible pixels cannot block the app below. + */ +export function resolveFadeOnHoverInteraction(params: { + cursorInsideWindow: boolean + enabled: boolean + transparentForFade: boolean + transparentForPointer: boolean +}) { + const fadeStage = params.enabled + && params.cursorInsideWindow + && !params.transparentForFade + + // NOTICE: + // Fade detection deliberately uses a sampled region to avoid flickering around model edges, + // while native pointer hit-testing uses the exact pixel to keep visible model pixels interactive. + // Once the whole stage fades, that exact pixel can still report the now-invisible model as opaque, + // so the fade decision must also enable click-through. This preserves the Auto Hide contract that + // invisible stage content cannot block the application below it. + // Source/context: `apps/stage-tamagotchi/src/renderer/pages/index.vue` transparency samplers. + // Removal condition: the visual fade no longer covers pixels considered interactive by hit-testing. + return { + fadeStage, + ignoreMouseEvents: params.enabled + && (fadeStage || params.transparentForPointer), + } +}