From 0ff95c8a13a239c58531c284f8e5b7086f6a5e6c Mon Sep 17 00:00:00 2001 From: Lulu Date: Mon, 27 Jul 2026 13:52:10 +0800 Subject: [PATCH 01/11] fix(desktop): harden Island drag, content-side settle, and shell outline Co-authored-by: Cursor --- apps/desktop/scripts/verify-island-flip.ts | 107 +++++++++++++++++ apps/desktop/src/main/ipc.ts | 25 ++++ .../src/main/windows/island-content-side.ts | 110 ++++++++++++++++++ apps/desktop/src/main/windows/island.ts | 77 ++++++------ apps/desktop/src/preload/index.ts | 11 +- apps/desktop/src/renderer/IslandApp.tsx | 55 +++++++-- apps/desktop/src/shared/ipc.ts | 11 +- .../ui/src/components/product/IslandBar.tsx | 69 ++++++++--- packages/ui/src/theme.css | 13 +-- 9 files changed, 403 insertions(+), 75 deletions(-) create mode 100644 apps/desktop/scripts/verify-island-flip.ts create mode 100644 apps/desktop/src/main/windows/island-content-side.ts diff --git a/apps/desktop/scripts/verify-island-flip.ts b/apps/desktop/scripts/verify-island-flip.ts new file mode 100644 index 0000000..babe251 --- /dev/null +++ b/apps/desktop/scripts/verify-island-flip.ts @@ -0,0 +1,107 @@ +/** + * Agent-runnable flip harness using the user's logged dual-monitor geometry. + * Run: pnpm exec tsx apps/desktop/scripts/verify-island-flip.ts + */ +import { + decideIslandContentSide, + menuSideForContentSide, + type Rect, +} from '../src/main/windows/island-content-side' + +/** From logged dual-monitor geometry (unequal vertical stack). */ +const DISPLAYS = [ + { id: 1, workArea: { x: 0, y: 34, width: 1710, height: 1073 } }, + { id: 3, workArea: { x: -409, y: -1410, width: 2560, height: 1348 } }, +] + +const WINDOW = { width: 420, height: 723 } + +function placeOnDisplay( + displayWorkArea: Rect, + vertical: 'upper' | 'lower', +): Rect { + const x = displayWorkArea.x + displayWorkArea.width - WINDOW.width - 24 + const y = + vertical === 'upper' + ? displayWorkArea.y + 8 + : displayWorkArea.y + displayWorkArea.height - WINDOW.height - 8 + return { x, y, width: WINDOW.width, height: WINDOW.height } +} + +type Case = { + name: string + bounds: Rect + currentSide: 'above' | 'below' + expect: 'above' | 'below' +} + +const cases: Case[] = [ + { + name: 'lower screen upper half → content below bar', + bounds: placeOnDisplay(DISPLAYS[0]!.workArea, 'upper'), + currentSide: 'above', + expect: 'below', + }, + { + name: 'lower screen lower half → content above bar', + bounds: placeOnDisplay(DISPLAYS[0]!.workArea, 'lower'), + currentSide: 'below', + expect: 'above', + }, + { + name: 'upper screen upper half → content below bar', + bounds: placeOnDisplay(DISPLAYS[1]!.workArea, 'upper'), + currentSide: 'above', + expect: 'below', + }, + { + name: 'upper screen lower half → content above bar', + bounds: placeOnDisplay(DISPLAYS[1]!.workArea, 'lower'), + currentSide: 'below', + expect: 'above', + }, + { + name: 'lower screen upper half stays below when already below', + bounds: placeOnDisplay(DISPLAYS[0]!.workArea, 'upper'), + currentSide: 'below', + expect: 'below', + }, + { + name: 'lower screen lower half stays above when already above', + bounds: placeOnDisplay(DISPLAYS[0]!.workArea, 'lower'), + currentSide: 'above', + expect: 'above', + }, +] + +let failed = 0 +for (const item of cases) { + const result = decideIslandContentSide({ + bounds: item.bounds, + currentSide: item.currentSide, + displays: DISPLAYS, + }) + const menu = menuSideForContentSide(result.desired) + const ok = result.desired === item.expect + if (!ok) failed += 1 + console.log( + `${ok ? 'PASS' : 'FAIL'} ${item.name}\n` + + ` bounds.y=${item.bounds.y} current=${item.currentSide} → ${result.desired} (expect ${item.expect})\n` + + ` display=${result.displayId} probeY=${result.probeY.toFixed(1)} mid=${result.midpoint} barY=${result.islandBarY} menu=${menu}`, + ) +} + +// Menu must match content side (single value). +for (const side of ['above', 'below'] as const) { + const menu = menuSideForContentSide(side) + const expect = side === 'above' ? 'top' : 'bottom' + const ok = menu === expect + if (!ok) failed += 1 + console.log(`${ok ? 'PASS' : 'FAIL'} menu mapping ${side} → ${menu} (expect ${expect})`) +} + +if (failed > 0) { + console.error(`\n${failed} failure(s)`) + process.exit(1) +} +console.log('\nAll island flip cases passed.') diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 9f16bfc..209938e 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -27,6 +27,8 @@ import { openOnboardingWindow, resizeOnboardingWindow, } from './windows/onboarding' +import { settleIslandContentSide } from './windows/island' +import type { IslandContentSide } from '../shared/ipc' function broadcastAuthChanged(sourceWebContentsId: number): void { for (const window of BrowserWindow.getAllWindows()) { @@ -82,6 +84,16 @@ export function registerIpcHandlers(params: { ipcMain.handle(IPC_CHANNEL.systemAudioStop, () => stopSystemAudioCapture()) ipcMain.handle(IPC_CHANNEL.islandGetContentSide, () => readConfig().islandContentSide) + ipcMain.handle( + IPC_CHANNEL.islandSettleContentSide, + (_event, current: IslandContentSide) => { + const window = params.getIslandWindow() + if (!window || (current !== 'above' && current !== 'below')) { + return readConfig().islandContentSide + } + return settleIslandContentSide(window, current) + }, + ) ipcMain.handle(IPC_CHANNEL.islandHide, () => params.getIslandWindow()?.hide()) ipcMain.handle(IPC_CHANNEL.islandShow, () => { params.getIslandWindow()?.show() @@ -90,6 +102,19 @@ export function registerIpcHandlers(params: { ipcMain.handle(IPC_CHANNEL.islandSetPointerThrough, (_event, ignored: boolean) => { params.getIslandWindow()?.setIgnoreMouseEvents(ignored, { forward: true }) }) + ipcMain.handle(IPC_CHANNEL.islandGetBounds, () => { + const window = params.getIslandWindow() + if (!window) return { x: 0, y: 0, width: 0, height: 0 } + return window.getBounds() + }) + ipcMain.handle(IPC_CHANNEL.islandSetPosition, (_event, x: number, y: number) => { + const window = params.getIslandWindow() as + | (BrowserWindow & { __scheduleMoveSettled?: () => void }) + | null + if (!window || !Number.isFinite(x) || !Number.isFinite(y)) return + window.setPosition(Math.round(x), Math.round(y)) + window.__scheduleMoveSettled?.() + }) ipcMain.handle(IPC_CHANNEL.sessionUpdateState, (_event, state: DesktopSessionState) => { params.onSessionState(state) diff --git a/apps/desktop/src/main/windows/island-content-side.ts b/apps/desktop/src/main/windows/island-content-side.ts new file mode 100644 index 0000000..1ae4dee --- /dev/null +++ b/apps/desktop/src/main/windows/island-content-side.ts @@ -0,0 +1,110 @@ +import type { IslandContentSide } from '../../shared/ipc' + +export type Rect = { x: number; y: number; width: number; height: number } + +export const ISLAND_CENTER_OFFSET = 29 +export const FLIP_HYSTERESIS = 48 + +export function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(Math.max(value, minimum), maximum) +} + +export function rectIntersectionArea(a: Rect, b: Rect): number { + const left = Math.max(a.x, b.x) + const right = Math.min(a.x + a.width, b.x + b.width) + const top = Math.max(a.y, b.y) + const bottom = Math.min(a.y + a.height, b.y + b.height) + const width = right - left + const height = bottom - top + return width > 0 && height > 0 ? width * height : 0 +} + +/** Display whose workArea intersects the window most (unequal dual monitors). */ +export function pickDisplayWorkArea( + bounds: Rect, + displays: Array<{ id: number; workArea: Rect }>, +): { id: number; workArea: Rect } { + let best = displays[0] + if (!best) throw new Error('no displays') + let bestArea = -1 + for (const display of displays) { + const area = rectIntersectionArea(bounds, display.workArea) + if (area > bestArea) { + best = display + bestArea = area + } + } + if (bestArea > 0) return best + const centerX = bounds.x + bounds.width / 2 + const centerY = bounds.y + bounds.height / 2 + let nearest = best + let nearestDistance = Number.POSITIVE_INFINITY + for (const display of displays) { + const area = display.workArea + const clampedX = clamp(centerX, area.x, area.x + area.width) + const clampedY = clamp(centerY, area.y, area.y + area.height) + const distance = (clampedX - centerX) ** 2 + (clampedY - centerY) ** 2 + if (distance < nearestDistance) { + nearest = display + nearestDistance = distance + } + } + return nearest +} + +export function islandBarYForSide(bounds: Rect, side: IslandContentSide): number { + return side === 'above' + ? bounds.y + bounds.height - ISLAND_CENTER_OFFSET + : bounds.y + ISLAND_CENTER_OFFSET +} + +/** + * Side-independent probe: visible overlap center on the chosen display. + * Falls back to window center when there is no overlap. + */ +export function probeYForFlip(bounds: Rect, workArea: Rect): number { + const left = Math.max(bounds.x, workArea.x) + const right = Math.min(bounds.x + bounds.width, workArea.x + workArea.width) + const top = Math.max(bounds.y, workArea.y) + const bottom = Math.min(bounds.y + bounds.height, workArea.y + workArea.height) + if (right > left && bottom > top) return (top + bottom) / 2 + return bounds.y + bounds.height / 2 +} + +export function decideIslandContentSide(input: { + bounds: Rect + currentSide: IslandContentSide + displays: Array<{ id: number; workArea: Rect }> +}): { + desired: IslandContentSide + displayId: number + workArea: Rect + midpoint: number + probeY: number + islandBarY: number +} { + const display = pickDisplayWorkArea(input.bounds, input.displays) + const workArea = display.workArea + const midpoint = workArea.y + workArea.height / 2 + const probeY = probeYForFlip(input.bounds, workArea) + const islandBarY = islandBarYForSide(input.bounds, input.currentSide) + const desired = + probeY < midpoint - FLIP_HYSTERESIS + ? 'below' + : probeY > midpoint + FLIP_HYSTERESIS + ? 'above' + : input.currentSide + return { + desired, + displayId: display.id, + workArea, + midpoint, + probeY, + islandBarY, + } +} + +/** Menu open direction locked to content side (no second heuristic). */ +export function menuSideForContentSide(contentSide: IslandContentSide): 'top' | 'bottom' { + return contentSide === 'above' ? 'top' : 'bottom' +} diff --git a/apps/desktop/src/main/windows/island.ts b/apps/desktop/src/main/windows/island.ts index a7c7e4f..de7714e 100644 --- a/apps/desktop/src/main/windows/island.ts +++ b/apps/desktop/src/main/windows/island.ts @@ -1,10 +1,11 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { BrowserWindow, screen, type Display, type Rectangle } from 'electron' -import { IPC_CHANNEL } from '../../shared/ipc' +import { IPC_CHANNEL, type IslandContentSide } from '../../shared/ipc' import { loadRendererEntry } from '../location' import { readConfig, updateConfig } from '../config' import { protectPrivilegedWindowNavigation, transparentWindowConfig } from './shared' +import { decideIslandContentSide } from './island-content-side' const isMacOS = process.platform === 'darwin' const mainDirname = dirname(fileURLToPath(import.meta.url)) @@ -15,8 +16,11 @@ const MIN_WIDTH = 360 const MIN_HEIGHT = 420 const MAX_WIDTH = 680 const MARGIN = 24 -const ISLAND_CENTER_OFFSET = 29 -const FLIP_HYSTERESIS = 32 + +type IslandBrowserWindow = BrowserWindow & { + __scheduleMoveSettled?: () => void + __suppressMoveSettle?: boolean +} function clamp(value: number, minimum: number, maximum: number): number { return Math.min(Math.max(value, minimum), maximum) @@ -49,6 +53,29 @@ function boundsInsideDisplay(bounds: Rectangle, display: Display): Rectangle { } } +/** + * Compute content side from the renderer's current value + window geometry. + * The renderer owns the live value; main only persists. + */ +export function settleIslandContentSide( + window: BrowserWindow, + currentSide: IslandContentSide, +): IslandContentSide { + const boundsNow = window.getBounds() + const displays = screen.getAllDisplays().map((item) => ({ + id: item.id, + workArea: item.workArea, + })) + const { desired } = decideIslandContentSide({ + bounds: boundsNow, + currentSide, + displays, + }) + if (desired === currentSide) return currentSide + updateConfig({ islandContentSide: desired }) + return desired +} + /** AIRI-style transparent, always-on-top, edge-resizable floating window. */ export async function createIslandWindow(): Promise { const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) @@ -70,9 +97,8 @@ export async function createIslandWindow(): Promise { sandbox: false, }, ...transparentWindowConfig(), - }) + }) as IslandBrowserWindow - let contentSide = config.islandContentSide let moveSettledTimer: ReturnType | null = null function persistBounds() { @@ -87,47 +113,24 @@ export async function createIslandWindow(): Promise { }) } - function settleVerticalFlip() { - const boundsNow = window.getBounds() - const display = screen.getDisplayMatching(boundsNow) - const midpoint = display.workArea.y + display.workArea.height / 2 - const islandCenter = - contentSide === 'above' - ? boundsNow.y + boundsNow.height - ISLAND_CENTER_OFFSET - : boundsNow.y + ISLAND_CENTER_OFFSET - const desired = - islandCenter < midpoint - FLIP_HYSTERESIS - ? 'below' - : islandCenter > midpoint + FLIP_HYSTERESIS - ? 'above' - : contentSide - if (desired === contentSide) return - - const nextY = - desired === 'below' - ? islandCenter - ISLAND_CENTER_OFFSET - : islandCenter - boundsNow.height + ISLAND_CENTER_OFFSET - const clampedY = clamp( - Math.round(nextY), - display.workArea.y, - display.workArea.y + display.workArea.height - boundsNow.height, - ) - contentSide = desired - updateConfig({ islandContentSide: desired }) - window.setPosition(boundsNow.x, clampedY) - window.webContents.send(IPC_CHANNEL.islandContentSideChanged, desired) - } - function scheduleMoveSettled() { + if (window.__suppressMoveSettle) return persistBounds() if (moveSettledTimer) clearTimeout(moveSettledTimer) - moveSettledTimer = setTimeout(settleVerticalFlip, 140) + // Renderer owns contentSide — notify it to settle with its single value. + moveSettledTimer = setTimeout(() => { + if (window.isDestroyed()) return + window.webContents.send(IPC_CHANNEL.islandMoveSettledEvent) + }, 140) } + window.__scheduleMoveSettled = scheduleMoveSettled window.on('move', scheduleMoveSettled) + window.on('moved', scheduleMoveSettled) window.on('resize', persistBounds) window.on('closed', () => { if (moveSettledTimer) clearTimeout(moveSettledTimer) + delete window.__scheduleMoveSettled }) window.setAlwaysOnTop(true, 'screen-saver', 1) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b9202fc..a04c234 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -38,13 +38,16 @@ const api: KiboTalkDesktopApi = { }, island: { getContentSide: () => ipcRenderer.invoke(IPC_CHANNEL.islandGetContentSide), + settleContentSide: (current) => ipcRenderer.invoke(IPC_CHANNEL.islandSettleContentSide, current), hide: () => ipcRenderer.invoke(IPC_CHANNEL.islandHide), show: () => ipcRenderer.invoke(IPC_CHANNEL.islandShow), setPointerThrough: (ignored) => ipcRenderer.invoke(IPC_CHANNEL.islandSetPointerThrough, ignored), - onContentSideChanged: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, side: Parameters[0]) => callback(side) - ipcRenderer.on(IPC_CHANNEL.islandContentSideChanged, listener) - return () => ipcRenderer.removeListener(IPC_CHANNEL.islandContentSideChanged, listener) + getBounds: () => ipcRenderer.invoke(IPC_CHANNEL.islandGetBounds), + setPosition: (x, y) => ipcRenderer.invoke(IPC_CHANNEL.islandSetPosition, x, y), + onMoveSettled: (callback) => { + const listener = () => callback() + ipcRenderer.on(IPC_CHANNEL.islandMoveSettledEvent, listener) + return () => ipcRenderer.removeListener(IPC_CHANNEL.islandMoveSettledEvent, listener) }, }, session: { diff --git a/apps/desktop/src/renderer/IslandApp.tsx b/apps/desktop/src/renderer/IslandApp.tsx index 1dc9cfd..0098311 100644 --- a/apps/desktop/src/renderer/IslandApp.tsx +++ b/apps/desktop/src/renderer/IslandApp.tsx @@ -46,6 +46,8 @@ function ReadyIsland({ }) { const { language } = useI18n() const [contentSide, setContentSide] = useState('above') + const contentSideRef = useRef(contentSide) + contentSideRef.current = contentSide const snapshot = createSessionSnapshot(prefs) const controller = useProductSession({ languageSnapshot: snapshot, @@ -65,8 +67,14 @@ function ReadyIsland({ }, []) useEffect(() => { - void window.kibotalk.island.getContentSide().then(setContentSide) - return window.kibotalk.island.onContentSideChanged(setContentSide) + void window.kibotalk.island.getContentSide().then((side) => { + setContentSide(side) + }) + return window.kibotalk.island.onMoveSettled(() => { + void window.kibotalk.island.settleContentSide(contentSideRef.current).then((next) => { + setContentSide(next) + }) + }) }, []) useEffect(() => { @@ -122,6 +130,8 @@ function ReadyIsland({ useEffect(() => { let ignored = false + let resizing = false + let resizeIdleTimer: number | null = null const setOutlineActive = (active: boolean) => { document .querySelector('.island-window-shell') @@ -129,32 +139,57 @@ function ReadyIsland({ } const handlePointerMove = (event: MouseEvent) => { const edge = - event.clientX <= 8 || - event.clientY <= 8 || - event.clientX >= window.innerWidth - 8 || - event.clientY >= window.innerHeight - 8 + event.clientX <= 12 || + event.clientY <= 12 || + event.clientX >= window.innerWidth - 12 || + event.clientY >= window.innerHeight - 12 const target = document.elementFromPoint(event.clientX, event.clientY) + const overlayOpen = !!( + document.querySelector('[role="dialog"]') + || document.querySelector('[role="menu"]') + ) const interactive = - !!document.querySelector('[role="dialog"]') || - (target instanceof Element && - !!target.closest('.desktop-interactive, [role="dialog"], [role="menu"]')) - setOutlineActive(edge || interactive) + overlayOpen + || (target instanceof Element + && !!target.closest( + '.desktop-interactive, [role="dialog"], [role="menu"], [role="tooltip"], [data-radix-popper-content-wrapper], [data-island-drag-handle]', + )) + // Keep yellow outline sticky while resizing so it does not flicker off + // when the OS briefly leaves the edge hit zone. + setOutlineActive(edge || interactive || overlayOpen || resizing) const nextIgnored = !edge && !interactive if (nextIgnored === ignored) return ignored = nextIgnored void window.kibotalk.island.setPointerThrough(ignored) } const handlePointerLeave = () => { + if (resizing) return setOutlineActive(false) if (ignored) return ignored = true void window.kibotalk.island.setPointerThrough(true) } + const handleResize = () => { + resizing = true + setOutlineActive(true) + if (ignored) { + ignored = false + void window.kibotalk.island.setPointerThrough(false) + } + if (resizeIdleTimer !== null) window.clearTimeout(resizeIdleTimer) + resizeIdleTimer = window.setTimeout(() => { + resizing = false + resizeIdleTimer = null + }, 250) + } window.addEventListener('mousemove', handlePointerMove) + window.addEventListener('resize', handleResize) document.documentElement.addEventListener('mouseleave', handlePointerLeave) return () => { window.removeEventListener('mousemove', handlePointerMove) + window.removeEventListener('resize', handleResize) document.documentElement.removeEventListener('mouseleave', handlePointerLeave) + if (resizeIdleTimer !== null) window.clearTimeout(resizeIdleTimer) setOutlineActive(false) void window.kibotalk.island.setPointerThrough(false) } diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 2b52900..c1a17cf 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -43,10 +43,13 @@ export const IPC_CHANNEL = { systemAudioStart: 'system-audio:start', systemAudioStop: 'system-audio:stop', islandGetContentSide: 'island:get-content-side', - islandContentSideChanged: 'island:content-side-changed', + islandSettleContentSide: 'island:settle-content-side', + islandMoveSettledEvent: 'island:move-settled', islandHide: 'island:hide', islandShow: 'island:show', islandSetPointerThrough: 'island:set-pointer-through', + islandGetBounds: 'island:get-bounds', + islandSetPosition: 'island:set-position', sessionUpdateState: 'session:update-state', sessionCommandEvent: 'session:command', appSetLaunchAtLogin: 'app:set-launch-at-login', @@ -94,10 +97,14 @@ export type KiboTalkDesktopApi = { } island: { getContentSide: () => Promise + /** Single source of truth stays in the renderer; main only computes + persists. */ + settleContentSide: (current: IslandContentSide) => Promise hide: () => Promise show: () => Promise setPointerThrough: (ignored: boolean) => Promise - onContentSideChanged: (callback: (side: IslandContentSide) => void) => () => void + getBounds: () => Promise<{ x: number; y: number; width: number; height: number }> + setPosition: (x: number, y: number) => Promise + onMoveSettled: (callback: () => void) => () => void } session: { updateState: (state: DesktopSessionState) => Promise diff --git a/packages/ui/src/components/product/IslandBar.tsx b/packages/ui/src/components/product/IslandBar.tsx index 9f08113..91c3beb 100644 --- a/packages/ui/src/components/product/IslandBar.tsx +++ b/packages/ui/src/components/product/IslandBar.tsx @@ -10,7 +10,7 @@ export type IslandBarProps = React.HTMLAttributes * holding a status readout, state toggles, and one-shot nav buttons. * * The bar itself is not draggable: movement is intentionally limited to the - * explicit four-way-arrow handle. + * explicit four-way-arrow handle (`IslandDragHandle`). */ export function IslandBar({ className, children, ...props }: IslandBarProps) { return ( @@ -70,23 +70,62 @@ export function IslandSeparator() { } /** - * Dedicated native window drag region, matching AIRI's four-way-arrow - * movement affordance. + * Dedicated move affordance (four-way arrow). Desktop uses pointer-capture + + * IPC window positioning because `-webkit-app-region: drag` is unreliable on + * the blurred Island bar and conflicts with click-through / tooltips. */ export function IslandDragHandle({ label = '拖动可移动悬浮窗' }: { label?: string }) { return ( - - -
- -
-
- {label} -
+
{ + if (event.button !== 0) return + const el = event.currentTarget + const startScreenX = event.screenX + const startScreenY = event.screenY + const desktop = ( + window as unknown as { + kibotalk?: { + island?: { + getBounds?: () => Promise<{ x: number; y: number; width: number; height: number }> + setPosition?: (x: number, y: number) => Promise + setPointerThrough?: (ignored: boolean) => Promise + } + } + } + ).kibotalk?.island + if (!desktop?.getBounds || !desktop?.setPosition) return + event.preventDefault() + void desktop.setPointerThrough?.(false) + void desktop.getBounds().then((bounds) => { + const originX = bounds.x + const originY = bounds.y + const onMove = (moveEvent: PointerEvent) => { + void desktop.setPosition?.( + originX + moveEvent.screenX - startScreenX, + originY + moveEvent.screenY - startScreenY, + ) + } + const onUp = () => { + el.releasePointerCapture(event.pointerId) + el.removeEventListener('pointermove', onMove) + el.removeEventListener('pointerup', onUp) + el.removeEventListener('pointercancel', onUp) + } + el.setPointerCapture(event.pointerId) + el.addEventListener('pointermove', onMove) + el.addEventListener('pointerup', onUp) + el.addEventListener('pointercancel', onUp) + }) + }} + > + +
) } diff --git a/packages/ui/src/theme.css b/packages/ui/src/theme.css index 5f84df2..86fd9c1 100644 --- a/packages/ui/src/theme.css +++ b/packages/ui/src/theme.css @@ -224,21 +224,20 @@ .island-window-shell::after { position: absolute; inset: 0; - border: 1px solid transparent; + border: 2px solid transparent; border-radius: 12px; content: ""; pointer-events: none; transition: - border-color 140ms var(--ease-out-soft), - box-shadow 140ms var(--ease-out-soft); + border-color 120ms var(--ease-out-soft), + box-shadow 120ms var(--ease-out-soft); } - .island-window-shell:has(.desktop-interactive:hover)::after, .island-window-shell[data-pointer-active="true"]::after { - border-color: rgb(255 255 255 / 0.34); + border-color: rgb(250 204 21 / 0.92); box-shadow: - inset 0 0 0 1px rgb(0 0 0 / 0.14), - 0 0 0 1px rgb(250 204 21 / 0.5); + 0 0 0 1px rgb(250 204 21 / 0.35), + 0 0 14px rgb(250 204 21 / 0.22); } .desktop-interactive { From aa85877c643a4f469be37fe9d8dddc659142f1e0 Mon Sep 17 00:00:00 2001 From: Lulu Date: Mon, 27 Jul 2026 13:53:12 +0800 Subject: [PATCH 02/11] fix(pages): prevent settings form clipping on narrow viewports Co-authored-by: Cursor --- packages/pages/src/AccountPage.tsx | 14 +++++++------- packages/pages/src/HistoryPage.tsx | 4 ++-- packages/pages/src/SyncPendingNotice.tsx | 10 +++++++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/pages/src/AccountPage.tsx b/packages/pages/src/AccountPage.tsx index 3e9a3ec..413947b 100644 --- a/packages/pages/src/AccountPage.tsx +++ b/packages/pages/src/AccountPage.tsx @@ -223,9 +223,9 @@ function AccountContent({ } return ( -
-
-