From e8e5077d649d48ede1646fdf61eb48c4bae8fe52 Mon Sep 17 00:00:00 2001 From: Renan Mello Date: Wed, 19 Aug 2026 17:21:19 -0300 Subject: [PATCH 1/2] fix(desktop): corrige flicker e salto lateral no morph da ilha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A abertura e o fechamento do chat piscavam mostrando o desktop, e a pílula saltava ~131px para o lado ao abrir. Instrumentando a transição (island-debug) apareceram três causas distintas, todas medidas: 1. Movimento lateral no mesmo SetWindowPos do resize. Centralizar o painel exigia mover a janela 132px físicos, e a ilha só ficava parada porque o CSS cancelava esse deslocamento via `left: calc(% - px)`. A compensação depende de o WebView refazer o layout no mesmo frame do resize; quando ele apresentava um frame com o layout antigo já na posição nova, a pílula saltava. A janela passa a ter largura fixa (ISLAND_WINDOW_WIDTH) em todos os modos: abrir mexe só na altura. Sem movimento não há o que compensar. As formas mais estreitas são centralizadas por dentro e o excedente é recortado da janela por SetWindowRgn, para as faixas transparentes não captarem cliques do desktop. 2. Tempo morto entre o fim da animação e o commit nativo. No fechamento havia ~240ms com a janela ainda expandida e transparente, já com a pílula desenhada — desktop visível em volta. Vinha de setMinSize e setResizable (invisíveis, ~150ms de IPC) sentados no caminho crítico, mais dois requestAnimationFrame por barreira. Os IPCs foram para o prepare e as barreiras caíram para um frame. 3. Faixa branca em volta da pílula. A região era montada a partir do retângulo visual cru, ignorando o recuo de 1px com que as superfícies são desenhadas, e o Rust ainda somava +1 — sobravam até 3px que a janela incluía e o CSS nunca pintava, expondo o fundo padrão branco do WebView2. A região agora sai do retângulo já recuado, arredondando para dentro, e a borda virou anel inset para não cair sobre a linha de corte. Também: reaplica a região em onScaleChanged, senão arrastar entre monitores de DPI diferente recorta a pílula; e a duração do morph caiu de 260ms para 170ms. Verificação: `tsc --noEmit` limpo. Os testes (vitest) e o `cargo check` não rodam no WSL deste ambiente — bug de optional deps do rollup e dependência de libpipewire, ambos alheios a estas mudanças. As expectativas de posição afetadas foram atualizadas, mas não executadas; o app foi validado manualmente no Windows. Co-Authored-By: Claude Opus 5 --- apps/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 1 + .../src-tauri/permissions/animation.toml | 5 + apps/desktop/src-tauri/src/lib.rs | 90 ++++++++ apps/desktop/src/BarApp.tsx | 210 +++++++++++++++--- .../components/floating-island-shell.test.tsx | 6 + .../src/components/floating-island-shell.tsx | 109 ++++++++- apps/desktop/src/index.css | 63 ++++-- .../src/lib/auth/enter-floating-mode.ts | 13 +- .../src/lib/floating-checklist-mode.test.ts | 8 +- .../src/lib/floating-checklist-mode.ts | 79 +++++-- .../src/lib/floating-compact-bounds.ts | 12 +- apps/desktop/src/lib/floating-edge-mode.ts | 21 +- .../src/lib/floating-island-transition.ts | 42 +++- .../src/lib/floating-quick-menu-mode.test.ts | 8 +- .../src/lib/floating-quick-menu-mode.ts | 88 ++++++-- apps/desktop/src/lib/island-debug.ts | 90 ++++++++ apps/desktop/src/lib/window-animation.ts | 9 + apps/desktop/src/lib/window-mode.ts | 36 ++- apps/desktop/src/lib/window-region.ts | 90 ++++++++ .../desktop/src/lib/window-transition.test.ts | 31 +-- apps/desktop/src/lib/window-transition.ts | 40 ++-- 22 files changed, 896 insertions(+), 156 deletions(-) create mode 100644 apps/desktop/src/lib/island-debug.ts create mode 100644 apps/desktop/src/lib/window-region.ts diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 9121b98..46b8af1 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ image = { version = "0.25", default-features = false, features = ["png"] } windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", "Win32_Media", "Win32_UI_WindowsAndMessaging", ] } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index fe2b367..3af8766 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -39,6 +39,7 @@ "allow-chat-clear-cache", "allow-animate-window-bounds", "allow-set-window-bounds", + "allow-set-window-region", "allow-monitor-work-area", "allow-panel-open", "allow-panel-close", diff --git a/apps/desktop/src-tauri/permissions/animation.toml b/apps/desktop/src-tauri/permissions/animation.toml index cfe8934..a044fdc 100644 --- a/apps/desktop/src-tauri/permissions/animation.toml +++ b/apps/desktop/src-tauri/permissions/animation.toml @@ -7,3 +7,8 @@ commands.allow = ["animate_window_bounds"] identifier = "allow-set-window-bounds" description = "Allows applying window position and size in a single step, without animating" commands.allow = ["set_window_bounds"] + +[[permission]] +identifier = "allow-set-window-region" +description = "Allows clipping the window's paint and hit-test area to a rounded rectangle" +commands.allow = ["set_window_region"] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 640ed1a..291dd95 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -262,6 +262,87 @@ fn set_window_bounds(window: WebviewWindow, to: Bounds) -> Result<(), String> { requeue_bounds(&window, to) } +#[cfg(windows)] +#[tauri::command] +fn set_window_region( + window: WebviewWindow, + region: Option, + radius: Option, +) -> Result<(), String> { + // `SetWindowRgn` mora em user32, mas o windows-sys o expõe em Graphics::Gdi + // junto do resto da API de regiões — não em UI::WindowsAndMessaging. + use windows_sys::Win32::Graphics::Gdi::{CreateRoundRectRgn, SetWindowRgn}; + + let hwnd = window.hwnd().map_err(|e| e.to_string())?; + + let hrgn = match region { + Some(rect) => { + // Limites exclusivos, sem `+1`: é melhor a região ficar um pixel + // dentro do desenho do que um pixel fora. Fora dela a janela expõe + // o fundo padrão do WebView2 (branco), que vira uma linha clara em + // volta da pílula; dentro, no máximo se perde uma fatia da borda. + let diameter = i32::try_from(radius.unwrap_or(0)).unwrap_or(0) * 2; + unsafe { + CreateRoundRectRgn( + rect.x, + rect.y, + rect.x + rect.width as i32, + rect.y + rect.height as i32, + diameter, + diameter, + ) + } + } + None => std::ptr::null_mut(), + }; + + // A janela assume a posse da região; deletá-la aqui invalidaria o handle. + let ok = unsafe { SetWindowRgn(hwnd.0 as _, hrgn, 1) }; + if ok == 0 { + return Err("SetWindowRgn failed".into()); + } + + hide_dwm_border(hwnd.0 as _); + Ok(()) +} + +/// Desliga o realce que o Windows 11 desenha na borda da janela ativa. +/// +/// Só o `DWMWA_BORDER_COLOR`: é o atributo que descreve exatamente esse realce. +/// `DWMWA_NCRENDERING_POLICY` e `DWMWA_WINDOW_CORNER_PREFERENCE` já foram +/// tentados aqui e não resolveram o sintoma — desligar a renderização +/// não-cliente inteira é largo demais para o problema e mexe no comportamento +/// geral da janela, então não vale manter no escuro. +#[cfg(windows)] +fn hide_dwm_border(hwnd: windows_sys::Win32::Foundation::HWND) { + use windows_sys::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_BORDER_COLOR, DWMWA_COLOR_NONE, + }; + + let color = DWMWA_COLOR_NONE; + // Ignorado em silêncio em versões que não conhecem o atributo. + unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_BORDER_COLOR as u32, + &color as *const _ as *const core::ffi::c_void, + core::mem::size_of_val(&color) as u32, + ); + } +} + +#[cfg(not(windows))] +#[tauri::command] +fn set_window_region( + _window: WebviewWindow, + _region: Option, + _radius: Option, +) -> Result<(), String> { + // Só o Windows precisa do recorte: é lá que a faixa transparente capta + // cliques. Nas outras plataformas o comando existe para o front não ramificar. + Ok(()) +} + #[tauri::command] fn monitor_work_area(window: WebviewWindow) -> Result { let monitor = window @@ -309,6 +390,14 @@ pub fn run() { if let Some(panel) = app.get_webview_window("panel") { panel::init_native_blur(&panel); } + // A ilha já nasce recortada pelo front; tirar o realce da borda aqui + // evita ele aparecer no primeiro foco, antes do primeiro recorte. + #[cfg(windows)] + if let Some(main) = app.get_webview_window("main") { + if let Ok(handle) = main.hwnd() { + hide_dwm_border(handle.0 as _); + } + } // Desliga o fade de show/hide das janelas e deixa o overlay fora de // qualquer captura. Precisa rodar aqui: as quatro janelas são // criadas pelo `tauri.conf.json`, antes deste ponto. @@ -318,6 +407,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ animate_window_bounds, set_window_bounds, + set_window_region, monitor_work_area, app::app_quit, auth::auth_set_tokens, diff --git a/apps/desktop/src/BarApp.tsx b/apps/desktop/src/BarApp.tsx index da841bc..efc6389 100644 --- a/apps/desktop/src/BarApp.tsx +++ b/apps/desktop/src/BarApp.tsx @@ -1,6 +1,7 @@ import type { UserPublic } from "@linvo/shared"; import { getCurrentWindow, PhysicalSize } from "@tauri-apps/api/window"; import { useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { EdgeHandle } from "@/components/edge-handle"; import { FloatingBar } from "@/components/floating-bar"; @@ -28,11 +29,19 @@ import { } from "@/lib/floating-quick-menu-mode"; import { hasMeaningfulMorph, + ISLAND_EXPANDED_RADIUS_PX, ISLAND_MORPH_DURATION_MS, ISLAND_MORPH_WATCHDOG_MS, ISLAND_PAINT_WATCHDOG_MS, type IslandMorphGeometry, } from "@/lib/floating-island-transition"; +import { applyIslandWindowRegion } from "@/lib/window-region"; +import { islandLog, sampleViewportFrames } from "@/lib/island-debug"; +import { + CHECKLIST_SIZE, + COMPACT_SIZE, + QUICK_MENU_SIZE, +} from "@/lib/window-mode"; import { releaseMinWindowSize } from "@/lib/window-animation"; import { NO_ANCHOR, type EdgeAnchor } from "@/lib/window-anchor"; import { @@ -57,6 +66,31 @@ type CloseQuickMenuOptions = { const QUICK_MENU_MIN_SIZE = { width: 320, height: 360 }; const QUICK_MENU_CLOSE_DEADLINE_MS = 600; +/** + * Desenho de cada modo. A janela é sempre `ISLAND_WINDOW_WIDTH` de largura; é + * isto que mantém a pílula com 168px em vez de esticar até a borda. + */ +function visualSizeForMode(mode: WindowMode) { + if (mode === "quick-menu") return QUICK_MENU_SIZE; + if (mode === "checklist") return CHECKLIST_SIZE; + return COMPACT_SIZE; +} + +function visualWidthForMode(mode: WindowMode): number { + return visualSizeForMode(mode).width; +} + +/** Pílula é totalmente arredondada; painéis usam o raio da ilha. */ +function visualRadiusForMode(mode: WindowMode): number { + return isCompactMode(mode) + ? visualSizeForMode(mode).height / 2 + : ISLAND_EXPANDED_RADIUS_PX; +} + +function isCompactMode(mode: WindowMode): boolean { + return mode === "compact" || mode === "edge-collapsed"; +} + async function withDeadline(task: Promise, timeoutMs: number): Promise { let timeoutId = 0; const timeout = new Promise((_, reject) => { @@ -162,7 +196,7 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { /* * A promise do `set_window_bounds` confirma o SetWindowPos, mas o WebView2 * ainda pode estar com o layout do viewport anterior. Esperar o `resize` e - * dois frames depois dele separa o commit nativo da primeira mudança de + * dois frames depois dele separa o commit nativo da primeira mudança de * transform/opacity do CSS. Sem essa barreira os dois commits podem cair no * mesmo frame e a janela transparente revela o desktop por um instante. */ @@ -189,30 +223,68 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { resolve(); }; + /* + * Um frame só, não dois. + * + * O segundo `requestAnimationFrame` custava 18-85ms medidos (frames caem + * enquanto o WebView refaz o layout do painel), e nesse intervalo a janela + * já está expandida com só a pílula pintada e parada — o desktop aparece + * em volta antes de a animação sequer começar. Um frame basta para o + * commit nativo não coalescer com a primeira mudança de transform. + */ const queuePaintFrames = () => { if (framesQueued || !matchesTargetViewport()) return; framesQueued = true; - firstFrame = window.requestAnimationFrame(() => { - secondFrame = window.requestAnimationFrame(finish); - }); + firstFrame = window.requestAnimationFrame(finish); }; const onResize = () => { queuePaintFrames(); }; - const timeoutId = window.setTimeout(finish, 180); + const timeoutId = window.setTimeout(() => { + islandLog("viewport-paint:WATCHDOG-EXPIRED", { + wanted: geometry.viewport, + got: { w: window.innerWidth, h: window.innerHeight }, + }); + finish(); + }, 180); window.addEventListener("resize", onResize); - // O evento pode ter chegado entre o commit nativo e a inscrição acima. + // O evento pode ter chegado entre o commit nativo e a inscrição acima. queuePaintFrames(); }); } + function settleIslandMorph(nextMode: WindowMode, options?: { panelReady?: boolean }) { + flushSync(() => { + setWindowMode(nextMode); + if (options?.panelReady) { + setPanelReady(true); + } + const current = islandMorphRef.current; + if (!current) { + return; + } + const settled: FloatingIslandMorph = { + ...current, + active: false, + settled: true, + }; + islandMorphRef.current = settled; + setIslandMorph(settled); + islandLog("morph:settle", { id: settled.id, mode: nextMode }); + }); + } + async function prepareIslandMorph( geometry: IslandMorphGeometry, fromMode: WindowMode, toMode: WindowMode, ) { + if (islandMorphRef.current?.settled) { + clearIslandMorph(); + } + if (!hasMeaningfulMorph(geometry)) { clearIslandMorph(); return; @@ -227,6 +299,14 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { }; islandMorphRef.current = nextMorph; setIslandMorph(nextMorph); + islandLog("morph:prepare", { + id: nextMorph.id, + from: fromMode, + to: toMode, + geomViewport: geometry.viewport, + fromRect: geometry.from, + toRect: geometry.to, + }); /* * O estado inicial precisa estar pintado antes de ativar, senão o browser @@ -264,6 +344,11 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { const activeMorph = { ...current, active: true }; islandMorphRef.current = activeMorph; setIslandMorph(activeMorph); + islandLog("morph:start", { id: current.id }); + sampleViewportFrames( + `morph-${current.id}-${current.fromMode}->${current.toMode}`, + ISLAND_MORPH_DURATION_MS + ISLAND_MORPH_WATCHDOG_MS, + ); return promise; } @@ -327,9 +412,10 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { }, }); await waitForIslandMorph(); - clearIslandMorph(); if (!cancelled && modeIntentRef.current === "checklist") { - setWindowMode("checklist"); + await waitForIslandPaint(); + settleIslandMorph("checklist"); + await waitForIslandPaint(); } } finally { if (!cancelled) { @@ -350,12 +436,17 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { await prepareIslandMorph(geometry, "checklist", "compact"); await startIslandMorph(); }, + onAfterCommit: async () => { + if (!cancelled && modeIntentRef.current === "compact") { + await waitForIslandPaint(); + settleIslandMorph("compact"); + await waitForIslandPaint(); + } + }, }) - .then(() => { + .then(async () => { if (!cancelled && modeIntentRef.current === "compact") { - setWindowMode("compact"); setChecklist(null); - clearIslandMorph(); } }) .catch(() => { @@ -394,18 +485,24 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { await prepareIslandMorph(geometry, "checklist", "compact"); await startIslandMorph(); }, + onAfterCommit: async () => { + if (modeIntentRef.current === "compact") { + await waitForIslandPaint(); + settleIslandMorph("compact"); + await waitForIslandPaint(); + } + }, }); if (modeIntentRef.current === "compact") { restoreChatFocusRef.current = true; - setWindowMode("compact"); setChecklist(null); } } catch { if (modeIntentRef.current === "compact") { modeIntentRef.current = "checklist"; } - } finally { clearIslandMorph(); + } finally { finishTransition(); } } @@ -420,6 +517,7 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { modeIntentRef.current = "quick-menu"; startTransition(); try { + setPanelReady(false); await expandFloatingToQuickMenu({ onPrepare: async (geometry) => { await prepareIslandMorph(geometry, "compact", "quick-menu"); @@ -428,28 +526,26 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { await waitForIslandViewportPaint(geometry); }, onResizeStart: () => { - if (modeIntentRef.current === "quick-menu") { - void startIslandMorph(); - } + void startIslandMorph(); }, }); await waitForIslandMorph(); if (modeIntentRef.current === "quick-menu") { - clearIslandMorph(); - setWindowMode("quick-menu"); - setPanelReady(true); + await waitForIslandPaint(); + settleIslandMorph("quick-menu", { panelReady: true }); + await waitForIslandPaint(); } else { - clearIslandMorph(); setCaptureAndSendPending(false); } } catch { - clearIslandMorph(); if (modeIntentRef.current === "quick-menu") { modeIntentRef.current = "compact"; setPanelReady(false); setWindowMode("compact"); setCaptureAndSendPending(false); } + clearIslandMorph(); + void ensureCompactWindowBounds().catch(() => undefined); } finally { finishTransition(); } @@ -471,9 +567,7 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { ): Promise { const quickMenuIsVisibleOrTransitioning = windowModeRef.current === "quick-menu" || - modeIntentRef.current === "quick-menu" || - islandMorphRef.current?.fromMode === "quick-menu" || - islandMorphRef.current?.toMode === "quick-menu"; + modeIntentRef.current === "quick-menu"; if ( !options.preserveIntent && @@ -505,17 +599,22 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { setPanelReady(false); await withDeadline( collapseQuickMenuToFloating({ + shouldCommit: () => + quickMenuCloseAttemptRef.current === closeAttempt, onBeforeCommit: async (geometry) => { - if (quickMenuCloseAttemptRef.current !== closeAttempt) return; await prepareIslandMorph(geometry, "quick-menu", "compact"); - if (quickMenuCloseAttemptRef.current !== closeAttempt) { - clearIslandMorph(); - return; - } await startIslandMorph(); }, - shouldCommit: () => - quickMenuCloseAttemptRef.current === closeAttempt, + onAfterCommit: async () => { + if ( + quickMenuCloseAttemptRef.current === closeAttempt && + modeIntentRef.current === "compact" + ) { + await waitForIslandPaint(); + settleIslandMorph("compact"); + await waitForIslandPaint(); + } + }, }), QUICK_MENU_CLOSE_DEADLINE_MS, ); @@ -528,10 +627,12 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { if (quickMenuCloseAttemptRef.current === closeAttempt) { quickMenuCloseAttemptRef.current += 1; } - setWindowMode("compact"); + if (windowModeRef.current !== "compact") { + setWindowMode("compact"); + } + clearIslandMorph(); setPanelReady(false); setCaptureAndSendPending(false); - clearIslandMorph(); setQuickMenuClosing(false); } })(); @@ -687,6 +788,40 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { }; }, []); + /* + * A região é recortada em pixels físicos, derivados da escala do monitor onde + * a janela estava quando foi aplicada. Com dois monitores de DPI diferente, ao + * arrastar a janela o Windows a redimensiona para o novo DPI e a região antiga + * passa a ser menor que a janela — a pílula aparece cortada. Reaplicar no + * evento de escala realinha os dois. + */ + useEffect(() => { + let disposed = false; + let unlisten: (() => void) | undefined; + + void getCurrentWindow() + .onScaleChanged(({ payload }) => { + const mode = windowModeRef.current; + void applyIslandWindowRegion({ + visual: visualSizeForMode(mode), + scaleFactor: payload.scaleFactor, + radius: visualRadiusForMode(mode), + }).catch(() => undefined); + }) + .then((dispose) => { + if (disposed) { + dispose(); + } else { + unlisten = dispose; + } + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, []); + useEffect(() => { let disposed = false; let unlisten: (() => void) | undefined; @@ -718,6 +853,10 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { }, []); useEffect(() => { + if (transitioning || (islandMorph && !islandMorph.settled)) { + return; + } + let cancelled = false; const win = getCurrentWindow(); @@ -752,7 +891,7 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { return () => { cancelled = true; }; - }, [windowMode]); + }, [windowMode, transitioning, islandMorph]); /* * Rede de segurança do morph: os bounds nativos e o CSS são aplicados em @@ -766,7 +905,7 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { !floatingReady || windowMode !== "compact" || transitioning || - islandMorph + (islandMorph && !islandMorph.settled) ) { return; } @@ -854,6 +993,7 @@ export function BarApp({ sessionWarning, user }: BarAppProps) { morph={islandMorph} renderMode={renderIslandMode} onMorphComplete={completeIslandMorph} + visualWidth={visualWidthForMode(windowMode)} /> ); } diff --git a/apps/desktop/src/components/floating-island-shell.test.tsx b/apps/desktop/src/components/floating-island-shell.test.tsx index 1ed541e..17e831e 100644 --- a/apps/desktop/src/components/floating-island-shell.test.tsx +++ b/apps/desktop/src/components/floating-island-shell.test.tsx @@ -29,6 +29,7 @@ describe("FloatingIslandShell", () => { morph={morph} renderMode={(mode) => {mode}} onMorphComplete={onMorphComplete} + visualWidth={168} />, ); @@ -62,6 +63,7 @@ describe("FloatingIslandShell", () => { }} renderMode={(mode) => {mode}} onMorphComplete={vi.fn()} + visualWidth={168} />, ); @@ -100,6 +102,7 @@ describe("FloatingIslandShell", () => { }} renderMode={(mode) => {mode}} onMorphComplete={vi.fn()} + visualWidth={168} />, ); @@ -126,6 +129,7 @@ describe("FloatingIslandShell", () => { }} renderMode={(mode) => {mode}} onMorphComplete={vi.fn()} + visualWidth={168} />, ); @@ -143,6 +147,7 @@ describe("FloatingIslandShell", () => { morph={null} renderMode={(mode) => {mode}} onMorphComplete={vi.fn()} + visualWidth={168} />, ); @@ -169,6 +174,7 @@ describe("FloatingIslandShell", () => { }} renderMode={(mode) => {mode}} onMorphComplete={onMorphComplete} + visualWidth={168} />, ); diff --git a/apps/desktop/src/components/floating-island-shell.tsx b/apps/desktop/src/components/floating-island-shell.tsx index 550a832..9234537 100644 --- a/apps/desktop/src/components/floating-island-shell.tsx +++ b/apps/desktop/src/components/floating-island-shell.tsx @@ -29,6 +29,7 @@ export type FloatingIslandMode = export type FloatingIslandMorph = { id: number; active: boolean; + settled?: boolean; fromMode: FloatingIslandMode; toMode: FloatingIslandMode; geometry: IslandMorphGeometry; @@ -39,12 +40,22 @@ type FloatingIslandShellProps = { morph: FloatingIslandMorph | null; renderMode: (mode: FloatingIslandMode) => ReactNode; onMorphComplete: (id: number) => void; + /** + * Largura do desenho do modo atual, em px lógicos. A janela é sempre mais + * larga (ver `ISLAND_WINDOW_WIDTH`); é esta medida que impede a pílula de + * esticar até as bordas quando não há morph em curso. + */ + visualWidth: number; }; type IslandStyle = CSSProperties & { "--island-morph-duration"?: string; }; +type StageStyle = CSSProperties & { + "--island-visual-width"?: string; +}; + function isCompactShape(mode: FloatingIslandMode): boolean { return mode === "compact" || mode === "edge-collapsed"; } @@ -56,6 +67,22 @@ function contentRectStyle( return resolveIslandPlacement(rect, geometry); } +function shouldRenderContentLayer( + phase: "source" | "target" | "stable", + morph: FloatingIslandMorph | null, +): boolean { + if (!morph) { + return true; + } + if (morph.settled) { + return phase === "stable"; + } + if (!morph.active) { + return phase === "source"; + } + return false; +} + type SurfaceLayer = { shape: "compact" | "expanded"; style: IslandStyle; @@ -124,22 +151,47 @@ function resolveSurfaceLayers( : [compactLayer, expandedLayer]; } +function resolveSettledSurfaceLayer( + geometry: IslandMorphGeometry, + mode: FloatingIslandMode, + fromMode: FloatingIslandMode, +): SurfaceLayer { + const layers = resolveSurfaceLayers(geometry, mode, fromMode); + const winningShape = isCompactShape(mode) ? "compact" : "expanded"; + const layer = layers.find((entry) => entry.shape === winningShape); + if (!layer) { + return layers[0]!; + } + return { + ...layer, + style: { + ...layer.style, + transition: "none", + }, + }; +} + export function FloatingIslandShell({ mode, morph, renderMode, onMorphComplete, + visualWidth, }: FloatingIslandShellProps) { const completedIdRef = useRef(null); + const isSettled = morph?.settled === true; const displayedMode = morph - ? morph.active + ? morph.settled || morph.active ? morph.toMode : morph.fromMode : mode; const displayedShape = isCompactShape(displayedMode) ? "compact" : "expanded"; - const surfaceLayers = morph - ? resolveSurfaceLayers(morph.geometry, displayedMode, morph.fromMode) - : null; + const settledSurface = + isSettled && morph ? resolveSettledSurfaceLayer(morph.geometry, mode, morph.fromMode) : null; + const surfaceLayers = + morph && !isSettled + ? resolveSurfaceLayers(morph.geometry, displayedMode, morph.fromMode) + : null; function completeMorph(id: number) { if (completedIdRef.current === id) { @@ -176,11 +228,24 @@ export function FloatingIslandShell({ event.currentTarget === event.target && event.propertyName === "transform" ) { - completeMorph(morph.id); + /* + * Um frame só. No colapso este callback é o que libera o `SetWindowPos`, + * e a essa altura o CSS já desenhou a pílula: cada frame extra aqui é um + * frame de janela expandida e transparente com o desktop em volta. + */ + window.requestAnimationFrame(() => completeMorph(morph.id)); } } - const contentLayers = morph + const contentLayers = morph?.settled + ? [ + { + mode, + rect: null, + phase: "stable" as const, + }, + ] + : morph ? [ { mode: morph.fromMode, @@ -201,13 +266,31 @@ export function FloatingIslandShell({ }, ]; + const stageStyle: StageStyle = { + "--island-visual-width": `${visualWidth}px`, + }; + return (
- {surfaceLayers ? ( + {settledSurface ? ( + diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index a835b60..7d2ca32 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -128,12 +128,12 @@ /* Motion da ilha flutuante: uma curva única para shell e conteúdo. */ --island-gutter: 1px; - --motion-duration-island: 260ms; + --motion-duration-island: 170ms; /* Cross-fade das superfícies: some antes do fim do transform, para nunca mostrar a borda da forma que está saindo já muito esticada. */ - --motion-duration-island-fade: 140ms; - --motion-duration-island-content: 150ms; - --motion-delay-island-content: 55ms; + --motion-duration-island-fade: 90ms; + --motion-duration-island-content: 100ms; + --motion-delay-island-content: 35ms; --motion-distance-island-content: 6px; --motion-distance-island-content-exit: -2px; --motion-ease-island: cubic-bezier(0.2, 0.82, 0.2, 1); @@ -528,14 +528,22 @@ box-shadow: var(--shadow-inner-light); } - /* Barra flutuante: pílula fina de superfície sólida */ + /* + * Barra flutuante: pílula fina de superfície sólida. + * + * A borda é um anel INSET, não `border`. A janela é recortada por + * `SetWindowRgn` exatamente no retângulo desta superfície (ver + * `window-region.ts`), e uma `border` mora no pixel mais externo do elemento — + * bem em cima da linha de corte. Em escala fracionária (1,25) esse pixel é + * raspado e a borda some em partes do contorno. O anel inset fica um pixel + * para dentro do recorte e sobrevive em qualquer DPI. + */ .floating-pill { background-color: var(--color-neutral-raised); - border: 1px solid var(--pill-hairline); - box-shadow: var(--shadow-inner-light); - transition: - border-color 200ms ease-out, - box-shadow 200ms ease-out; + box-shadow: + inset 0 0 0 1px var(--pill-hairline), + var(--shadow-inner-light); + transition: box-shadow 200ms ease-out; } /* @@ -544,7 +552,9 @@ * nos próprios botões, inset (ver `floating-bar.tsx`). */ .floating-pill:hover { - border-color: var(--hairline-strong); + box-shadow: + inset 0 0 0 1px var(--hairline-strong), + var(--shadow-inner-light); } .floating-island-stage { @@ -583,9 +593,17 @@ * transicionar isso fazia a pílula encolher e voltar no fim de cada * transição. Só as cores seguem animando, para o hover não ficar seco. */ + /* + * A janela tem sempre a largura do painel, então o estado estável não pode + * ocupá-la inteira: a pílula esticaria de 168 para 380px. A forma visível é + * centralizada por `--island-visual-width`, e o que sobra nas laterais é + * recortado da própria janela por `set_window_region`. + */ .floating-island-surface-stable { - inset: var(--island-gutter); - width: auto; + top: var(--island-gutter); + bottom: var(--island-gutter); + left: calc(50% - var(--island-visual-width, 100%) / 2 + var(--island-gutter)); + width: calc(var(--island-visual-width, 100%) - var(--island-gutter) * 2); height: auto; transform: none; opacity: 1; @@ -600,6 +618,20 @@ transition: none; } + /* + * Mesmo motivo do `.floating-pill`: a superfície expandida também é recortada + * pela região da janela, então a `border` de `.window-shell` cairia sobre a + * linha de corte. Escopado à ilha de propósito — `.window-shell` também veste + * containers de layout (auth, onboarding, checklist), onde trocar `border` por + * sombra mudaria o box-model. + */ + .floating-island-surface.window-shell { + border: none; + box-shadow: + inset 0 0 0 1px var(--hairline), + var(--shadow-inner-light); + } + .floating-island-content { position: absolute; z-index: 1; @@ -609,7 +641,10 @@ } .floating-island-content[data-phase="stable"] { - inset: var(--island-gutter); + top: var(--island-gutter); + bottom: var(--island-gutter); + left: calc(50% - var(--island-visual-width, 100%) / 2 + var(--island-gutter)); + width: calc(var(--island-visual-width, 100%) - var(--island-gutter) * 2); } /* diff --git a/apps/desktop/src/lib/auth/enter-floating-mode.ts b/apps/desktop/src/lib/auth/enter-floating-mode.ts index 34eba77..79d495b 100644 --- a/apps/desktop/src/lib/auth/enter-floating-mode.ts +++ b/apps/desktop/src/lib/auth/enter-floating-mode.ts @@ -11,13 +11,14 @@ import { logicalToPhysical, readWindowBounds, } from "@/lib/window-animation"; -import { COMPACT_SIZE } from "@/lib/window-mode"; +import { COMPACT_SIZE, windowSizeForVisual } from "@/lib/window-mode"; import { clampToMonitor, computeTopCenter, type Position, } from "@/lib/window-position"; import { updateTaskbarVisibility } from "@/lib/app-windows"; +import { applyIslandWindowRegion } from "@/lib/window-region"; import { EDGE_MARGIN, loadSavedPosition } from "@/lib/window-storage"; async function resolveCompactPosition( @@ -51,7 +52,7 @@ export async function enterFloatingMode(): Promise { const config = configForSurfaceMode("compact"); const win = getCurrentWindow(); const scale = await win.scaleFactor(); - const targetSize = logicalToPhysical(COMPACT_SIZE, scale); + const targetSize = logicalToPhysical(windowSizeForVisual(COMPACT_SIZE), scale); const targetPosition = await resolveCompactPosition(targetSize); await applyWindowBoundsWithFallback(win, { @@ -70,6 +71,14 @@ export async function enterFloatingMode(): Promise { new PhysicalPosition(targetPosition.x, targetPosition.y), ); + // A janela nasce com a largura do painel; sem o recorte a faixa transparente + // ao lado da pílula captaria cliques do desktop. + await applyIslandWindowRegion({ + visual: COMPACT_SIZE, + scaleFactor: scale, + radius: COMPACT_SIZE.height / 2, + }); + await updateTaskbarVisibility(true); await win.show(); await win.setFocus(); diff --git a/apps/desktop/src/lib/floating-checklist-mode.test.ts b/apps/desktop/src/lib/floating-checklist-mode.test.ts index fc15576..074eda8 100644 --- a/apps/desktop/src/lib/floating-checklist-mode.test.ts +++ b/apps/desktop/src/lib/floating-checklist-mode.test.ts @@ -24,7 +24,7 @@ describe("floating-checklist-mode positions", () => { expect(plan.finalPosition).toEqual({ x: 0, y: 0 }); }); - it("resizes without a pre-move when the bar is already visible, centering on the bar", () => { + it("resizes without a pre-move when the bar is already visible, keeping the origin", () => { const plan = resolveChecklistExpandPosition({ currentPosition: { x: 100, y: 80 }, currentSize: { width: 140, height: 40 }, @@ -33,9 +33,9 @@ describe("floating-checklist-mode positions", () => { }); expect(plan.moveFirst).toBeNull(); - // Centro da barra (100 + 140/2 = 170) vira o centro do checklist; só o - // eixo vertical, que não caberia acima do topo, é grudado na borda. - expect(plan.finalPosition).toEqual({ x: 26, y: 0 }); + // A janela cresce a partir do próprio canto: nenhum dos eixos se desloca, + // e o checklist ainda cabe na tela a partir de (100, 80). + expect(plan.finalPosition).toEqual({ x: 100, y: 80 }); }); it("clamps final checklist bounds to the monitor", () => { diff --git a/apps/desktop/src/lib/floating-checklist-mode.ts b/apps/desktop/src/lib/floating-checklist-mode.ts index e913628..dfca165 100644 --- a/apps/desktop/src/lib/floating-checklist-mode.ts +++ b/apps/desktop/src/lib/floating-checklist-mode.ts @@ -1,19 +1,24 @@ import { applyWindowBoundsImmediate, - applyWindowBoundsWithFallback, logicalToPhysical, readWindowBounds, } from "@/lib/window-animation"; import { + ISLAND_EXPANDED_RADIUS_PX, resolveCollapseMorphGeometry, resolveExpandMorphGeometry, + withCenteredVisualRects, type IslandCollapseHooks, type IslandExpandHooks, type IslandMorphGeometry, type PreparedIslandWindowTransition, } from "@/lib/floating-island-transition"; import type { EdgeAnchor } from "@/lib/window-anchor"; -import { CHECKLIST_SIZE, COMPACT_SIZE } from "@/lib/window-mode"; +import { + CHECKLIST_SIZE, + COMPACT_SIZE, + windowSizeForVisual, +} from "@/lib/window-mode"; import { clampToMonitor, type MonitorInfo, @@ -34,6 +39,10 @@ import { resolveExpandPlan, } from "@/lib/window-transition"; import { readWorkArea } from "@/lib/window-work-area"; +import { + applyIslandMorphRegion, + applyIslandWindowRegion, +} from "@/lib/window-region"; export const CHECKLIST_EXPAND_DURATION_MS = 320; export const CHECKLIST_COLLAPSE_DURATION_MS = 260; @@ -69,7 +78,10 @@ export async function expandFloatingToChecklist( await win.setFocus(); const scale = await win.scaleFactor(); - const targetSize = logicalToPhysical(CHECKLIST_SIZE, scale); + const targetSize = logicalToPhysical( + windowSizeForVisual(CHECKLIST_SIZE), + scale, + ); const current = await readWindowBounds(win); const monitorInfo = await readWorkArea(); const anchor = loadSavedAnchor() ?? undefined; @@ -87,11 +99,10 @@ export async function expandFloatingToChecklist( (plan.moveFirst.x !== current.position.x || plan.moveFirst.y !== current.position.y) ) { - await applyWindowBoundsWithFallback( - win, - { position: plan.moveFirst, size: current.size }, - { durationMs: 180 }, - ); + await applyWindowBoundsImmediate(win, { + position: plan.moveFirst, + size: current.size, + }); } const sameSize = @@ -109,10 +120,22 @@ export async function expandFloatingToChecklist( position: plan.finalPosition, size: targetSize, }; - const geometry = resolveExpandMorphGeometry({ - sourceBounds, - targetBounds, + const geometry = withCenteredVisualRects( + resolveExpandMorphGeometry({ + sourceBounds, + targetBounds, + scaleFactor: scale, + }), + COMPACT_SIZE.width, + CHECKLIST_SIZE.width, + ); + + // Região do morph: sem recorte lateral para o checklist crescer, mas ainda + // arredondada — sem região o Windows desenha a moldura do retângulo. + await applyIslandMorphRegion({ + maxHeight: CHECKLIST_SIZE.height, scaleFactor: scale, + radius: ISLAND_EXPANDED_RADIUS_PX, }); await options.onPrepare?.(geometry); @@ -139,7 +162,10 @@ export async function prepareChecklistCollapse(): Promise { const win = getCurrentWindow(); const scale = await win.scaleFactor(); - const targetSize = logicalToPhysical(COMPACT_SIZE, scale); + const targetSize = logicalToPhysical( + windowSizeForVisual(COMPACT_SIZE), + scale, + ); const current = await readWindowBounds(win); const monitorInfo = await readWorkArea(); const anchor = loadSavedAnchor() ?? undefined; @@ -165,11 +191,16 @@ export async function prepareChecklistCollapse(): Promise { return enqueueWindowAnimation(async () => { const win = getCurrentWindow(); + /* + * Recorte antes do resize, e com a escala já lida no prepare. + * + * Aqui a animação de CSS já terminou — a pílula está desenhada no tamanho + * final — então recortar na pílula não corta nada visível. Fazer isto + * depois deixava a janela encolhida e sem recorte pelo tempo do IPC + * (~160ms medidos), e é nesse retângulo cru que o Windows desenha a moldura. + */ + await applyIslandWindowRegion({ + visual: COMPACT_SIZE, + scaleFactor: transition.scaleFactor, + radius: COMPACT_SIZE.height / 2, + }); await applyWindowBoundsImmediate(win, transition.targetBounds); clearRestoreOrigin("checklist"); }); @@ -191,4 +235,5 @@ export async function collapseChecklistToFloating( const transition = await prepareChecklistCollapse(); await options.onBeforeCommit?.(transition.geometry); await commitChecklistCollapse(transition); + await options.onAfterCommit?.(transition.geometry); } diff --git a/apps/desktop/src/lib/floating-compact-bounds.ts b/apps/desktop/src/lib/floating-compact-bounds.ts index 5c450cb..eecac73 100644 --- a/apps/desktop/src/lib/floating-compact-bounds.ts +++ b/apps/desktop/src/lib/floating-compact-bounds.ts @@ -4,7 +4,7 @@ import { readWindowBounds, releaseMinWindowSize, } from "@/lib/window-animation"; -import { COMPACT_SIZE } from "@/lib/window-mode"; +import { COMPACT_SIZE, windowSizeForVisual } from "@/lib/window-mode"; import { clampToMonitor, type Size } from "@/lib/window-position"; import { clearRestoreOrigin, @@ -18,6 +18,7 @@ import { resolveCollapsePosition, } from "@/lib/window-transition"; import { readWorkArea } from "@/lib/window-work-area"; +import { applyIslandWindowRegion } from "@/lib/window-region"; /** * Tolerância de 1px: os bounds físicos vêm de `Math.ceil` sobre a escala do @@ -64,7 +65,7 @@ export async function ensureCompactWindowBounds( return enqueueWindowAnimation(async () => { const win = getCurrentWindow(); const scale = await win.scaleFactor(); - const targetSize = logicalToPhysical(COMPACT_SIZE, scale); + const targetSize = logicalToPhysical(windowSizeForVisual(COMPACT_SIZE), scale); const current = await readWindowBounds(win); if (isCompactWindowSize(current.size, targetSize)) { @@ -103,6 +104,13 @@ export async function ensureCompactWindowBounds( await releaseMinWindowSize(win); await win.setResizable(false); await applyWindowBoundsImmediate(win, { position, size: targetSize }); + // A região faz parte do estado compacto: um morph abortado deixa a janela + // sem recorte, e sem isto as faixas laterais seguiriam captando cliques. + await applyIslandWindowRegion({ + visual: COMPACT_SIZE, + scaleFactor: scale, + radius: COMPACT_SIZE.height / 2, + }); clearRestoreOrigin("quick-menu"); clearRestoreOrigin("checklist"); diff --git a/apps/desktop/src/lib/floating-edge-mode.ts b/apps/desktop/src/lib/floating-edge-mode.ts index ec4b216..0cd85a8 100644 --- a/apps/desktop/src/lib/floating-edge-mode.ts +++ b/apps/desktop/src/lib/floating-edge-mode.ts @@ -11,6 +11,7 @@ import { } from "@/lib/window-anchor"; import { COMPACT_SIZE, + windowSizeForVisual, EDGE_HANDLE_LENGTH, EDGE_HANDLE_THICKNESS, } from "@/lib/window-mode"; @@ -33,6 +34,7 @@ import { resolveCollapsePosition, } from "@/lib/window-transition"; import { readWorkArea } from "@/lib/window-work-area"; +import { applyIslandWindowRegion } from "@/lib/window-region"; export const EDGE_COLLAPSE_DURATION_MS = 180; export const EDGE_EXPAND_DURATION_MS = 200; @@ -148,6 +150,17 @@ export async function collapseToEdge(): Promise { { durationMs: EDGE_COLLAPSE_DURATION_MS }, ); + // O handle é mais estreito que a janela: recorta para as faixas laterais + // transparentes não captarem cliques da borda da tela. + await applyIslandWindowRegion({ + visual: { + width: targetSize.width / scale, + height: targetSize.height / scale, + }, + scaleFactor: scale, + radius: 0, + }); + saveSavedPosition(position); return anchor; }); @@ -157,7 +170,7 @@ export async function expandFromEdge(): Promise { return enqueueWindowAnimation(async () => { const win = getCurrentWindow(); const scale = await win.scaleFactor(); - const targetSize = logicalToPhysical(COMPACT_SIZE, scale); + const targetSize = logicalToPhysical(windowSizeForVisual(COMPACT_SIZE), scale); const current = await readWindowBounds(win); const workArea = await readWorkArea(); const anchor = loadSavedAnchor() ?? undefined; @@ -189,6 +202,12 @@ export async function expandFromEdge(): Promise { { durationMs: EDGE_EXPAND_DURATION_MS }, ); + await applyIslandWindowRegion({ + visual: COMPACT_SIZE, + scaleFactor: scale, + radius: COMPACT_SIZE.height / 2, + }); + saveSavedPosition(position); }); } diff --git a/apps/desktop/src/lib/floating-island-transition.ts b/apps/desktop/src/lib/floating-island-transition.ts index a9d6950..73b46b0 100644 --- a/apps/desktop/src/lib/floating-island-transition.ts +++ b/apps/desktop/src/lib/floating-island-transition.ts @@ -1,7 +1,7 @@ import type { Position, Size } from "@/lib/window-position"; -export const ISLAND_MORPH_DURATION_MS = 260; -export const ISLAND_MORPH_WATCHDOG_MS = 80; +export const ISLAND_MORPH_DURATION_MS = 170; +export const ISLAND_MORPH_WATCHDOG_MS = 60; export const ISLAND_PAINT_WATCHDOG_MS = 80; export const ISLAND_GUTTER_PX = 1; export const ISLAND_EXPANDED_RADIUS_PX = 14; @@ -24,6 +24,13 @@ export type IslandMorphGeometry = { export type PreparedIslandWindowTransition = { geometry: IslandMorphGeometry; targetBounds: WindowBounds; + /** + * Escala já lida no prepare. Carregada até o commit para o recorte da região + * não precisar de outro round-trip de IPC no meio da transição — era ele que + * deixava a janela ~160ms encolhida e ainda sem recorte, tempo suficiente para + * o Windows desenhar a moldura em volta do retângulo. + */ + scaleFactor: number; }; export type IslandExpandHooks = { @@ -40,6 +47,11 @@ export type IslandExpandHooks = { export type IslandCollapseHooks = { /** Run the local CSS collapse before compact native bounds are committed. */ onBeforeCommit?: (geometry: IslandMorphGeometry) => Promise | void; + /** + * Runs after native bounds shrink. Use this to settle React into the target + * mode once the WebView viewport matches the compact shell. + */ + onAfterCommit?: (geometry: IslandMorphGeometry) => Promise | void; /** Prevent a delayed transition from committing after it was superseded. */ shouldCommit?: () => boolean; }; @@ -101,6 +113,32 @@ export function resolveCollapseMorphGeometry(input: { }; } +/** + * Substitui os retângulos da janela pelos retângulos do desenho, centralizados. + * + * A janela tem sempre a mesma largura em todos os modos, então os retângulos que + * saem de `resolve*MorphGeometry` descrevem a janela, não a forma visível: sem + * isto a pílula animaria com a largura inteira do painel. O eixo vertical não + * muda — a altura da janela já é a altura do desenho. + */ +export function withCenteredVisualRects( + geometry: IslandMorphGeometry, + fromVisualWidth: number, + toVisualWidth: number, +): IslandMorphGeometry { + const center = (rect: IslandRect, visualWidth: number): IslandRect => ({ + ...rect, + x: rect.x + Math.round((rect.width - visualWidth) / 2), + width: visualWidth, + }); + + return { + viewport: geometry.viewport, + from: center(geometry.from, fromVisualWidth), + to: center(geometry.to, toVisualWidth), + }; +} + export function hasMeaningfulMorph( geometry: IslandMorphGeometry, epsilon = 0.5, diff --git a/apps/desktop/src/lib/floating-quick-menu-mode.test.ts b/apps/desktop/src/lib/floating-quick-menu-mode.test.ts index e057915..d43abba 100644 --- a/apps/desktop/src/lib/floating-quick-menu-mode.test.ts +++ b/apps/desktop/src/lib/floating-quick-menu-mode.test.ts @@ -38,7 +38,7 @@ describe("floating-quick-menu-mode positions", () => { expect(plan.finalPosition).toEqual({ x: 0, y: 0 }); }); - it("resizes without a pre-move when the bar is already visible, centering on the bar", () => { + it("resizes without a pre-move when the bar is already visible, keeping the origin", () => { const plan = resolveQuickMenuExpandPosition({ currentPosition: { x: 100, y: 80 }, currentSize: { width: 140, height: 40 }, @@ -47,9 +47,9 @@ describe("floating-quick-menu-mode positions", () => { }); expect(plan.moveFirst).toBeNull(); - // Centro da barra (100 + 140/2 = 170) vira o centro do painel; o eixo - // vertical não caberia acima do topo, então só ele é grudado na borda. - expect(plan.finalPosition).toEqual({ x: 10, y: 0 }); + // A janela cresce a partir do próprio canto: nenhum dos eixos se desloca, + // e o painel ainda cabe na tela a partir de (100, 80). + expect(plan.finalPosition).toEqual({ x: 100, y: 80 }); }); it("clamps final quick-menu bounds to the monitor", () => { diff --git a/apps/desktop/src/lib/floating-quick-menu-mode.ts b/apps/desktop/src/lib/floating-quick-menu-mode.ts index 5311136..6f8f6ac 100644 --- a/apps/desktop/src/lib/floating-quick-menu-mode.ts +++ b/apps/desktop/src/lib/floating-quick-menu-mode.ts @@ -1,20 +1,25 @@ import { applyWindowBoundsImmediate, - applyWindowBoundsWithFallback, logicalToPhysical, readWindowBounds, releaseMinWindowSize, } from "@/lib/window-animation"; import { + ISLAND_EXPANDED_RADIUS_PX, resolveCollapseMorphGeometry, resolveExpandMorphGeometry, + withCenteredVisualRects, type IslandCollapseHooks, type IslandExpandHooks, type IslandMorphGeometry, type PreparedIslandWindowTransition, } from "@/lib/floating-island-transition"; import type { EdgeAnchor } from "@/lib/window-anchor"; -import { COMPACT_SIZE, QUICK_MENU_SIZE } from "@/lib/window-mode"; +import { + COMPACT_SIZE, + QUICK_MENU_SIZE, + windowSizeForVisual, +} from "@/lib/window-mode"; import { clampToMonitor, type MonitorInfo, @@ -39,6 +44,10 @@ import { resolveExpandPlan, } from "@/lib/window-transition"; import { readWorkArea } from "@/lib/window-work-area"; +import { + applyIslandMorphRegion, + applyIslandWindowRegion, +} from "@/lib/window-region"; /** * A janela não é animada: vai ao tamanho final num único `SetWindowPos` e a @@ -128,11 +137,10 @@ export async function expandFloatingToQuickMenu( (plan.moveFirst.x !== current.position.x || plan.moveFirst.y !== current.position.y) ) { - await applyWindowBoundsWithFallback( - win, - { position: plan.moveFirst, size: current.size }, - { durationMs: 180 }, - ); + await applyWindowBoundsImmediate(win, { + position: plan.moveFirst, + size: current.size, + }); } const sameSize = @@ -150,10 +158,22 @@ export async function expandFloatingToQuickMenu( position: plan.finalPosition, size: targetSize, }; - const geometry = resolveExpandMorphGeometry({ - sourceBounds, - targetBounds, + const geometry = withCenteredVisualRects( + resolveExpandMorphGeometry({ + sourceBounds, + targetBounds, + scaleFactor: scale, + }), + COMPACT_SIZE.width, + QUICK_MENU_SIZE.width, + ); + + // Região do morph: larga o bastante para o painel crescer sem recorte, mas + // ainda arredondada — sem região o Windows desenha a moldura do retângulo. + await applyIslandMorphRegion({ + maxHeight: QUICK_MENU_SIZE.height, scaleFactor: scale, + radius: ISLAND_EXPANDED_RADIUS_PX, }); await options.onPrepare?.(geometry); @@ -186,7 +206,7 @@ export async function prepareQuickMenuCollapse(): Promise { const win = getCurrentWindow(); const scale = await win.scaleFactor(); - const targetSize = logicalToPhysical(COMPACT_SIZE, scale); + const targetSize = logicalToPhysical(windowSizeForVisual(COMPACT_SIZE), scale); const current = await readWindowBounds(win); const monitorInfo = await readWorkArea(); const anchor = loadSavedAnchor() ?? undefined; @@ -209,15 +229,32 @@ export async function prepareQuickMenuCollapse(): Promise { return enqueueWindowAnimation(async () => { const win = getCurrentWindow(); - // Windows otherwise constrains SetWindowPos to the expanded minimum. - await releaseMinWindowSize(win); - await win.setResizable(false); + /* + * Recorte antes do resize, e com a escala já lida no prepare. + * + * Aqui a animação de CSS já terminou — a pílula está desenhada no tamanho + * final — então recortar na pílula não corta nada visível. Fazer isto + * depois deixava a janela encolhida e sem recorte pelo tempo do IPC + * (~160ms medidos), e é nesse retângulo cru que o Windows desenha a moldura. + * + * O mínimo e o resizable já foram soltos no prepare, então não sobra + * nenhum IPC entre o fim da animação e o SetWindowPos. + */ + await applyIslandWindowRegion({ + visual: COMPACT_SIZE, + scaleFactor: transition.scaleFactor, + radius: COMPACT_SIZE.height / 2, + }); await applyWindowBoundsImmediate(win, transition.targetBounds); clearRestoreOrigin("quick-menu"); saveSavedPosition(transition.targetBounds.position); diff --git a/apps/desktop/src/lib/island-debug.ts b/apps/desktop/src/lib/island-debug.ts new file mode 100644 index 0000000..ba5ef9b --- /dev/null +++ b/apps/desktop/src/lib/island-debug.ts @@ -0,0 +1,90 @@ +/* + * Instrumentação do morph da ilha. + * + * Ligue no DevTools do WebView2 com: + * localStorage.setItem("linvo:debug-island", "1"); location.reload(); + * Desligue com: + * localStorage.removeItem("linvo:debug-island"); location.reload(); + * + * O objetivo é separar três relógios que normalmente se confundem: + * - quando o `SetWindowPos` nativo retorna (marca `native:*`); + * - quando o WebView2 realmente entrega o viewport novo (`viewport`); + * - quando o CSS começa/termina de animar (`morph:*`). + * O flicker mora exatamente na folga entre eles. + */ + +const STORAGE_KEY = "linvo:debug-island"; + +type IslandDebugWindow = Window & { + __islandDebug?: boolean; +}; + +/* + * Lido a cada chamada, nunca memoizado: assim `__islandDebug = true` no console + * passa a valer no próximo morph, sem reload. O reload perde o estado da janela + * (modo, posição) e com ele metade dos cenários que queremos observar. + */ +export function islandDebugEnabled(): boolean { + const override = (window as IslandDebugWindow).__islandDebug; + if (typeof override === "boolean") { + return override; + } + try { + return localStorage.getItem(STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +function stamp(): string { + return `t=${performance.now().toFixed(1)}ms`; +} + +function viewport(): string { + return `vp=${window.innerWidth}x${window.innerHeight} dpr=${window.devicePixelRatio}`; +} + +export function islandLog(event: string, detail?: Record) { + if (!islandDebugEnabled()) return; + const parts = [`[island] ${event}`, stamp(), viewport()]; + if (detail) { + parts.push(JSON.stringify(detail)); + } + // eslint-disable-next-line no-console + console.log(parts.join(" | ")); +} + +/** + * Amostra o viewport a cada frame durante `durationMs`. + * + * É a sonda central: se o viewport só assume o tamanho final vários frames + * depois do `SetWindowPos` ter retornado, a janela passou esses frames maior + * que a superfície pintada pelo WebView2 — e, numa janela transparente, essa + * faixa descoberta é o flicker. + */ +export function sampleViewportFrames(label: string, durationMs: number) { + if (!islandDebugEnabled()) return; + + const start = performance.now(); + const samples: string[] = []; + let last = ""; + + const tick = () => { + const now = performance.now(); + const current = `${window.innerWidth}x${window.innerHeight}`; + if (current !== last) { + samples.push(`+${(now - start).toFixed(1)}ms ${current}`); + last = current; + } + if (now - start < durationMs) { + window.requestAnimationFrame(tick); + return; + } + // eslint-disable-next-line no-console + console.log( + `[island] viewport-timeline ${label} | frames=${samples.length} | ${samples.join(" -> ")}`, + ); + }; + + window.requestAnimationFrame(tick); +} diff --git a/apps/desktop/src/lib/window-animation.ts b/apps/desktop/src/lib/window-animation.ts index 6a2ad95..53491bd 100644 --- a/apps/desktop/src/lib/window-animation.ts +++ b/apps/desktop/src/lib/window-animation.ts @@ -5,6 +5,7 @@ import { type Window, } from "@tauri-apps/api/window"; +import { islandLog } from "@/lib/island-debug"; import type { MonitorInfo, Position, Size } from "@/lib/window-position"; export type WindowBounds = { @@ -92,6 +93,8 @@ export async function applyWindowBoundsImmediate( win: Window, to: WindowBounds, ): Promise { + const startedAt = performance.now(); + islandLog("native:setBounds:begin", { to }); try { await invoke("set_window_bounds", { to: { @@ -101,7 +104,13 @@ export async function applyWindowBoundsImmediate( height: to.size.height, }, }); + islandLog("native:setBounds:done", { + ipcMs: Number((performance.now() - startedAt).toFixed(1)), + }); } catch { + islandLog("native:setBounds:FALLBACK", { + ipcMs: Number((performance.now() - startedAt).toFixed(1)), + }); // Fora do Windows o comando cai no set_size/set_position do Tauri; se nem // isso existir (testes/web), aplica pelo próprio handle da janela. await win.setSize(new PhysicalSize(to.size.width, to.size.height)); diff --git a/apps/desktop/src/lib/window-mode.ts b/apps/desktop/src/lib/window-mode.ts index d150cab..a5c8c7f 100644 --- a/apps/desktop/src/lib/window-mode.ts +++ b/apps/desktop/src/lib/window-mode.ts @@ -1,9 +1,41 @@ import type { Size } from "@/lib/window-position"; -/** Pílula flutuante fina (Neural Premium Graphite). */ +/** + * Largura única de toda janela flutuante, em qualquer modo. + * + * A janela nunca muda de largura nem de posição horizontal: abrir o chat ou o + * checklist mexe só na altura. Isso existe porque mover e redimensionar no mesmo + * `SetWindowPos` obrigava o CSS a compensar o deslocamento, e essa compensação + * dependia de o WebView refazer o layout no mesmo frame — quando não refazia, a + * pílula saltava ~131px para o lado. Sem movimento não há o que compensar. + * + * As formas mais estreitas (pílula, checklist) são centralizadas por dentro; o + * excedente é recortado da janela por `set_window_region`, para as faixas + * transparentes não captarem cliques do desktop. + */ +export const ISLAND_WINDOW_WIDTH = 380; + +/** Pílula flutuante fina (Neural Premium Graphite): o desenho, não a janela. */ export const COMPACT_SIZE: Size = { width: 168, height: 34 }; export const CHECKLIST_SIZE: Size = { width: 288, height: 420 }; -export const QUICK_MENU_SIZE: Size = { width: 380, height: 520 }; +export const QUICK_MENU_SIZE: Size = { width: ISLAND_WINDOW_WIDTH, height: 520 }; + +/** Tamanho da janela de cada modo: largura fixa, altura do próprio desenho. */ +export function windowSizeForVisual(visual: Size): Size { + return { width: ISLAND_WINDOW_WIDTH, height: visual.height }; +} + +/** Retângulo do desenho dentro da janela, centralizado na horizontal. */ +export function centeredVisualRect( + visual: Size, +): { x: number; y: number; width: number; height: number } { + return { + x: Math.round((ISLAND_WINDOW_WIDTH - visual.width) / 2), + y: 0, + width: visual.width, + height: visual.height, + }; +} export const PANEL_SIZE: Size = { width: 1200, height: 800 }; /** Janela do onboarding: uma etapa por tela, coluna única. Não redimensionável. */ diff --git a/apps/desktop/src/lib/window-region.ts b/apps/desktop/src/lib/window-region.ts new file mode 100644 index 0000000..40d104a --- /dev/null +++ b/apps/desktop/src/lib/window-region.ts @@ -0,0 +1,90 @@ +import { invoke } from "@tauri-apps/api/core"; + +import { ISLAND_GUTTER_PX } from "@/lib/floating-island-transition"; +import { islandLog } from "@/lib/island-debug"; +import type { Size } from "@/lib/window-position"; +import { ISLAND_WINDOW_WIDTH } from "@/lib/window-mode"; + +/** + * Recorta a janela ao desenho visível. + * + * A janela flutuante tem sempre a largura do painel, mesmo compacta, para que + * abrir mude só a altura (ver `ISLAND_WINDOW_WIDTH`). As faixas transparentes + * que sobram nas laterais continuariam captando cliques destinados ao desktop — + * `SetWindowRgn` tira essas faixas da janela de verdade, no nível da HWND. + * + * Nunca lança: perder o recorte piora a área clicável, mas não justifica abortar + * a transição de janela que chamou. + */ +export async function applyIslandWindowRegion(input: { + /** Tamanho do desenho, em px lógicos. */ + visual: Size; + scaleFactor: number; + /** Metade da altura para a pílula; o raio do painel para os demais. */ + radius: number; +}): Promise { + const { visual, scaleFactor, radius } = input; + + /* + * A região tem que ficar DENTRO do que o CSS pinta, nunca fora. + * + * As superfícies são desenhadas com `--island-gutter` de recuo (ver + * `.floating-island-surface-stable`), então montar a região a partir do + * retângulo visual cru deixava uma faixa de 1-3px que a janela inclui e o CSS + * nunca pinta — e nela aparece o fundo branco padrão do WebView2, como uma + * linha clara em volta da pílula. + * + * Arredondar para dentro (ceil no início, floor no fim) mantém isso válido em + * escalas fracionárias, onde o recuo lógico não cai em pixel inteiro. + */ + const inset = ISLAND_GUTTER_PX; + const left = (ISLAND_WINDOW_WIDTH - visual.width) / 2 + inset; + const x = Math.ceil(left * scaleFactor); + const y = Math.ceil(inset * scaleFactor); + const region = { + x, + y, + width: Math.floor((visual.width - inset * 2) * scaleFactor), + height: Math.floor((visual.height - inset * 2) * scaleFactor), + }; + + // O raio também é do retângulo já recuado: usar o raio do desenho cru deixaria + // os cantos da região mais abertos que os do CSS, expondo a mesma faixa branca + // justamente nas curvas. + const radiusPx = Math.floor( + Math.min(radius, (visual.height - inset * 2) / 2) * scaleFactor, + ); + + try { + await invoke("set_window_region", { + region, + radius: Math.max(0, radiusPx), + }); + islandLog("region:apply", { region, visual, scaleFactor }); + } catch (error) { + islandLog("region:apply:FAILED", { region, error: String(error) }); + } +} + +/** + * Região usada enquanto o morph roda: largura cheia e a altura do maior dos dois + * estados, para nada ser recortado no meio da animação. + * + * Importante que continue sendo uma região arredondada, e não `null`: sem região + * a janela volta a ser um retângulo cru e o Windows desenha a moldura em volta + * dele — a barra clara que aparecia ao abrir e fechar o chat. A região excedente + * é limitada ao retângulo da janela pelo próprio sistema, então passar a altura + * expandida é seguro nos dois sentidos. + */ +export async function applyIslandMorphRegion(input: { + maxHeight: number; + scaleFactor: number; + radius: number; +}): Promise { + const { maxHeight, scaleFactor, radius } = input; + await applyIslandWindowRegion({ + visual: { width: ISLAND_WINDOW_WIDTH, height: maxHeight }, + scaleFactor, + radius, + }); +} diff --git a/apps/desktop/src/lib/window-transition.test.ts b/apps/desktop/src/lib/window-transition.test.ts index 538e1f2..3866f07 100644 --- a/apps/desktop/src/lib/window-transition.test.ts +++ b/apps/desktop/src/lib/window-transition.test.ts @@ -24,7 +24,7 @@ describe("window-transition positions", () => { expect(plan.finalPosition).toEqual({ x: 0, y: 0 }); }); - it("expands from the center when the target fits around the current bar", () => { + it("expands from its own origin when the target fits around the current bar", () => { const plan = resolveExpandPlan({ currentPosition: { x: 400, y: 300 }, currentSize: { width: 140, height: 40 }, @@ -33,10 +33,8 @@ describe("window-transition positions", () => { }); expect(plan.moveFirst).toBeNull(); - expect(plan.finalPosition).toEqual({ - x: 400 + Math.round((140 - 288) / 2), - y: 300 + Math.round((40 - 420) / 2), - }); + // Sem deslocamento: a janela cresce a partir do canto onde já estava. + expect(plan.finalPosition).toEqual({ x: 400, y: 300 }); }); it("clamps final bounds to the monitor", () => { @@ -67,7 +65,7 @@ describe("window-transition positions", () => { ).toEqual({ x: 120, y: 40 }); }); - it("collapses toward the center when current size is known and compact fits", () => { + it("collapses back to its own origin when current size is known and compact fits", () => { expect( resolveCollapsePosition({ currentPosition: { x: 400, y: 220 }, @@ -75,10 +73,7 @@ describe("window-transition positions", () => { targetSize: { width: 168, height: 34 }, monitor, }), - ).toEqual({ - x: 400 + Math.round((380 - 168) / 2), - y: 220 + Math.round((520 - 34) / 2), - }); + ).toEqual({ x: 400, y: 220 }); }); it("recomputes top-center collapse position when compact no longer fits", () => { @@ -137,7 +132,7 @@ describe("window-transition positions", () => { expect(position.x).toBe(1920 - 168); }); - it("centers horizontally under a top-docked bar instead of opening to one side", () => { + it("keeps the origin under a top-docked bar while staying flush to the top", () => { const barPosition = { x: 876, y: 0 }; const barSize = { width: 168, height: 34 }; const panelSize = { width: 380, height: 520 }; @@ -151,11 +146,8 @@ describe("window-transition positions", () => { anchor, }); - // Cresce para baixo colado no topo, mas centralizado no eixo livre. - expect(plan.finalPosition).toEqual({ - x: 876 + Math.round((168 - 380) / 2), - y: 0, - }); + // Cresce para baixo colado no topo; o eixo livre não se desloca. + expect(plan.finalPosition).toEqual({ x: 876, y: 0 }); }); it("collapses a top-docked panel back to the exact bar position", () => { @@ -205,7 +197,7 @@ describe("window-transition positions", () => { ).toEqual(barPosition); }); - it("ignores anchor when it is empty on both axes and grows from center", () => { + it("ignores anchor when it is empty on both axes and grows from the origin", () => { const plan = resolveExpandPlan({ currentPosition: { x: 400, y: 300 }, currentSize: { width: 140, height: 40 }, @@ -214,9 +206,6 @@ describe("window-transition positions", () => { anchor: { horizontal: null, vertical: null }, }); - expect(plan.finalPosition).toEqual({ - x: 400 + Math.round((140 - 288) / 2), - y: 300 + Math.round((40 - 420) / 2), - }); + expect(plan.finalPosition).toEqual({ x: 400, y: 300 }); }); }); diff --git a/apps/desktop/src/lib/window-transition.ts b/apps/desktop/src/lib/window-transition.ts index 1d94684..aa8408e 100644 --- a/apps/desktop/src/lib/window-transition.ts +++ b/apps/desktop/src/lib/window-transition.ts @@ -12,36 +12,34 @@ import { import { EDGE_MARGIN } from "@/lib/window-storage"; /** - * Redimensiona mantendo o centro, eixo por eixo. + * Redimensiona preservando a origem da janela. * - * Duas regras importantes, ambas por eixo e nunca "tudo ou nada": + * A janela cresce e encolhe a partir do próprio canto superior esquerdo: o + * painel abre alinhado à borda esquerda da pílula, não centralizado nela. * - * 1. No eixo ancorado a janela cola na borda; no eixo livre ela cresce/encolhe - * a partir do centro. `applyAnchor` sozinho deixava o eixo livre parado na - * posição anterior — com a barra no topo, o painel (mais largo que a pílula) - * abria todo para a direita em vez de centralizado sob ela. - * 2. O clamp é aplicado por eixo no fim. A versão anterior descartava a - * centralização inteira quando o resultado não caberia na tela, então perto - * do topo o eixo horizontal também perdia o alinhamento. + * Centralizar exigia mover a janela no mesmo `SetWindowPos` que a redimensiona + * (132px físicos para a esquerda, medidos), e a ilha só ficava parada na tela + * porque o CSS cancelava esse deslocamento via `left: calc(% - px)`. Essa + * compensação depende de o WebView refazer o layout no mesmo frame do resize; + * quando ele apresentava um frame com o layout antigo já na posição nova, a + * pílula saltava ~131px para o lado. Sem movimento não há o que compensar, e a + * classe inteira desse artefato desaparece. * - * Expand e collapse usam esta mesma função, o que as torna inversas exatas — - * é o que evita a pílula voltar num lugar diferente ao fechar o chat. + * O clamp continua por eixo no fim, e expand e collapse seguem usando esta + * mesma função — o que as mantém inversas exatas, e é o que faz a pílula voltar + * exatamente de onde saiu. */ -function resizeAroundCenter(input: { +function resizeFromOrigin(input: { currentPosition: Position; currentSize: Size; targetSize: Size; monitor: MonitorInfo; anchor?: EdgeAnchor; }): Position { - const { currentPosition, currentSize, targetSize, monitor, anchor } = input; + const { currentPosition, targetSize, monitor, anchor } = input; - let x = Math.round( - currentPosition.x + (currentSize.width - targetSize.width) / 2, - ); - let y = Math.round( - currentPosition.y + (currentSize.height - targetSize.height) / 2, - ); + let x = currentPosition.x; + let y = currentPosition.y; if (anchor?.horizontal === "left") { x = monitor.position.x; @@ -82,7 +80,7 @@ export function resolveExpandPlan(input: { : clampToMonitor(currentPosition, currentSize, monitor); const basePosition = moveFirst ?? currentPosition; - const finalPosition = resizeAroundCenter({ + const finalPosition = resizeFromOrigin({ currentPosition: basePosition, currentSize, targetSize, @@ -106,7 +104,7 @@ export function resolveCollapsePosition(input: { } if (currentSize) { // Mesma regra do expand — garante que a pílula volte exatamente de onde saiu. - return resizeAroundCenter({ + return resizeFromOrigin({ currentPosition, currentSize, targetSize, From 1762b23bc84d0bd14f8a29dfa62099e7c794df01 Mon Sep 17 00:00:00 2001 From: Renan Mello Date: Wed, 19 Aug 2026 17:56:12 -0300 Subject: [PATCH 2/2] fix(desktop): corrige testes quebrados pelo morph da ilha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consegui rodar a suíte pelo lado Windows (`cmd.exe /c npx vitest run`) — o node_modules tem os binários win32 do rollup, que é o motivo de ela falhar no WSL. Com isso as falhas do CI ficaram visíveis: de 32 para 5. - `islandDebugEnabled` assumia `window`. A lane `unit` roda em Node e chama as funções de janela com os mocks do Tauri, então a instrumentação derrubava o teste inteiro. Agora tem guarda. - `onScaleChanged` faltava no mock do Tauri, e o efeito novo do BarApp quebrava na montagem. - O teste de `transitionend` checava o commit na hora, mas ele passou a ser adiado um frame; agora espera. - Expectativas de posição em `floating-compact-bounds` e `enter-floating-mode` ainda descreviam centralização e janela de 168px. Restam 5 falhas em `BarApp.test.tsx`, todas do mesmo tipo: asserções sobre `set_window_bounds`/`animate_window_bounds` que agora também veem chamadas de `set_window_region`. Co-Authored-By: Claude Opus 5 --- .../components/floating-island-shell.test.tsx | 9 +++++--- .../src/lib/auth/enter-floating-mode.test.ts | 3 ++- .../src/lib/floating-compact-bounds.test.ts | 23 +++++++++++-------- apps/desktop/src/lib/island-debug.ts | 7 ++++++ apps/desktop/src/test/mocks/tauri.ts | 1 + 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/floating-island-shell.test.tsx b/apps/desktop/src/components/floating-island-shell.test.tsx index 17e831e..6ce04b4 100644 --- a/apps/desktop/src/components/floating-island-shell.test.tsx +++ b/apps/desktop/src/components/floating-island-shell.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { @@ -13,7 +13,7 @@ const geometry: FloatingIslandMorph["geometry"] = { }; describe("FloatingIslandShell", () => { - it("keeps transition content inert and completes from transform transitionend", () => { + it("keeps transition content inert and completes from transform transitionend", async () => { const onMorphComplete = vi.fn(); const morph: FloatingIslandMorph = { id: 1, @@ -42,7 +42,10 @@ describe("FloatingIslandShell", () => { fireEvent.transitionEnd(screen.getByTestId("floating-island-surface"), { propertyName: "transform", }); - expect(onMorphComplete).toHaveBeenCalledWith(1); + + // O commit é adiado um frame para o `SetWindowPos` do colapso não cair no + // mesmo frame em que o CSS assenta — daí esperar em vez de checar na hora. + await waitFor(() => expect(onMorphComplete).toHaveBeenCalledWith(1)); }); /* diff --git a/apps/desktop/src/lib/auth/enter-floating-mode.test.ts b/apps/desktop/src/lib/auth/enter-floating-mode.test.ts index a5a9f8f..083475d 100644 --- a/apps/desktop/src/lib/auth/enter-floating-mode.test.ts +++ b/apps/desktop/src/lib/auth/enter-floating-mode.test.ts @@ -33,7 +33,8 @@ describe("enterFloatingMode", () => { expect(invokeMock).toHaveBeenCalledWith( "animate_window_bounds", expect.objectContaining({ - to: expect.objectContaining({ width: 168, height: 34 }), + // Largura da janela (fixa em todos os modos), altura da pílula. + to: expect.objectContaining({ width: 380, height: 34 }), }), ); expect(setSizeMock).toHaveBeenCalled(); diff --git a/apps/desktop/src/lib/floating-compact-bounds.test.ts b/apps/desktop/src/lib/floating-compact-bounds.test.ts index 50b8d21..661449b 100644 --- a/apps/desktop/src/lib/floating-compact-bounds.test.ts +++ b/apps/desktop/src/lib/floating-compact-bounds.test.ts @@ -4,7 +4,11 @@ import { ensureCompactWindowBounds, isCompactWindowSize, } from "@/lib/floating-compact-bounds"; -import { COMPACT_SIZE, QUICK_MENU_SIZE } from "@/lib/window-mode"; +import { + COMPACT_SIZE, + QUICK_MENU_SIZE, + windowSizeForVisual, +} from "@/lib/window-mode"; import { loadSavedPosition } from "@/lib/window-storage"; import { invokeMock, @@ -52,7 +56,7 @@ describe("ensureCompactWindowBounds", () => { windowMock.scaleFactor.mockResolvedValue(1); mockBounds({ position: { x: 0, y: 0 }, - size: { ...COMPACT_SIZE }, + size: windowSizeForVisual(COMPACT_SIZE), }); }); @@ -61,7 +65,7 @@ describe("ensureCompactWindowBounds", () => { expect(boundsCalls()).toHaveLength(0); }); - it("shrinks a window left expanded, centering the pill on the panel", async () => { + it("shrinks a window left expanded, keeping the window origin", async () => { mockBounds({ position: { x: 400, y: 200 }, size: { ...QUICK_MENU_SIZE }, @@ -69,14 +73,16 @@ describe("ensureCompactWindowBounds", () => { await expect(ensureCompactWindowBounds()).resolves.toBe(true); + // Sem deslocamento: encolher preserva o canto, e a largura da janela é a + // mesma nos dois modos — só a altura muda. expect(boundsCalls()).toEqual([ [ "set_window_bounds", { to: { - x: 400 + Math.round((QUICK_MENU_SIZE.width - COMPACT_SIZE.width) / 2), - y: 200 + Math.round((QUICK_MENU_SIZE.height - COMPACT_SIZE.height) / 2), - width: COMPACT_SIZE.width, + x: 400, + y: 200, + width: windowSizeForVisual(COMPACT_SIZE).width, height: COMPACT_SIZE.height, }, }, @@ -136,9 +142,6 @@ describe("ensureCompactWindowBounds", () => { await ensureCompactWindowBounds(); - expect(loadSavedPosition()).toEqual({ - x: 400 + Math.round((QUICK_MENU_SIZE.width - COMPACT_SIZE.width) / 2), - y: 200 + Math.round((QUICK_MENU_SIZE.height - COMPACT_SIZE.height) / 2), - }); + expect(loadSavedPosition()).toEqual({ x: 400, y: 200 }); }); }); diff --git a/apps/desktop/src/lib/island-debug.ts b/apps/desktop/src/lib/island-debug.ts index ba5ef9b..2db4098 100644 --- a/apps/desktop/src/lib/island-debug.ts +++ b/apps/desktop/src/lib/island-debug.ts @@ -25,6 +25,13 @@ type IslandDebugWindow = Window & { * (modo, posição) e com ele metade dos cenários que queremos observar. */ export function islandDebugEnabled(): boolean { + // A lane `unit` roda em Node, sem `window`: as funções de janela são chamadas + // de lá com os mocks do Tauri, e sem esta guarda a instrumentação derruba o + // teste inteiro. + if (typeof window === "undefined") { + return false; + } + const override = (window as IslandDebugWindow).__islandDebug; if (typeof override === "boolean") { return override; diff --git a/apps/desktop/src/test/mocks/tauri.ts b/apps/desktop/src/test/mocks/tauri.ts index 84a8773..3d216ce 100644 --- a/apps/desktop/src/test/mocks/tauri.ts +++ b/apps/desktop/src/test/mocks/tauri.ts @@ -44,6 +44,7 @@ function createWindowMock(label: string) { onResized: vi.fn(() => Promise.resolve(() => {})), onCloseRequested: vi.fn(() => Promise.resolve(() => {})), onFocusChanged: vi.fn(() => Promise.resolve(() => {})), + onScaleChanged: vi.fn(() => Promise.resolve(() => {})), outerPosition: vi.fn(() => Promise.resolve({ x: 0, y: 0 })), outerSize: vi.fn(() => Promise.resolve({ width: 140, height: 40 })), scaleFactor: vi.fn(() => Promise.resolve(1)),