From f7fc034bd9cdb754e5c42084caf881f0b58f456c Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Tue, 26 May 2026 14:05:35 -0700 Subject: [PATCH 01/14] feat(notes): edge-aware meeting widget settings + drag/snap math (PRSM-68) Swap meetingWidget.normalizedY for { edge, normalizedPosition }. Generalize window-manager bounds to handle right and bottom edges. Replace updateMeetingWidgetWindowPosition with a free-drag method that follows the cursor unconstrained, plus snapMeetingWidgetToEdge that resolves the nearest edge on drag end and returns the persisted position. IPC drag signatures gain screenX/screenY/offsetX/offsetY. Renderer + UI changes follow in subsequent commits. --- apps/desktop/src/db/app-settings.ts | 3 +- apps/desktop/src/db/schema.ts | 3 +- apps/desktop/src/main/core/window-manager.ts | 146 +++++++++++++----- .../meeting-recording-widget-manager.ts | 69 +++++++-- apps/desktop/src/main/preload.ts | 30 +++- apps/desktop/src/services/settings-service.ts | 20 ++- apps/desktop/src/trpc/routers/settings.ts | 3 +- apps/desktop/src/types/electron-api.ts | 14 +- apps/desktop/src/types/meeting-widget.ts | 2 + .../meeting-recording-widget-manager.test.ts | 8 +- 10 files changed, 223 insertions(+), 75 deletions(-) diff --git a/apps/desktop/src/db/app-settings.ts b/apps/desktop/src/db/app-settings.ts index ddf444a9..c1efd32b 100644 --- a/apps/desktop/src/db/app-settings.ts +++ b/apps/desktop/src/db/app-settings.ts @@ -102,7 +102,8 @@ const defaultSettings: AppSettingsData = { }, meetingWidget: { visibility: "always", - normalizedY: 0.5, + edge: "right", + normalizedPosition: 0.5, }, shortcuts: getDefaultShortcuts(), // No `modelDefaults` here — undefined means "no default set yet"; the diff --git a/apps/desktop/src/db/schema.ts b/apps/desktop/src/db/schema.ts index 1bf06423..c7f1f11c 100644 --- a/apps/desktop/src/db/schema.ts +++ b/apps/desktop/src/db/schema.ts @@ -255,7 +255,8 @@ export interface AppSettingsData { }; meetingWidget?: { visibility?: "never" | "while-recording" | "always"; - normalizedY?: number; + edge?: "right" | "bottom"; + normalizedPosition?: number; }; shortcuts?: { pushToTalk?: number[]; diff --git a/apps/desktop/src/main/core/window-manager.ts b/apps/desktop/src/main/core/window-manager.ts index 0c8ac865..bd84169b 100644 --- a/apps/desktop/src/main/core/window-manager.ts +++ b/apps/desktop/src/main/core/window-manager.ts @@ -4,6 +4,7 @@ import { logger } from "../logger"; import { getAppIconPath } from "./icon"; import type { SettingsService } from "../../services/settings-service"; import type { createIPCHandler } from "electron-trpc-experimental/main"; +import type { MeetingWidgetEdge } from "../../types/meeting-widget"; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare const MAIN_WINDOW_VITE_NAME: string; @@ -14,7 +15,7 @@ export class WindowManager { private static readonly MEETING_WIDGET_WINDOW_WIDTH = 380 as const; private static readonly MEETING_WIDGET_WINDOW_HEIGHT = 240 as const; private static readonly MEETING_WIDGET_EDGE_MARGIN = 12 as const; - private static readonly MEETING_WIDGET_VERTICAL_MARGIN = 24 as const; + private static readonly MEETING_WIDGET_PARALLEL_MARGIN = 24 as const; private mainWindow: BrowserWindow | null = null; private onboardingWindow: BrowserWindow | null = null; private meetingWidgetWindow: BrowserWindow | null = null; @@ -42,7 +43,8 @@ export class WindowManager { } private getMeetingWidgetWindowBounds( - normalizedY: number = 0.5, + edge: MeetingWidgetEdge = "right", + normalizedPosition: number = 0.5, displayPoint: Electron.Point = screen.getCursorScreenPoint(), ): Electron.Rectangle { const display = screen.getDisplayNearestPoint(displayPoint); @@ -56,18 +58,29 @@ export class WindowManager { workArea.height, ); const edgeMargin = WindowManager.MEETING_WIDGET_EDGE_MARGIN; - const verticalMargin = WindowManager.MEETING_WIDGET_VERTICAL_MARGIN; - const minY = workArea.y + verticalMargin; - const maxY = workArea.y + workArea.height - height - verticalMargin; - const clampedNormalizedY = clampNormalizedY(normalizedY); - const y = - maxY <= minY - ? minY - : Math.round(minY + (maxY - minY) * clampedNormalizedY); + const parallelMargin = WindowManager.MEETING_WIDGET_PARALLEL_MARGIN; + const clamped = clampNormalizedPosition(normalizedPosition); + + if (edge === "right") { + const minY = workArea.y + parallelMargin; + const maxY = workArea.y + workArea.height - height - parallelMargin; + const y = + maxY <= minY ? minY : Math.round(minY + (maxY - minY) * clamped); + return { + x: workArea.x + workArea.width - width - edgeMargin, + y, + width, + height, + }; + } + // edge === "bottom" + const minX = workArea.x + parallelMargin; + const maxX = workArea.x + workArea.width - width - parallelMargin; + const x = maxX <= minX ? minX : Math.round(minX + (maxX - minX) * clamped); return { - x: workArea.x + workArea.width - width - edgeMargin, - y, + x, + y: workArea.y + workArea.height - height - edgeMargin, width, height, }; @@ -336,9 +349,10 @@ export class WindowManager { } async createOrShowMeetingWidgetWindow( - normalizedY: number = 0.5, + edge: MeetingWidgetEdge = "right", + normalizedPosition: number = 0.5, ): Promise { - const bounds = this.getMeetingWidgetWindowBounds(normalizedY); + const bounds = this.getMeetingWidgetWindowBounds(edge, normalizedPosition); if (this.meetingWidgetWindow && !this.meetingWidgetWindow.isDestroyed()) { this.meetingWidgetWindow.setBounds(bounds); @@ -432,43 +446,97 @@ export class WindowManager { ); } - updateMeetingWidgetWindowPosition( + /** + * Move the widget window to follow the cursor freely during a drag. + * No edge constraint — the window is wherever the cursor is. Returns the + * top-left bounds we set, useful for tests. + */ + updateMeetingWidgetWindowPositionFree( + screenX: number, screenY: number, + pointerOffsetX: number, pointerOffsetY: number, - ): number | null { + ): Electron.Rectangle | null { if (!this.meetingWidgetWindow || this.meetingWidgetWindow.isDestroyed()) { return null; } const currentBounds = this.meetingWidgetWindow.getBounds(); - const display = screen.getDisplayNearestPoint({ - x: currentBounds.x + currentBounds.width - 1, - y: screenY, - }); + const targetX = Math.round(screenX - pointerOffsetX); + const targetY = Math.round(screenY - pointerOffsetY); + const display = screen.getDisplayNearestPoint({ x: screenX, y: screenY }); const workArea = display.workArea; - const edgeMargin = WindowManager.MEETING_WIDGET_EDGE_MARGIN; - const verticalMargin = WindowManager.MEETING_WIDGET_VERTICAL_MARGIN; - const minY = workArea.y + verticalMargin; - const maxY = - workArea.y + workArea.height - currentBounds.height - verticalMargin; + const x = clamp( + targetX, + workArea.x, + workArea.x + workArea.width - currentBounds.width, + ); const y = clamp( - Math.round(screenY - pointerOffsetY), - minY, - Math.max(minY, maxY), + targetY, + workArea.y, + workArea.y + workArea.height - currentBounds.height, ); - const x = workArea.x + workArea.width - currentBounds.width - edgeMargin; - this.meetingWidgetWindow.setBounds({ - ...currentBounds, - x, - y, - }); + const next = { ...currentBounds, x, y }; + this.meetingWidgetWindow.setBounds(next); + return next; + } + + /** + * Snap the widget to the nearest edge of the display currently under the + * cursor. Returns the resolved { edge, normalizedPosition } so the manager + * can persist them. + */ + snapMeetingWidgetToEdge( + screenX: number, + screenY: number, + ): { edge: MeetingWidgetEdge; normalizedPosition: number } | null { + if (!this.meetingWidgetWindow || this.meetingWidgetWindow.isDestroyed()) { + return null; + } - if (maxY <= minY) { - return 1; + const display = screen.getDisplayNearestPoint({ x: screenX, y: screenY }); + const workArea = display.workArea; + const distanceToRight = Math.max( + 0, + workArea.x + workArea.width - screenX, + ); + const distanceToBottom = Math.max( + 0, + workArea.y + workArea.height - screenY, + ); + const edge: MeetingWidgetEdge = + distanceToBottom < distanceToRight ? "bottom" : "right"; + + const bounds = this.meetingWidgetWindow.getBounds(); + const parallelMargin = WindowManager.MEETING_WIDGET_PARALLEL_MARGIN; + let normalizedPosition: number; + + if (edge === "right") { + const minY = workArea.y + parallelMargin; + const maxY = + workArea.y + workArea.height - bounds.height - parallelMargin; + normalizedPosition = + maxY <= minY + ? 0.5 + : clampNormalizedPosition((screenY - minY) / (maxY - minY)); + } else { + const minX = workArea.x + parallelMargin; + const maxX = + workArea.x + workArea.width - bounds.width - parallelMargin; + normalizedPosition = + maxX <= minX + ? 0.5 + : clampNormalizedPosition((screenX - minX) / (maxX - minX)); } - return clampNormalizedY((y - minY) / (maxY - minY)); + const target = this.getMeetingWidgetWindowBounds( + edge, + normalizedPosition, + { x: screenX, y: screenY }, + ); + this.meetingWidgetWindow.setBounds(target); + return { edge, normalizedPosition }; } async navigateMainWindow(route: string): Promise { @@ -570,9 +638,9 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -function clampNormalizedY(value: number): number { +function clampNormalizedPosition(value: number): number { if (!Number.isFinite(value)) { - return 1; + return 0.5; } return Math.min(1, Math.max(0, value)); diff --git a/apps/desktop/src/main/managers/meeting-recording-widget-manager.ts b/apps/desktop/src/main/managers/meeting-recording-widget-manager.ts index 47091509..71554347 100644 --- a/apps/desktop/src/main/managers/meeting-recording-widget-manager.ts +++ b/apps/desktop/src/main/managers/meeting-recording-widget-manager.ts @@ -48,12 +48,14 @@ export class MeetingRecordingWidgetManager extends EventEmitter { meetingState: "idle", noteId: null, meetingDetection: null, + edge: "right", }; private started = false; private settings: MeetingWidgetSettings = { visibility: "always", - normalizedY: 0.5, + edge: "right", + normalizedPosition: 0.5, }; private hideTimer: NodeJS.Timeout | null = null; private ipcHandlersRegistered = false; @@ -108,12 +110,14 @@ export class MeetingRecordingWidgetManager extends EventEmitter { meetingState: this.deps.meetingManager.getState().state, noteId: this.deps.meetingManager.getState().noteId, meetingDetection: null, + edge: this.settings.edge, }); } getState(): MeetingWidgetState { return { ...this.state, + edge: this.settings.edge, meetingDetection: this.state.meetingDetection ? { ...this.state.meetingDetection } : null, @@ -184,39 +188,54 @@ export class MeetingRecordingWidgetManager extends EventEmitter { ); } - dragMove(screenY: number, pointerOffsetY: number): void { + dragMove( + screenX: number, + screenY: number, + pointerOffsetX: number, + pointerOffsetY: number, + ): void { if (!this.state.visible) { return; } this.clearHideTimer(); this.setInteractive(true); - this.deps.windowManager.updateMeetingWidgetWindowPosition( + this.deps.windowManager.updateMeetingWidgetWindowPositionFree( + screenX, screenY, + pointerOffsetX, pointerOffsetY, ); } - async dragEnd(screenY: number, pointerOffsetY: number): Promise { - const normalizedY = - this.deps.windowManager.updateMeetingWidgetWindowPosition( - screenY, - pointerOffsetY, - ); + async dragEnd( + screenX: number, + screenY: number, + _pointerOffsetX: number, + _pointerOffsetY: number, + ): Promise { + const snapped = this.deps.windowManager.snapMeetingWidgetToEdge( + screenX, + screenY, + ); this.setInteractive(false); - if (normalizedY === null) { + if (snapped === null) { return; } this.settings = { ...this.settings, - normalizedY, + edge: snapped.edge, + normalizedPosition: snapped.normalizedPosition, }; await this.deps.settingsService.setMeetingWidgetSettings({ - normalizedY, + edge: snapped.edge, + normalizedPosition: snapped.normalizedPosition, }); + // Propagate the new edge into the renderer state so it can re-orient. + this.refreshState("drag-end-snap"); } private attachListeners(): void { @@ -259,15 +278,27 @@ export class MeetingRecordingWidgetManager extends EventEmitter { ); ipcMain.handle( IPC_CHANNELS.dragMove, - (_event, screenY: number, pointerOffsetY: number) => { - this.dragMove(screenY, pointerOffsetY); + ( + _event, + screenX: number, + screenY: number, + pointerOffsetX: number, + pointerOffsetY: number, + ) => { + this.dragMove(screenX, screenY, pointerOffsetX, pointerOffsetY); return true; }, ); ipcMain.handle( IPC_CHANNELS.dragEnd, - async (_event, screenY: number, pointerOffsetY: number) => { - await this.dragEnd(screenY, pointerOffsetY); + async ( + _event, + screenX: number, + screenY: number, + pointerOffsetX: number, + pointerOffsetY: number, + ) => { + await this.dragEnd(screenX, screenY, pointerOffsetX, pointerOffsetY); return true; }, ); @@ -321,7 +352,8 @@ export class MeetingRecordingWidgetManager extends EventEmitter { if (nextVisible) { this.clearHideTimer(); void this.deps.windowManager.createOrShowMeetingWidgetWindow( - this.settings.normalizedY, + this.settings.edge, + this.settings.normalizedPosition, ); this.deps.windowManager.setMeetingWidgetWindowIgnoreMouseEvents(true); } else if (this.isWidgetWindowVisible()) { @@ -335,6 +367,7 @@ export class MeetingRecordingWidgetManager extends EventEmitter { meetingState: runtime.state, noteId: runtime.noteId, meetingDetection: this.state.meetingDetection, + edge: this.settings.edge, }); logger.debug("Meeting recording widget state refreshed", { @@ -414,6 +447,7 @@ export class MeetingRecordingWidgetManager extends EventEmitter { nextState.visible === this.state.visible && nextState.meetingState === this.state.meetingState && nextState.noteId === this.state.noteId && + nextState.edge === this.state.edge && sameDetectionId(nextState.meetingDetection, this.state.meetingDetection) ) { return false; @@ -424,6 +458,7 @@ export class MeetingRecordingWidgetManager extends EventEmitter { this.state.meetingState = nextState.meetingState; this.state.noteId = nextState.noteId; this.state.meetingDetection = nextState.meetingDetection; + this.state.edge = nextState.edge; this.emit("state-changed", this.getState()); return true; } diff --git a/apps/desktop/src/main/preload.ts b/apps/desktop/src/main/preload.ts index ed80134d..5a904af7 100644 --- a/apps/desktop/src/main/preload.ts +++ b/apps/desktop/src/main/preload.ts @@ -141,10 +141,32 @@ const api: ElectronAPI = { recordingWidget: { setInteractive: (interactive: boolean) => ipcRenderer.invoke("meeting-widget:set-interactive", interactive), - dragMove: (screenY: number, pointerOffsetY: number) => - ipcRenderer.invoke("meeting-widget:drag-move", screenY, pointerOffsetY), - dragEnd: (screenY: number, pointerOffsetY: number) => - ipcRenderer.invoke("meeting-widget:drag-end", screenY, pointerOffsetY), + dragMove: ( + screenX: number, + screenY: number, + pointerOffsetX: number, + pointerOffsetY: number, + ) => + ipcRenderer.invoke( + "meeting-widget:drag-move", + screenX, + screenY, + pointerOffsetX, + pointerOffsetY, + ), + dragEnd: ( + screenX: number, + screenY: number, + pointerOffsetX: number, + pointerOffsetY: number, + ) => + ipcRenderer.invoke( + "meeting-widget:drag-end", + screenX, + screenY, + pointerOffsetX, + pointerOffsetY, + ), openNote: (options?: { noteId?: number | null; openTranscription?: boolean; diff --git a/apps/desktop/src/services/settings-service.ts b/apps/desktop/src/services/settings-service.ts index 35496fdf..09189ca9 100644 --- a/apps/desktop/src/services/settings-service.ts +++ b/apps/desktop/src/services/settings-service.ts @@ -43,9 +43,12 @@ export interface MeetingNotificationSettings { export type MeetingWidgetVisibility = "never" | "while-recording" | "always"; +export type MeetingWidgetEdge = "right" | "bottom"; + export interface MeetingWidgetSettings { visibility: MeetingWidgetVisibility; - normalizedY: number; + edge: MeetingWidgetEdge; + normalizedPosition: number; } export class SettingsService extends EventEmitter { @@ -134,7 +137,10 @@ export class SettingsService extends EventEmitter { return { visibility: meetingWidget?.visibility ?? "always", - normalizedY: clampNormalizedY(meetingWidget?.normalizedY ?? 0.5), + edge: meetingWidget?.edge ?? "right", + normalizedPosition: clampNormalizedPosition( + meetingWidget?.normalizedPosition ?? 0.5, + ), }; } @@ -145,11 +151,11 @@ export class SettingsService extends EventEmitter { meetingWidgetSettings: Partial, ): Promise { const current = await this.getMeetingWidgetSettings(); - const next = { + const next: MeetingWidgetSettings = { ...current, ...meetingWidgetSettings, - normalizedY: clampNormalizedY( - meetingWidgetSettings.normalizedY ?? current.normalizedY, + normalizedPosition: clampNormalizedPosition( + meetingWidgetSettings.normalizedPosition ?? current.normalizedPosition, ), }; @@ -458,9 +464,9 @@ export class SettingsService extends EventEmitter { } } -function clampNormalizedY(value: number): number { +function clampNormalizedPosition(value: number): number { if (!Number.isFinite(value)) { - return 1; + return 0.5; } return Math.min(1, Math.max(0, value)); diff --git a/apps/desktop/src/trpc/routers/settings.ts b/apps/desktop/src/trpc/routers/settings.ts index 7d3de95e..79be9cd2 100644 --- a/apps/desktop/src/trpc/routers/settings.ts +++ b/apps/desktop/src/trpc/routers/settings.ts @@ -67,7 +67,8 @@ const RecordingSettingsSchema = z.object({ const MeetingWidgetSettingsSchema = z.object({ visibility: z.enum(["never", "while-recording", "always"]).optional(), - normalizedY: z.number().min(0).max(1).optional(), + edge: z.enum(["right", "bottom"]).optional(), + normalizedPosition: z.number().min(0).max(1).optional(), }); export const settingsRouter = createRouter({ diff --git a/apps/desktop/src/types/electron-api.ts b/apps/desktop/src/types/electron-api.ts index 833f08f7..f6f3f0af 100644 --- a/apps/desktop/src/types/electron-api.ts +++ b/apps/desktop/src/types/electron-api.ts @@ -58,8 +58,18 @@ export interface ElectronAPI { recordingWidget: { setInteractive: (interactive: boolean) => Promise; - dragMove: (screenY: number, pointerOffsetY: number) => Promise; - dragEnd: (screenY: number, pointerOffsetY: number) => Promise; + dragMove: ( + screenX: number, + screenY: number, + pointerOffsetX: number, + pointerOffsetY: number, + ) => Promise; + dragEnd: ( + screenX: number, + screenY: number, + pointerOffsetX: number, + pointerOffsetY: number, + ) => Promise; openNote: (options?: { noteId?: number | null; openTranscription?: boolean; diff --git a/apps/desktop/src/types/meeting-widget.ts b/apps/desktop/src/types/meeting-widget.ts index b6dcd3bd..07d9edeb 100644 --- a/apps/desktop/src/types/meeting-widget.ts +++ b/apps/desktop/src/types/meeting-widget.ts @@ -2,6 +2,7 @@ import type { MeetingRuntimeState } from "./meeting"; import type { MeetingStartNotificationPayload } from "./meeting-start-notifications"; export type MeetingWidgetVisibility = "never" | "while-recording" | "always"; +export type MeetingWidgetEdge = "right" | "bottom"; export interface MeetingWidgetState { visibility: MeetingWidgetVisibility; @@ -9,4 +10,5 @@ export interface MeetingWidgetState { meetingState: MeetingRuntimeState; noteId: number | null; meetingDetection: MeetingStartNotificationPayload | null; + edge: MeetingWidgetEdge; } diff --git a/apps/desktop/tests/services/meeting-recording-widget-manager.test.ts b/apps/desktop/tests/services/meeting-recording-widget-manager.test.ts index 5babb2e9..0e9ed9c3 100644 --- a/apps/desktop/tests/services/meeting-recording-widget-manager.test.ts +++ b/apps/desktop/tests/services/meeting-recording-widget-manager.test.ts @@ -13,7 +13,7 @@ class FakeMeetingManager extends EventEmitter { class FakeSettingsService extends EventEmitter { async getMeetingWidgetSettings(): Promise { - return { visibility: "always", normalizedY: 0.5 }; + return { visibility: "always", edge: "right", normalizedPosition: 0.5 }; } } @@ -26,7 +26,8 @@ function createManager() { hideMeetingWidgetWindow: vi.fn(), getMainWindow: vi.fn(() => null), getMeetingWidgetWindow: vi.fn(() => null), - updateMeetingWidgetWindowPosition: vi.fn(), + updateMeetingWidgetWindowPositionFree: vi.fn(() => null), + snapMeetingWidgetToEdge: vi.fn(() => null), }; const manager = new MeetingRecordingWidgetManager({ settingsService: settingsService as any, @@ -93,7 +94,8 @@ describe("MeetingRecordingWidgetManager visibility with detection", () => { const { manager, settingsService } = createManager(); settingsService.getMeetingWidgetSettings = async () => ({ visibility: "while-recording" as const, - normalizedY: 0.5, + edge: "right" as const, + normalizedPosition: 0.5, }); await manager.start(); From 9db3f0e1c5ae2e17d19b578b74f9a0a55a19d2f8 Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Tue, 26 May 2026 18:38:58 -0700 Subject: [PATCH 02/14] test(notes): unit-test widget edge-snap math (PRSM-68) --- .../tests/main/window-manager-bounds.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 apps/desktop/tests/main/window-manager-bounds.test.ts diff --git a/apps/desktop/tests/main/window-manager-bounds.test.ts b/apps/desktop/tests/main/window-manager-bounds.test.ts new file mode 100644 index 00000000..e2ee5399 --- /dev/null +++ b/apps/desktop/tests/main/window-manager-bounds.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; + +// Mock electron before importing WindowManager +vi.mock("electron", () => ({ + BrowserWindow: vi.fn(), + screen: { + getDisplayNearestPoint: () => ({ + workArea: { x: 0, y: 0, width: 1440, height: 900 }, + }), + getCursorScreenPoint: () => ({ x: 720, y: 450 }), + }, + nativeTheme: { shouldUseDarkColors: false, themeSource: "system" }, + shell: {}, +})); +vi.mock("../../src/main/logger", () => ({ + logger: { main: { info: vi.fn(), error: vi.fn(), debug: vi.fn() } }, +})); + +import { WindowManager } from "../../src/main/core/window-manager"; + +function makeManager() { + // The constructor signature requires settingsService + trpcHandler; + // both are unused by snap/bounds and can be mocked. + return new WindowManager({} as any, { + attachWindow: vi.fn(), + detachWindow: vi.fn(), + } as any); +} + +function attachFakeWindow(mgr: WindowManager) { + const fakeBounds = { x: 0, y: 0, width: 380, height: 240 }; + (mgr as any).meetingWidgetWindow = { + isDestroyed: () => false, + getBounds: () => fakeBounds, + setBounds: (next: any) => Object.assign(fakeBounds, next), + }; + return fakeBounds; +} + +describe("WindowManager.snapMeetingWidgetToEdge", () => { + it("snaps to right edge when cursor is near the right side", () => { + const mgr = makeManager(); + attachFakeWindow(mgr); + const result = mgr.snapMeetingWidgetToEdge(1400, 200); + expect(result?.edge).toBe("right"); + expect(result?.normalizedPosition).toBeGreaterThanOrEqual(0); + expect(result?.normalizedPosition).toBeLessThanOrEqual(1); + }); + + it("snaps to bottom edge when cursor is near the bottom", () => { + const mgr = makeManager(); + attachFakeWindow(mgr); + const result = mgr.snapMeetingWidgetToEdge(400, 880); + expect(result?.edge).toBe("bottom"); + }); + + it("normalizedPosition reflects cursor X when snapped to bottom", () => { + const mgr = makeManager(); + attachFakeWindow(mgr); + // Cursor near far-left should map to a low normalizedPosition. + const left = mgr.snapMeetingWidgetToEdge(40, 880); + const right = mgr.snapMeetingWidgetToEdge(1300, 880); + expect(left?.normalizedPosition).toBeLessThan(right!.normalizedPosition); + }); + + it("returns null when the widget window is absent", () => { + const mgr = makeManager(); + (mgr as any).meetingWidgetWindow = null; + expect(mgr.snapMeetingWidgetToEdge(100, 100)).toBeNull(); + }); +}); From d3094a5d4d6cf8ac2f83424abed5217520e568a1 Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Tue, 26 May 2026 18:40:20 -0700 Subject: [PATCH 03/14] fix(notes): tighten widget hover hit zone to visible elements (PRSM-68) --- apps/desktop/src/renderer/recording-widget/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/recording-widget/index.tsx b/apps/desktop/src/renderer/recording-widget/index.tsx index 320fc9f3..45201531 100644 --- a/apps/desktop/src/renderer/recording-widget/index.tsx +++ b/apps/desktop/src/renderer/recording-widget/index.tsx @@ -170,7 +170,6 @@ function RecordingWidgetWindow() { >
Date: Tue, 26 May 2026 18:45:48 -0700 Subject: [PATCH 04/14] feat(notes): icon-stack widget UI + bottom-edge support (PRSM-68) Replace the expanded pill with a stack of round 36px icon buttons. Idle state shows Take Notes (new createBlankNote mutation, creates a note without auto-record), Start Recording (mic, matches the in-app dock icon), and a drag handle. Recording state shows Stop, waveform-as-anchor, Open Note, drag handle. The stack orientation flips with the active edge: vertical column at the right edge (today), horizontal row at the bottom edge. Drag-and-snap follows the cursor freely and snaps to the nearest edge on release. DetectionPill is intentionally unchanged. --- .../meeting-start-notification-manager.ts | 20 +++ .../recording-widget/icon-button-stack.tsx | 38 ++++ .../renderer/recording-widget/icon-button.tsx | 44 +++++ .../renderer/recording-widget/idle-pill.tsx | 71 +++++--- .../src/renderer/recording-widget/index.tsx | 166 ++++++++++++------ .../recording-widget/recording-pill.tsx | 146 ++++++++------- .../src/trpc/routers/meeting-widget.ts | 7 + 7 files changed, 349 insertions(+), 143 deletions(-) create mode 100644 apps/desktop/src/renderer/recording-widget/icon-button-stack.tsx create mode 100644 apps/desktop/src/renderer/recording-widget/icon-button.tsx diff --git a/apps/desktop/src/main/managers/meeting-start-notification-manager.ts b/apps/desktop/src/main/managers/meeting-start-notification-manager.ts index 39d5f8e9..313e4f56 100644 --- a/apps/desktop/src/main/managers/meeting-start-notification-manager.ts +++ b/apps/desktop/src/main/managers/meeting-start-notification-manager.ts @@ -201,6 +201,26 @@ export class MeetingStartNotificationManager extends EventEmitter { return { noteId: note.id }; } + async createBlankNote(): Promise<{ noteId: number }> { + const note = await this.notesService.createNote({ + title: buildIdleNoteTitle(), + icon: null, + }); + + this.deps.telemetryService?.trackNoteCreated({ + note_id: note.id, + has_initial_content: false, + has_icon: false, + }); + + await this.deps.windowManager.navigateMainWindow(`/notes/${note.id}`); + + this.clearActiveNotificationWindow(); + logger.info("Created blank note from widget", { noteId: note.id }); + + return { noteId: note.id }; + } + async showTestNotification(): Promise { this.clearActiveNotificationWindow(); await this.showNotification({ diff --git a/apps/desktop/src/renderer/recording-widget/icon-button-stack.tsx b/apps/desktop/src/renderer/recording-widget/icon-button-stack.tsx new file mode 100644 index 00000000..fbdb858c --- /dev/null +++ b/apps/desktop/src/renderer/recording-widget/icon-button-stack.tsx @@ -0,0 +1,38 @@ +import { motion } from "framer-motion"; +import type { ReactNode } from "react"; +import type { MeetingWidgetEdge } from "@/types/meeting-widget"; + +export interface IconButtonStackProps { + edge: MeetingWidgetEdge; + /** Element that's always visible (collapsed and expanded). */ + mainAnchor: ReactNode; + /** Secondary slot that visually flips position with edge rotation. */ + secondaryLeading?: ReactNode; + /** Optional second secondary on the opposite side of main-anchor. */ + secondaryTrailing?: ReactNode; +} + +export function IconButtonStack({ + edge, + mainAnchor, + secondaryLeading, + secondaryTrailing, +}: IconButtonStackProps) { + if (edge === "right") { + return ( + + {secondaryLeading} + {mainAnchor} + {secondaryTrailing} + + ); + } + // edge === "bottom" — rotate 90° CW: top→right, bottom→left. + return ( + + {secondaryTrailing} + {mainAnchor} + {secondaryLeading} + + ); +} diff --git a/apps/desktop/src/renderer/recording-widget/icon-button.tsx b/apps/desktop/src/renderer/recording-widget/icon-button.tsx new file mode 100644 index 00000000..98d3d41f --- /dev/null +++ b/apps/desktop/src/renderer/recording-widget/icon-button.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef } from "react"; +import type { ReactNode } from "react"; + +export interface IconButtonProps + extends React.ButtonHTMLAttributes { + /** Accessible tooltip text. Shown via native title attribute. */ + tooltip: string; + /** Lucide / Tabler icon node, 16-18px. */ + icon: ReactNode; + /** When true, the button uses the destructive (red) accent. */ + destructive?: boolean; +} + +export const IconButton = forwardRef( + function IconButton( + { tooltip, icon, destructive, disabled, className, ...rest }, + ref, + ) { + return ( + + ); + }, +); diff --git a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx index 541dfe0c..16abe5c3 100644 --- a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx @@ -1,40 +1,67 @@ import { motion } from "framer-motion"; -import { TakeNotesButton } from "./widget-buttons"; +import { Mic } from "lucide-react"; +import { IconNotes } from "@tabler/icons-react"; +import type { MeetingWidgetEdge } from "@/types/meeting-widget"; +import { IconButton } from "./icon-button"; +import { IconButtonStack } from "./icon-button-stack"; export const PILL_SHELL_CLASS = "relative pointer-events-auto bg-black/80 dark:bg-black/70 backdrop-blur-md ring-[1px] ring-black/60 shadow-[0px_0px_15px_0px_rgba(0,0,0,0.40)] before:content-[''] before:absolute before:inset-[1px] before:outline before:outline-white/15 before:pointer-events-none"; export interface IdlePillProps { + edge: MeetingWidgetEdge; hovered: boolean; onTakeNotes: () => void; takingNotes: boolean; + onStartRecording: () => void; + startingRecording: boolean; } -export function IdlePill({ hovered, onTakeNotes, takingNotes }: IdlePillProps) { +const SLIVER_RIGHT = { width: 8, height: 56 }; +const SLIVER_BOTTOM = { width: 56, height: 8 }; + +export function IdlePill({ + edge, + hovered, + onTakeNotes, + takingNotes, + onStartRecording, + startingRecording, +}: IdlePillProps) { + if (hovered) { + return ( + } + onClick={onTakeNotes} + disabled={takingNotes} + /> + } + mainAnchor={ + } + onClick={onStartRecording} + disabled={startingRecording} + /> + } + /> + ); + } + const sliver = edge === "right" ? SLIVER_RIGHT : SLIVER_BOTTOM; return ( - {hovered && ( - - - - )} - + className={`${PILL_SHELL_CLASS} rounded-full before:rounded-full`} + /> ); } + +// Note: PILL_SHELL_CLASS is also imported by recording-pill.tsx; keep the export. diff --git a/apps/desktop/src/renderer/recording-widget/index.tsx b/apps/desktop/src/renderer/recording-widget/index.tsx index 45201531..6a72a780 100644 --- a/apps/desktop/src/renderer/recording-widget/index.tsx +++ b/apps/desktop/src/renderer/recording-widget/index.tsx @@ -9,7 +9,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AnimatePresence, motion } from "framer-motion"; import { api, trpcClient } from "@/trpc/react"; import { combinedLevel, useMeetingLevel } from "@/hooks/useMeetingLevel"; -import type { MeetingWidgetState } from "@/types/meeting-widget"; +import type { + MeetingWidgetEdge, + MeetingWidgetState, +} from "@/types/meeting-widget"; import { IdlePill } from "./idle-pill"; import { DetectionPill } from "./detection-pill"; import { RecordingPill } from "./recording-pill"; @@ -21,7 +24,41 @@ const queryClient = new QueryClient({ }, }); -type DragState = { pointerOffsetY: number }; +type DragState = { pointerOffsetX: number; pointerOffsetY: number }; + +interface DragHandleProps { + edge: MeetingWidgetEdge; + visible: boolean; + onPointerDown: (event: React.PointerEvent) => void; +} + +function DragHandle({ edge, visible, onPointerDown }: DragHandleProps) { + const isVertical = edge === "right"; + return ( + +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); +} function RecordingWidgetWindow() { const initialStateQuery = api.meetingWidget.getState.useQuery(); @@ -34,8 +71,6 @@ function RecordingWidgetWindow() { onData: (nextState) => setLiveState(nextState), }); - // Real-time mic + system audio amplitude (combined max), used to drive the - // waveform in the recording pill. const meetingLevels = useMeetingLevel(); const waveformLevel = combinedLevel(meetingLevels); @@ -43,12 +78,14 @@ function RecordingWidgetWindow() { const startNoteFromDetectionMutation = api.meetingWidget.startNoteFromDetection.useMutation(); const dismissDetectionMutation = api.meetingWidget.dismissDetection.useMutation(); + const createBlankNoteMutation = api.meetingWidget.createBlankNote.useMutation(); const state = liveState ?? initialStateQuery.data ?? null; const widgetVisible = state?.visible ?? false; const meetingState = state?.meetingState ?? "idle"; const meetingDetection = state?.meetingDetection ?? null; const currentNoteId = state?.noteId ?? null; + const edge: MeetingWidgetEdge = state?.edge ?? "right"; const isRecording = meetingState === "recording" || @@ -84,14 +121,18 @@ function RecordingWidgetWindow() { const handlePointerMove = (event: PointerEvent) => { void window.electronAPI.recordingWidget.dragMove( + event.screenX, event.screenY, + dragState.pointerOffsetX, dragState.pointerOffsetY, ); }; const handlePointerUp = (event: PointerEvent) => { void window.electronAPI.recordingWidget.dragEnd( + event.screenX, event.screenY, + dragState.pointerOffsetX, dragState.pointerOffsetY, ); setDragState(null); @@ -126,7 +167,10 @@ function RecordingWidgetWindow() { (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); - setDragState({ pointerOffsetY: event.clientY }); + setDragState({ + pointerOffsetX: event.clientX, + pointerOffsetY: event.clientY, + }); setIsHovered(true); }, [], @@ -148,10 +192,14 @@ function RecordingWidgetWindow() { }); }, [currentNoteId, isRecording]); - const handleTakeNotesIdle = useCallback(() => { + const handleStartRecording = useCallback(() => { startNoteFromIdleMutation.mutate(); }, [startNoteFromIdleMutation]); + const handleTakeNotes = useCallback(() => { + createBlankNoteMutation.mutate(); + }, [createBlankNoteMutation]); + const handleTakeNotesDetection = useCallback(() => { startNoteFromDetectionMutation.mutate(); }, [startNoteFromDetectionMutation]); @@ -162,66 +210,72 @@ function RecordingWidgetWindow() { const showHandle = isHovered || dragState !== null; + // Outer container anchors the visible content to the active edge. + const outerJustify = + edge === "right" + ? "items-center justify-end pr-1" + : "items-end justify-center pb-1"; + const innerLayout = + edge === "right" + ? "flex flex-col items-center gap-1.5" + : "flex flex-row items-center gap-1.5"; + + const dragHandleEl = ( + + ); + return (
-
+
- -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- - - {isRecording ? ( - - ) : isDetection && meetingDetection ? ( - - ) : ( - - )} - +
+ {edge === "bottom" ? dragHandleEl : null} + + {isRecording ? ( + + ) : isDetection && meetingDetection ? ( + + ) : ( + + )} + + {edge === "right" ? dragHandleEl : null} +
diff --git a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx index f9b4574c..35facbf4 100644 --- a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx @@ -1,23 +1,27 @@ -import { AnimatePresence, motion } from "framer-motion"; -import { AlertTriangle, Loader2 } from "lucide-react"; +import { motion } from "framer-motion"; +import { AlertTriangle, Loader2, Square } from "lucide-react"; +import { IconNotes } from "@tabler/icons-react"; import { Waveform } from "@/components/Waveform"; -import { StopButton, NotesIconButton } from "./widget-buttons"; -import { PILL_SHELL_CLASS } from "./idle-pill"; +import type { MeetingWidgetEdge } from "@/types/meeting-widget"; import type { MeetingRuntimeState } from "@/types/meeting"; +import { IconButton } from "./icon-button"; +import { IconButtonStack } from "./icon-button-stack"; +import { PILL_SHELL_CLASS } from "./idle-pill"; const NUM_WAVEFORM_BARS_HOVERED = 6; const NUM_WAVEFORM_BARS_COLLAPSED = 4; export interface RecordingPillProps { + edge: MeetingWidgetEdge; hovered: boolean; meetingState: MeetingRuntimeState; - // Real-time amplitude (0-1) — combined mic + system, owned by the parent. level: number; onStop: (event: React.MouseEvent) => void; onOpenNote: () => void; } export function RecordingPill({ + edge, hovered, meetingState, level, @@ -29,69 +33,81 @@ export function RecordingPill({ const isStopping = meetingState === "stopping"; const isBusy = isStarting || isStopping; - return ( - - - {hovered && ( - - {isBusy ? ( - - - - ) : isError ? ( - - - - ) : ( - - )} - - )} - + {Array.from({ + length: hovered ? NUM_WAVEFORM_BARS_HOVERED : NUM_WAVEFORM_BARS_COLLAPSED, + }).map((_, index) => ( + + ))} +
+ ); -
- {Array.from({ - length: hovered ? NUM_WAVEFORM_BARS_HOVERED : NUM_WAVEFORM_BARS_COLLAPSED, - }).map((_, index) => ( - - ))} -
+ {waveformContent} + + ); + } + + const stopIcon = isBusy ? ( + + ) : isError ? ( + + ) : ( + + ); + + const stopButton = ( + + ); + + const waveformAsAnchor = ( +
+ {waveformContent} +
+ ); + + const openNoteButton = ( + } + onClick={onOpenNote} + /> + ); - - {hovered && ( - - - - )} - - + return ( + ); } diff --git a/apps/desktop/src/trpc/routers/meeting-widget.ts b/apps/desktop/src/trpc/routers/meeting-widget.ts index 5eb13382..05dccc7e 100644 --- a/apps/desktop/src/trpc/routers/meeting-widget.ts +++ b/apps/desktop/src/trpc/routers/meeting-widget.ts @@ -52,6 +52,13 @@ export const meetingWidgetRouter = createRouter({ return await meetingStartNotificationManager.startNoteFromIdle(); }), + createBlankNote: procedure.mutation(async ({ ctx }) => { + const meetingStartNotificationManager = ctx.serviceManager.getService( + "meetingStartNotificationManager", + ); + return await meetingStartNotificationManager.createBlankNote(); + }), + showTestDetection: procedure.mutation(async ({ ctx }) => { const meetingStartNotificationManager = ctx.serviceManager.getService( "meetingStartNotificationManager", From 439e67221a2328b14879907b7d715b4a9fd36ec5 Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Tue, 26 May 2026 18:52:56 -0700 Subject: [PATCH 05/14] chore(notes): drop unused StopButton/NotesIconButton (PRSM-68) Both components were used only by RecordingPill, which was rewritten in the previous commit to use the new IconButton primitive. Removing the dead exports keeps widget-buttons.tsx focused on the symbols DetectionPill still needs. --- .../recording-widget/widget-buttons.tsx | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/apps/desktop/src/renderer/recording-widget/widget-buttons.tsx b/apps/desktop/src/renderer/recording-widget/widget-buttons.tsx index 66a35e7e..094b871d 100644 --- a/apps/desktop/src/renderer/recording-widget/widget-buttons.tsx +++ b/apps/desktop/src/renderer/recording-widget/widget-buttons.tsx @@ -1,7 +1,5 @@ import React, { forwardRef } from "react"; import type { ReactNode } from "react"; -import { IconNotes } from "@tabler/icons-react"; -import { Square } from "lucide-react"; export interface TakeNotesButtonProps extends React.ButtonHTMLAttributes { @@ -51,39 +49,3 @@ export const OutlinedIconButton = forwardRef< ); }); - -export const StopButton = forwardRef< - HTMLButtonElement, - React.ButtonHTMLAttributes ->(function StopButton(props, ref) { - return ( - - ); -}); - -export const NotesIconButton = forwardRef< - HTMLButtonElement, - React.ButtonHTMLAttributes ->(function NotesIconButton(props, ref) { - return ( - - ); -}); From b4798318b29dd88f8f921f21c994563978d6bdf3 Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Tue, 26 May 2026 23:28:46 -0700 Subject: [PATCH 06/14] fix(notes): keep widget expanded while cursor crosses gaps (PRSM-68) Two related fixes after the icon-stack rewrite caused the widget to collapse mid-interaction: - Conditionally render the drag handle so it's not a phantom hit zone when invisible (collapsed state should be tight to the visible sliver, not span the would-be drag-handle slot). - Mark the inner pill+drag-handle wrapper as a hit zone while expanded so cursor travel through the 6px gaps doesn't drop the hover state and unmount the buttons before clicks land. --- apps/desktop/src/renderer/recording-widget/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/recording-widget/index.tsx b/apps/desktop/src/renderer/recording-widget/index.tsx index 6a72a780..8218f88e 100644 --- a/apps/desktop/src/renderer/recording-widget/index.tsx +++ b/apps/desktop/src/renderer/recording-widget/index.tsx @@ -220,13 +220,13 @@ function RecordingWidgetWindow() { ? "flex flex-col items-center gap-1.5" : "flex flex-row items-center gap-1.5"; - const dragHandleEl = ( + const dragHandleEl = showHandle ? ( - ); + ) : null; return (
-
+
{edge === "bottom" ? dragHandleEl : null} {isRecording ? ( From f21ca559d6a21eef976829fe4a0779789081fa69 Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Wed, 27 May 2026 01:49:33 -0700 Subject: [PATCH 07/14] fix(notes): widget waveform animation + drag snap precision (PRSM-68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two widget regressions: - Waveform froze when the main app lost focus (i.e. whenever the widget was actually visible). The widget BrowserWindow inherited Chromium's default backgroundThrottling, which clamps requestAnimationFrame to ~1Hz on backgrounded renderers — framer-motion's height transitions couldn't run. Level IPC kept arriving, but the bars couldn't animate. Disable backgroundThrottling on the widget window. - Drag snap landed the widget "before" the cursor by the pointer offset. snapMeetingWidgetToEdge derived edge + normalizedPosition from raw cursor screenX/Y, so on release the window top-left jumped to the cursor — discarding the grip offset that updateMeetingWidgetWindowPositionFree was tracking during the drag. Snap now uses the window's post-drag bounds (center for edge selection, top-left for normalizedPosition) so the widget stays where the user dropped it. --- apps/desktop/src/main/core/window-manager.ts | 26 +++++++----- .../tests/main/window-manager-bounds.test.ts | 40 ++++++++++++------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/main/core/window-manager.ts b/apps/desktop/src/main/core/window-manager.ts index bd84169b..fc36e663 100644 --- a/apps/desktop/src/main/core/window-manager.ts +++ b/apps/desktop/src/main/core/window-manager.ts @@ -381,6 +381,7 @@ export class WindowManager { preload: path.join(__dirname, "preload.js"), nodeIntegration: false, contextIsolation: true, + backgroundThrottling: false, }, }); @@ -488,27 +489,34 @@ export class WindowManager { * can persist them. */ snapMeetingWidgetToEdge( - screenX: number, - screenY: number, + _screenX: number, + _screenY: number, ): { edge: MeetingWidgetEdge; normalizedPosition: number } | null { if (!this.meetingWidgetWindow || this.meetingWidgetWindow.isDestroyed()) { return null; } - const display = screen.getDisplayNearestPoint({ x: screenX, y: screenY }); + const bounds = this.meetingWidgetWindow.getBounds(); + // Use the window's center to choose nearest edge and derive + // normalizedPosition from the window's current bounds — this preserves + // the pointer-offset grip that the user established at drag-start, so + // the widget snaps to where it visually ended, not where the cursor + // released. + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + const display = screen.getDisplayNearestPoint({ x: centerX, y: centerY }); const workArea = display.workArea; const distanceToRight = Math.max( 0, - workArea.x + workArea.width - screenX, + workArea.x + workArea.width - centerX, ); const distanceToBottom = Math.max( 0, - workArea.y + workArea.height - screenY, + workArea.y + workArea.height - centerY, ); const edge: MeetingWidgetEdge = distanceToBottom < distanceToRight ? "bottom" : "right"; - const bounds = this.meetingWidgetWindow.getBounds(); const parallelMargin = WindowManager.MEETING_WIDGET_PARALLEL_MARGIN; let normalizedPosition: number; @@ -519,7 +527,7 @@ export class WindowManager { normalizedPosition = maxY <= minY ? 0.5 - : clampNormalizedPosition((screenY - minY) / (maxY - minY)); + : clampNormalizedPosition((bounds.y - minY) / (maxY - minY)); } else { const minX = workArea.x + parallelMargin; const maxX = @@ -527,13 +535,13 @@ export class WindowManager { normalizedPosition = maxX <= minX ? 0.5 - : clampNormalizedPosition((screenX - minX) / (maxX - minX)); + : clampNormalizedPosition((bounds.x - minX) / (maxX - minX)); } const target = this.getMeetingWidgetWindowBounds( edge, normalizedPosition, - { x: screenX, y: screenY }, + { x: centerX, y: centerY }, ); this.meetingWidgetWindow.setBounds(target); return { edge, normalizedPosition }; diff --git a/apps/desktop/tests/main/window-manager-bounds.test.ts b/apps/desktop/tests/main/window-manager-bounds.test.ts index e2ee5399..e6429227 100644 --- a/apps/desktop/tests/main/window-manager-bounds.test.ts +++ b/apps/desktop/tests/main/window-manager-bounds.test.ts @@ -27,8 +27,11 @@ function makeManager() { } as any); } -function attachFakeWindow(mgr: WindowManager) { - const fakeBounds = { x: 0, y: 0, width: 380, height: 240 }; +function attachFakeWindow( + mgr: WindowManager, + initial: { x: number; y: number } = { x: 0, y: 0 }, +) { + const fakeBounds = { ...initial, width: 380, height: 240 }; (mgr as any).meetingWidgetWindow = { isDestroyed: () => false, getBounds: () => fakeBounds, @@ -38,28 +41,37 @@ function attachFakeWindow(mgr: WindowManager) { } describe("WindowManager.snapMeetingWidgetToEdge", () => { - it("snaps to right edge when cursor is near the right side", () => { + it("snaps to right edge when window center is near the right side", () => { const mgr = makeManager(); - attachFakeWindow(mgr); - const result = mgr.snapMeetingWidgetToEdge(1400, 200); + // Window top-left at (1200, 200) → center ≈ (1390, 320) on 1440×900 area. + // distanceToRight ≈ 50, distanceToBottom ≈ 580 → right. + attachFakeWindow(mgr, { x: 1200, y: 200 }); + const result = mgr.snapMeetingWidgetToEdge(0, 0); expect(result?.edge).toBe("right"); expect(result?.normalizedPosition).toBeGreaterThanOrEqual(0); expect(result?.normalizedPosition).toBeLessThanOrEqual(1); }); - it("snaps to bottom edge when cursor is near the bottom", () => { + it("snaps to bottom edge when window center is near the bottom", () => { const mgr = makeManager(); - attachFakeWindow(mgr); - const result = mgr.snapMeetingWidgetToEdge(400, 880); + // Window top-left at (400, 700) → centerY ≈ 820. distanceToBottom ≈ 80, + // distanceToRight ≈ 850 → bottom. + attachFakeWindow(mgr, { x: 400, y: 700 }); + const result = mgr.snapMeetingWidgetToEdge(0, 0); expect(result?.edge).toBe("bottom"); }); - it("normalizedPosition reflects cursor X when snapped to bottom", () => { - const mgr = makeManager(); - attachFakeWindow(mgr); - // Cursor near far-left should map to a low normalizedPosition. - const left = mgr.snapMeetingWidgetToEdge(40, 880); - const right = mgr.snapMeetingWidgetToEdge(1300, 880); + it("normalizedPosition reflects window X when snapped to bottom", () => { + const leftMgr = makeManager(); + attachFakeWindow(leftMgr, { x: 10, y: 700 }); + const left = leftMgr.snapMeetingWidgetToEdge(0, 0); + + const rightMgr = makeManager(); + attachFakeWindow(rightMgr, { x: 1000, y: 700 }); + const right = rightMgr.snapMeetingWidgetToEdge(0, 0); + + expect(left?.edge).toBe("bottom"); + expect(right?.edge).toBe("bottom"); expect(left?.normalizedPosition).toBeLessThan(right!.normalizedPosition); }); From 620e929347c1db3e5e753c9ff2b0a41e9a5511f0 Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Wed, 27 May 2026 12:57:39 -0700 Subject: [PATCH 08/14] feat(notes): tooltips + waveform/stop anchor + recording always-expanded (PRSM-68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swap IconButton's native `title` tooltip for the shadcn/radix Tooltip primitive (per-button `tooltipSide` so labels don't clip at screen edges). Wrap the widget renderer in `TooltipProvider`. - New WaveformStopAnchor merges the recording stop button with the waveform: bars when idle, swap to a red Stop square on the anchor's own hover. Replaces the separate top-of-stack stop button. - RecordingPill no longer has a collapsed/expanded distinction. While recording, the widget rests in expanded form: Open Note + waveform anchor + drag handle, all visible. Drag handle visibility is keyed off `isRecording || isHovered || dragState`. - Reduce the anchor's bar count 6 → 4 so the bars fit within the 36px button with comfortable padding. --- .../renderer/recording-widget/icon-button.tsx | 36 ++++- .../renderer/recording-widget/idle-pill.tsx | 101 +++++++++----- .../src/renderer/recording-widget/index.tsx | 11 +- .../recording-widget/recording-pill.tsx | 107 ++++----------- .../recording-widget/waveform-stop-anchor.tsx | 123 ++++++++++++++++++ 5 files changed, 253 insertions(+), 125 deletions(-) create mode 100644 apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx diff --git a/apps/desktop/src/renderer/recording-widget/icon-button.tsx b/apps/desktop/src/renderer/recording-widget/icon-button.tsx index 98d3d41f..5dcb712e 100644 --- a/apps/desktop/src/renderer/recording-widget/icon-button.tsx +++ b/apps/desktop/src/renderer/recording-widget/icon-button.tsx @@ -1,27 +1,42 @@ import React, { forwardRef } from "react"; import type { ReactNode } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; export interface IconButtonProps extends React.ButtonHTMLAttributes { - /** Accessible tooltip text. Shown via native title attribute. */ + /** Tooltip text. Rendered via radix tooltip; falsy = no tooltip wrapper. */ tooltip: string; /** Lucide / Tabler icon node, 16-18px. */ icon: ReactNode; /** When true, the button uses the destructive (red) accent. */ destructive?: boolean; + /** Side the tooltip floats on. Defaults to "left" so it doesn't get clipped + * by the right edge of the screen for right-anchored widgets. */ + tooltipSide?: "top" | "right" | "bottom" | "left"; } export const IconButton = forwardRef( function IconButton( - { tooltip, icon, destructive, disabled, className, ...rest }, + { + tooltip, + icon, + destructive, + disabled, + className, + tooltipSide = "left", + ...rest + }, ref, ) { - return ( + const button = ( ); + + if (!tooltip) { + return button; + } + + return ( + + {button} + + {tooltip} + + + ); }, ); diff --git a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx index 16abe5c3..259bc997 100644 --- a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx @@ -1,4 +1,4 @@ -import { motion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { Mic } from "lucide-react"; import { IconNotes } from "@tabler/icons-react"; import type { MeetingWidgetEdge } from "@/types/meeting-widget"; @@ -20,6 +20,12 @@ export interface IdlePillProps { const SLIVER_RIGHT = { width: 8, height: 56 }; const SLIVER_BOTTOM = { width: 56, height: 8 }; +const buttonSpring = { + type: "spring", + stiffness: 480, + damping: 28, +} as const; + export function IdlePill({ edge, hovered, @@ -28,40 +34,69 @@ export function IdlePill({ onStartRecording, startingRecording, }: IdlePillProps) { - if (hovered) { - return ( - } - onClick={onTakeNotes} - disabled={takingNotes} - /> - } - mainAnchor={ - } - onClick={onStartRecording} - disabled={startingRecording} - /> - } - /> - ); - } const sliver = edge === "right" ? SLIVER_RIGHT : SLIVER_BOTTOM; + const tooltipSide = edge === "right" ? "left" : "top"; + + // IconButtonStack's outer motion.div has `layout`, so the bounding box + // animates as the sliver gives way to two buttons. Each slot uses + // AnimatePresence so the sliver and the buttons fade/scale in & out + // rather than crossfading abruptly — the effect is the bar morphing + // outward into the buttons. return ( - + {hovered ? ( + + } + onClick={onTakeNotes} + disabled={takingNotes} + tooltipSide={tooltipSide} + /> + + ) : null} + + } + mainAnchor={ + + {hovered ? ( + + } + onClick={onStartRecording} + disabled={startingRecording} + tooltipSide={tooltipSide} + /> + + ) : ( + + )} + + } /> ); } - -// Note: PILL_SHELL_CLASS is also imported by recording-pill.tsx; keep the export. diff --git a/apps/desktop/src/renderer/recording-widget/index.tsx b/apps/desktop/src/renderer/recording-widget/index.tsx index 8218f88e..62e262bc 100644 --- a/apps/desktop/src/renderer/recording-widget/index.tsx +++ b/apps/desktop/src/renderer/recording-widget/index.tsx @@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AnimatePresence, motion } from "framer-motion"; import { api, trpcClient } from "@/trpc/react"; import { combinedLevel, useMeetingLevel } from "@/hooks/useMeetingLevel"; +import { TooltipProvider } from "@/components/ui/tooltip"; import type { MeetingWidgetEdge, MeetingWidgetState, @@ -208,7 +209,10 @@ function RecordingWidgetWindow() { dismissDetectionMutation.mutate(); }, [dismissDetectionMutation]); - const showHandle = isHovered || dragState !== null; + // While recording, the widget rests in its expanded layout (Open Note + + // waveform anchor + drag handle), so the handle is always visible. + // Idle keeps the original hover-to-reveal behavior. + const showHandle = isRecording || isHovered || dragState !== null; // Outer container anchors the visible content to the active edge. const outerJustify = @@ -250,7 +254,6 @@ function RecordingWidgetWindow() { - + + + , ); diff --git a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx index 35facbf4..ca864927 100644 --- a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx @@ -1,113 +1,50 @@ -import { motion } from "framer-motion"; -import { AlertTriangle, Loader2, Square } from "lucide-react"; import { IconNotes } from "@tabler/icons-react"; -import { Waveform } from "@/components/Waveform"; import type { MeetingWidgetEdge } from "@/types/meeting-widget"; import type { MeetingRuntimeState } from "@/types/meeting"; import { IconButton } from "./icon-button"; import { IconButtonStack } from "./icon-button-stack"; -import { PILL_SHELL_CLASS } from "./idle-pill"; - -const NUM_WAVEFORM_BARS_HOVERED = 6; -const NUM_WAVEFORM_BARS_COLLAPSED = 4; +import { WaveformStopAnchor } from "./waveform-stop-anchor"; export interface RecordingPillProps { edge: MeetingWidgetEdge; - hovered: boolean; meetingState: MeetingRuntimeState; level: number; onStop: (event: React.MouseEvent) => void; onOpenNote: () => void; } +// Recording state has no collapsed/expanded distinction — the layout is +// always at rest in expanded form (Open Note + waveform anchor + drag +// handle). Only the anchor itself toggles waveform → red Stop on its own +// internal hover, owned by WaveformStopAnchor. export function RecordingPill({ edge, - hovered, meetingState, level, onStop, onOpenNote, }: RecordingPillProps) { - const isError = meetingState === "error"; - const isStarting = meetingState === "starting"; - const isStopping = meetingState === "stopping"; - const isBusy = isStarting || isStopping; - - const waveformContent = ( -
- {Array.from({ - length: hovered ? NUM_WAVEFORM_BARS_HOVERED : NUM_WAVEFORM_BARS_COLLAPSED, - }).map((_, index) => ( - - ))} -
- ); - - if (!hovered) { - return ( - - {waveformContent} - - ); - } - - const stopIcon = isBusy ? ( - - ) : isError ? ( - - ) : ( - - ); - - const stopButton = ( - - ); - - const waveformAsAnchor = ( -
- {waveformContent} -
- ); - - const openNoteButton = ( - } - onClick={onOpenNote} - /> - ); + const tooltipSide = edge === "right" ? "left" : "top"; return ( } + onClick={onOpenNote} + tooltipSide={tooltipSide} + /> + } + mainAnchor={ + + } /> ); } diff --git a/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx b/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx new file mode 100644 index 00000000..90df20a2 --- /dev/null +++ b/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { AlertTriangle, Loader2, Square } from "lucide-react"; +import { Waveform } from "@/components/Waveform"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { MeetingRuntimeState } from "@/types/meeting"; +import { PILL_SHELL_CLASS } from "./idle-pill"; + +const NUM_WAVEFORM_BARS = 4; + +export interface WaveformStopAnchorProps { + meetingState: MeetingRuntimeState; + level: number; + onStop: (event: React.MouseEvent) => void; + tooltipSide?: "top" | "right" | "bottom" | "left"; +} + +export function WaveformStopAnchor({ + meetingState, + level, + onStop, + tooltipSide = "left", +}: WaveformStopAnchorProps) { + const [isInnerHover, setIsInnerHover] = useState(false); + + const isError = meetingState === "error"; + const isStarting = meetingState === "starting"; + const isStopping = meetingState === "stopping"; + const isBusy = isStarting || isStopping; + const showStop = isInnerHover && !isBusy && !isError; + + const button = ( + + ); + + return ( + + {button} + + Stop Recording + + + ); +} From 7cada73eebd70f2b1d6bd3fc6bb52701d53b33cb Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Wed, 27 May 2026 13:44:19 -0700 Subject: [PATCH 09/14] feat(notes): edge-anchored sliver-to-buttons morph for idle widget (PRSM-68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the idle widget animation so the sliver bar morphs in place instead of crossfading + reflowing. - Each pill now owns a fixed 36×36 frame and renders its own absolutely-positioned siblings (drag handle, secondary button) around that frame. No more shared inner-flex shifting items during hover. - The sliver and the Mic IconButton share a single anchor pinned to the screen-facing edge of the frame. The bar's right edge (or bottom edge) never moves — it just grows inward into the Mic's bounding box as it fades out, while the Mic fades in with its scale origin at that same edge. - Lift the shared DragHandle into its own module; drop the now orphaned IconButtonStack. - Pull MEETING_WIDGET_EDGE_MARGIN 12 → 6 so the bar sits closer to the screen edge at rest. --- apps/desktop/src/main/core/window-manager.ts | 2 +- .../renderer/recording-widget/drag-handle.tsx | 40 ++++ .../recording-widget/icon-button-stack.tsx | 38 ---- .../renderer/recording-widget/idle-pill.tsx | 203 ++++++++++++------ .../src/renderer/recording-widget/index.tsx | 123 ++++------- .../recording-widget/recording-pill.tsx | 75 +++++-- 6 files changed, 272 insertions(+), 209 deletions(-) create mode 100644 apps/desktop/src/renderer/recording-widget/drag-handle.tsx delete mode 100644 apps/desktop/src/renderer/recording-widget/icon-button-stack.tsx diff --git a/apps/desktop/src/main/core/window-manager.ts b/apps/desktop/src/main/core/window-manager.ts index fc36e663..7720c646 100644 --- a/apps/desktop/src/main/core/window-manager.ts +++ b/apps/desktop/src/main/core/window-manager.ts @@ -14,7 +14,7 @@ declare const RECORDING_WIDGET_WINDOW_VITE_NAME: string; export class WindowManager { private static readonly MEETING_WIDGET_WINDOW_WIDTH = 380 as const; private static readonly MEETING_WIDGET_WINDOW_HEIGHT = 240 as const; - private static readonly MEETING_WIDGET_EDGE_MARGIN = 12 as const; + private static readonly MEETING_WIDGET_EDGE_MARGIN = 6 as const; private static readonly MEETING_WIDGET_PARALLEL_MARGIN = 24 as const; private mainWindow: BrowserWindow | null = null; private onboardingWindow: BrowserWindow | null = null; diff --git a/apps/desktop/src/renderer/recording-widget/drag-handle.tsx b/apps/desktop/src/renderer/recording-widget/drag-handle.tsx new file mode 100644 index 00000000..a45145fe --- /dev/null +++ b/apps/desktop/src/renderer/recording-widget/drag-handle.tsx @@ -0,0 +1,40 @@ +import { motion } from "framer-motion"; +import type { MeetingWidgetEdge } from "@/types/meeting-widget"; + +export interface DragHandleProps { + edge: MeetingWidgetEdge; + visible: boolean; + onPointerDown: (event: React.PointerEvent) => void; +} + +export function DragHandle({ edge, visible, onPointerDown }: DragHandleProps) { + const isVertical = edge === "right"; + return ( + +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/recording-widget/icon-button-stack.tsx b/apps/desktop/src/renderer/recording-widget/icon-button-stack.tsx deleted file mode 100644 index fbdb858c..00000000 --- a/apps/desktop/src/renderer/recording-widget/icon-button-stack.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { motion } from "framer-motion"; -import type { ReactNode } from "react"; -import type { MeetingWidgetEdge } from "@/types/meeting-widget"; - -export interface IconButtonStackProps { - edge: MeetingWidgetEdge; - /** Element that's always visible (collapsed and expanded). */ - mainAnchor: ReactNode; - /** Secondary slot that visually flips position with edge rotation. */ - secondaryLeading?: ReactNode; - /** Optional second secondary on the opposite side of main-anchor. */ - secondaryTrailing?: ReactNode; -} - -export function IconButtonStack({ - edge, - mainAnchor, - secondaryLeading, - secondaryTrailing, -}: IconButtonStackProps) { - if (edge === "right") { - return ( - - {secondaryLeading} - {mainAnchor} - {secondaryTrailing} - - ); - } - // edge === "bottom" — rotate 90° CW: top→right, bottom→left. - return ( - - {secondaryTrailing} - {mainAnchor} - {secondaryLeading} - - ); -} diff --git a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx index 259bc997..e98f42b8 100644 --- a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx @@ -3,7 +3,7 @@ import { Mic } from "lucide-react"; import { IconNotes } from "@tabler/icons-react"; import type { MeetingWidgetEdge } from "@/types/meeting-widget"; import { IconButton } from "./icon-button"; -import { IconButtonStack } from "./icon-button-stack"; +import { DragHandle } from "./drag-handle"; export const PILL_SHELL_CLASS = "relative pointer-events-auto bg-black/80 dark:bg-black/70 backdrop-blur-md ring-[1px] ring-black/60 shadow-[0px_0px_15px_0px_rgba(0,0,0,0.40)] before:content-[''] before:absolute before:inset-[1px] before:outline before:outline-white/15 before:pointer-events-none"; @@ -15,14 +15,28 @@ export interface IdlePillProps { takingNotes: boolean; onStartRecording: () => void; startingRecording: boolean; + showHandle: boolean; + onDragStart: (event: React.PointerEvent) => void; } -const SLIVER_RIGHT = { width: 8, height: 56 }; -const SLIVER_BOTTOM = { width: 56, height: 8 }; +// Frame = the 36×36 Mic-slot. Sliver and Mic occupy this exact spot, so +// the bar morphs in place with no displacement. Take Notes and the drag +// handle are absolutely positioned around the frame and fade/scale in +// when hovered, without affecting layout. +const FRAME = 36; +const GAP = 6; +const TAKE_NOTES = 36; +const HANDLE_SHORT = 18; -const buttonSpring = { +const anchorSpring = { type: "spring", - stiffness: 480, + stiffness: 420, + damping: 32, +} as const; + +const popSpring = { + type: "spring", + stiffness: 460, damping: 28, } as const; @@ -33,70 +47,127 @@ export function IdlePill({ takingNotes, onStartRecording, startingRecording, + showHandle, + onDragStart, }: IdlePillProps) { - const sliver = edge === "right" ? SLIVER_RIGHT : SLIVER_BOTTOM; - const tooltipSide = edge === "right" ? "left" : "top"; + const isVertical = edge === "right"; + const tooltipSide = isVertical ? "left" : "top"; - // IconButtonStack's outer motion.div has `layout`, so the bounding box - // animates as the sliver gives way to two buttons. Each slot uses - // AnimatePresence so the sliver and the buttons fade/scale in & out - // rather than crossfading abruptly — the effect is the bar morphing - // outward into the buttons. - return ( - - {hovered ? ( - - } - onClick={onTakeNotes} - disabled={takingNotes} - tooltipSide={tooltipSide} - /> - - ) : null} - + // Sliver and Mic share the frame's center. Sliver may exceed the + // 36×36 box on one axis; absolute positioning lets it overflow visually + // without affecting layout. + const sliverDims = isVertical + ? { width: 8, height: 56 } + : { width: 56, height: 8 }; + const micDims = { width: FRAME, height: FRAME }; + + // Where Take Notes lives relative to the frame. + const takeNotesStyle = isVertical + ? { right: 0, top: -(TAKE_NOTES + GAP) } + : { right: -(TAKE_NOTES + GAP), top: 0 }; + const takeNotesEnter = isVertical ? { y: 14 } : { x: -14 }; + + // Where the drag handle lives relative to the frame. + const handleStyle = isVertical + ? { + bottom: -(HANDLE_SHORT + GAP), + left: "50%", + transform: "translateX(-50%)", } - mainAnchor={ - - {hovered ? ( - - } - onClick={onStartRecording} - disabled={startingRecording} - tooltipSide={tooltipSide} - /> - - ) : ( - + {/* Sliver shell — bar at rest; morphs into the Mic's bounding box + and fades out as the Mic IconButton fades in. Pinned to the + screen-facing edge so the bar never moves away from it. */} +
+ +
+ + {/* Mic IconButton — same anchor as the bar, scales out from the + screen edge to feel like the bar morphing into a button. */} +
+ + } + onClick={onStartRecording} + disabled={startingRecording} + tooltipSide={tooltipSide} + /> + +
+ + {/* Take Notes — absolute, outside the frame on the away-from-edge + side. Slides toward the anchor on exit. */} + + {hovered ? ( + + } + onClick={onTakeNotes} + disabled={takingNotes} + tooltipSide={tooltipSide} /> - )} - - } - /> +
+ ) : null} +
+ + {/* Drag handle — opposite side of Take Notes, follows hover state. */} +
+ +
+
); } diff --git a/apps/desktop/src/renderer/recording-widget/index.tsx b/apps/desktop/src/renderer/recording-widget/index.tsx index 62e262bc..e63d8787 100644 --- a/apps/desktop/src/renderer/recording-widget/index.tsx +++ b/apps/desktop/src/renderer/recording-widget/index.tsx @@ -27,40 +27,6 @@ const queryClient = new QueryClient({ type DragState = { pointerOffsetX: number; pointerOffsetY: number }; -interface DragHandleProps { - edge: MeetingWidgetEdge; - visible: boolean; - onPointerDown: (event: React.PointerEvent) => void; -} - -function DragHandle({ edge, visible, onPointerDown }: DragHandleProps) { - const isVertical = edge === "right"; - return ( - -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- ); -} - function RecordingWidgetWindow() { const initialStateQuery = api.meetingWidget.getState.useQuery(); const [liveState, setLiveState] = useState(null); @@ -214,23 +180,13 @@ function RecordingWidgetWindow() { // Idle keeps the original hover-to-reveal behavior. const showHandle = isRecording || isHovered || dragState !== null; - // Outer container anchors the visible content to the active edge. + // Outer container anchors the pill's 36×36 frame to the active edge. + // Each pill owns its own absolutely-positioned drag handle + secondary + // button around that frame, so no inner-flex / layout-shift dance. const outerJustify = edge === "right" ? "items-center justify-end pr-1" : "items-end justify-center pb-1"; - const innerLayout = - edge === "right" - ? "flex flex-col items-center gap-1.5" - : "flex flex-row items-center gap-1.5"; - - const dragHandleEl = showHandle ? ( - - ) : null; return (
-
- {edge === "bottom" ? dragHandleEl : null} - - {isRecording ? ( - - ) : isDetection && meetingDetection ? ( - - ) : ( - - )} - - {edge === "right" ? dragHandleEl : null} -
+ + {isRecording ? ( + + ) : isDetection && meetingDetection ? ( + + ) : ( + + )} +
diff --git a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx index ca864927..c216d764 100644 --- a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx @@ -2,8 +2,8 @@ import { IconNotes } from "@tabler/icons-react"; import type { MeetingWidgetEdge } from "@/types/meeting-widget"; import type { MeetingRuntimeState } from "@/types/meeting"; import { IconButton } from "./icon-button"; -import { IconButtonStack } from "./icon-button-stack"; import { WaveformStopAnchor } from "./waveform-stop-anchor"; +import { DragHandle } from "./drag-handle"; export interface RecordingPillProps { edge: MeetingWidgetEdge; @@ -11,40 +11,77 @@ export interface RecordingPillProps { level: number; onStop: (event: React.MouseEvent) => void; onOpenNote: () => void; + showHandle: boolean; + onDragStart: (event: React.PointerEvent) => void; } -// Recording state has no collapsed/expanded distinction — the layout is -// always at rest in expanded form (Open Note + waveform anchor + drag -// handle). Only the anchor itself toggles waveform → red Stop on its own -// internal hover, owned by WaveformStopAnchor. +// Recording state always lives in its expanded form — same 36×36 frame +// as the idle pill, with Open Note + drag handle around it. The frame +// position never changes; only the anchor's internal waveform↔stop swap +// reacts to hover. +const FRAME = 36; +const GAP = 6; +const OPEN_NOTE = 36; +const HANDLE_SHORT = 18; + export function RecordingPill({ edge, meetingState, level, onStop, onOpenNote, + showHandle, + onDragStart, }: RecordingPillProps) { - const tooltipSide = edge === "right" ? "left" : "top"; + const isVertical = edge === "right"; + const tooltipSide = isVertical ? "left" : "top"; - return ( - } - onClick={onOpenNote} - tooltipSide={tooltipSide} - /> + const openNoteStyle = isVertical + ? { right: 0, top: -(OPEN_NOTE + GAP) } + : { right: -(OPEN_NOTE + GAP), top: 0 }; + const handleStyle = isVertical + ? { + bottom: -(HANDLE_SHORT + GAP), + left: "50%", + transform: "translateX(-50%)", } - mainAnchor={ + : { + right: -(HANDLE_SHORT + GAP), + top: "50%", + transform: "translateY(-50%)", + }; + + return ( +
+ {/* Waveform / Stop anchor — always present, owns its own + internal hover state for the waveform→Stop swap. */} +
- } - /> +
+ + {/* Open Note — always rendered while recording. */} +
+ } + onClick={onOpenNote} + tooltipSide={tooltipSide} + /> +
+ + {/* Drag handle — always visible while recording. */} +
+ +
+
); } From faeb3fc4cf0846a61cb2d06651a38be708500345 Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Wed, 27 May 2026 13:49:57 -0700 Subject: [PATCH 10/14] fix(notes): make widget hit-zone span the whole expanded bounding box (PRSM-68) Cursor crossing the gap between Mic, drag handle, or Take Notes would fall off the hit-zone and collapse the pill mid-traversal. - Add an invisible hit-zone underlay sized to the full expanded bounding box (buttons + gaps). Underlay sits behind the buttons, so the actual buttons still own clicks; the underlay only catches the "still inside the widget" mousemove signal while the cursor is in a gap. - For idle, the underlay's data-hit-zone is only active while hovered, so it doesn't extend the initial trigger area. For recording (always expanded), the underlay is always a hit-zone. - Drive-by: move the bottom-edge drag handle to the left of the frame so it doesn't overlap with Open Note / Take Notes (which sit to the right). --- .../renderer/recording-widget/idle-pill.tsx | 36 +++++++++++++++++-- .../recording-widget/recording-pill.tsx | 28 +++++++++++++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx index e98f42b8..262e7112 100644 --- a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx @@ -67,19 +67,40 @@ export function IdlePill({ : { right: -(TAKE_NOTES + GAP), top: 0 }; const takeNotesEnter = isVertical ? { y: 14 } : { x: -14 }; - // Where the drag handle lives relative to the frame. - const handleStyle = isVertical + // Where the drag handle lives relative to the frame — opposite side + // from Take Notes so they don't overlap (right-edge: handle below, + // bottom-edge: handle to the left while Take Notes is to the right). + const handleStyle: React.CSSProperties = isVertical ? { bottom: -(HANDLE_SHORT + GAP), left: "50%", transform: "translateX(-50%)", } : { - right: -(HANDLE_SHORT + GAP), + left: -(HANDLE_SHORT + GAP), top: "50%", transform: "translateY(-50%)", }; + // Invisible hit-zone underlay covering the full expanded bounding + // box (Take Notes + frame + drag handle + the gaps in-between). Buttons + // and the sliver sit on top of it, so it only catches mousemove events + // when the cursor is in a gap — which is enough to keep `isHovered` + // true while the user travels between buttons. + const hitUnderlayStyle: React.CSSProperties = isVertical + ? { + top: -(TAKE_NOTES + GAP), + left: 0, + width: FRAME, + height: TAKE_NOTES + GAP + FRAME + GAP + HANDLE_SHORT, + } + : { + top: 0, + left: -(HANDLE_SHORT + GAP), + width: HANDLE_SHORT + GAP + FRAME + GAP + TAKE_NOTES, + height: FRAME, + }; + // Pin the anchor (bar / Mic) to the screen-facing edge of the frame so // the bar's edge stays put while it morphs inward. The wrapper owns the // CSS transform that handles the perpendicular centering; the inner @@ -96,6 +117,15 @@ export function IdlePill({ style={{ width: FRAME, height: FRAME }} data-hit-zone={hovered ? "true" : undefined} > + {/* Hit-zone underlay — only active while hovered. Keeps the cursor + "inside" the widget as it travels across button gaps so the pill + doesn't collapse mid-traversal. */} +
+ {/* Sliver shell — bar at rest; morphs into the Mic's bounding box and fades out as the Mic IconButton fades in. Pinned to the screen-facing edge so the bar never moves away from it. */} diff --git a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx index c216d764..ba78ef67 100644 --- a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx @@ -36,20 +36,36 @@ export function RecordingPill({ const isVertical = edge === "right"; const tooltipSide = isVertical ? "left" : "top"; - const openNoteStyle = isVertical + const openNoteStyle: React.CSSProperties = isVertical ? { right: 0, top: -(OPEN_NOTE + GAP) } : { right: -(OPEN_NOTE + GAP), top: 0 }; - const handleStyle = isVertical + // Drag handle on the opposite side from Open Note. + const handleStyle: React.CSSProperties = isVertical ? { bottom: -(HANDLE_SHORT + GAP), left: "50%", transform: "translateX(-50%)", } : { - right: -(HANDLE_SHORT + GAP), + left: -(HANDLE_SHORT + GAP), top: "50%", transform: "translateY(-50%)", }; + // Hit-zone underlay covering the full expanded bounding box so the + // cursor doesn't lose hover crossing the gaps between buttons. + const hitUnderlayStyle: React.CSSProperties = isVertical + ? { + top: -(OPEN_NOTE + GAP), + left: 0, + width: FRAME, + height: OPEN_NOTE + GAP + FRAME + GAP + HANDLE_SHORT, + } + : { + top: 0, + left: -(HANDLE_SHORT + GAP), + width: HANDLE_SHORT + GAP + FRAME + GAP + OPEN_NOTE, + height: FRAME, + }; return (
+ {/* Hit-zone underlay — always active during recording. */} +
{/* Waveform / Stop anchor — always present, owns its own internal hover state for the waveform→Stop swap. */}
From 314265b8cd7508b93adf15ac49e3979ad5c6b29b Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Wed, 27 May 2026 13:58:07 -0700 Subject: [PATCH 11/14] fix(notes): give collapsed widget bar a white-tinted ring (PRSM-68) The sliver's dark ring blended into dark wallpapers, making the bar nearly invisible at rest. Override PILL_SHELL_CLASS's ring-black/60 with ring-white/40 just on the morphing sliver so it stays visible without affecting the rest of the pill shells. --- apps/desktop/src/renderer/recording-widget/idle-pill.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx index 262e7112..6fbac454 100644 --- a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx @@ -138,7 +138,9 @@ export function IdlePill({ : { ...sliverDims, opacity: 1 } } transition={anchorSpring} - className={`${PILL_SHELL_CLASS} rounded-full before:rounded-full`} + // Override PILL_SHELL_CLASS's dark ring with a white-tinted one + // so the bar stays visible on dark wallpapers. + className={`${PILL_SHELL_CLASS} ring-white/40 rounded-full before:rounded-full`} />
From 9191146775a4e484c593269b3d93518c4b80ffa5 Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Wed, 27 May 2026 14:19:41 -0700 Subject: [PATCH 12/14] fix(notes): high-contrast widget button hover (PRSM-68) Resting `bg-black/80` looked faded on light wallpapers, and the prior `hover:bg-white/10` made the button vanish into the background. Use fully opaque black + a brighter `border-white/55` on hover so the button visibly "lifts" on any wallpaper instead of disappearing. --- apps/desktop/src/renderer/recording-widget/icon-button.tsx | 7 +++++-- .../src/renderer/recording-widget/waveform-stop-anchor.tsx | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/recording-widget/icon-button.tsx b/apps/desktop/src/renderer/recording-widget/icon-button.tsx index 5dcb712e..07f3cf0b 100644 --- a/apps/desktop/src/renderer/recording-widget/icon-button.tsx +++ b/apps/desktop/src/renderer/recording-widget/icon-button.tsx @@ -44,9 +44,12 @@ export const IconButton = forwardRef( "border border-white/15 bg-black/80 backdrop-blur-md", "shadow-[0_8px_24px_rgba(0,0,0,0.35)]", "transition-colors", + // Hover state: fully opaque black + bright border so the button + // pops on light wallpapers (where the resting 80% black still + // shows the bg through and looks faded). destructive - ? "text-red-400 hover:border-white/35 hover:bg-white/10" - : "text-white/85 hover:border-white/35 hover:bg-white/10 hover:text-white", + ? "text-red-400 hover:border-white/55 hover:bg-black" + : "text-white/85 hover:border-white/55 hover:bg-black hover:text-white", "disabled:cursor-not-allowed disabled:opacity-50", className ?? "", ].join(" ")} diff --git a/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx b/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx index 90df20a2..2ba9d55d 100644 --- a/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx +++ b/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx @@ -47,8 +47,10 @@ export function WaveformStopAnchor({ "border border-white/15 bg-black/80 backdrop-blur-md", "shadow-[0_8px_24px_rgba(0,0,0,0.35)]", "transition-colors", + // Stop-hover state: fully opaque black + bright border so it pops + // on light wallpapers and reads clearly as "destructive on hover". showStop - ? "text-red-400 border-white/35 bg-white/10" + ? "text-red-400 border-white/55 bg-black" : "text-white/85", "disabled:cursor-not-allowed disabled:opacity-60", PILL_SHELL_CLASS, From f11551d371ef6fdccb0c928b3eac51e61906f5aa Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Wed, 27 May 2026 14:28:31 -0700 Subject: [PATCH 13/14] chore(notes): address PRSM-68 review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead `isTahoeOrLater` branch in `getTrafficLightPosition` — earlier change flattened the return to `{x:16, y:16}` but left the variable behind, tripping the unused-var warning. - Show "Dismiss" instead of "Stop Recording" on the WaveformStopAnchor tooltip when the meeting is in error state. - Add unit coverage for `updateMeetingWidgetWindowPositionFree`: grip-point alignment plus clamping against both corners of the work area, since the snap regression originated in this offset formula going untested. --- apps/desktop/src/main/core/window-manager.ts | 14 ------ .../recording-widget/waveform-stop-anchor.tsx | 4 +- .../tests/main/window-manager-bounds.test.ts | 45 +++++++++++++++++++ 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/core/window-manager.ts b/apps/desktop/src/main/core/window-manager.ts index 7720c646..6d74c5b3 100644 --- a/apps/desktop/src/main/core/window-manager.ts +++ b/apps/desktop/src/main/core/window-manager.ts @@ -21,24 +21,10 @@ export class WindowManager { private meetingWidgetWindow: BrowserWindow | null = null; private themeListenerSetup: boolean = false; - /** - * Get the correct traffic light position based on macOS version. - * macOS Tahoe (26+) has larger, redesigned traffic light buttons as part of - * the "Liquid Glass" design language that require a different y-offset. - * Electron does not handle this automatically - apps must detect OS version. - * See: https://github.com/microsoft/vscode/pull/280593 - */ private getTrafficLightPosition(): { x: number; y: number } { if (process.platform !== "darwin") { return { x: 20, y: 16 }; // Not used on non-macOS, but return default } - - // process.getSystemVersion() returns marketing version (e.g., "26.0.0") - // vs os.release() which returns Darwin kernel version (e.g., "25.1.0") - const systemVersion = process.getSystemVersion(); - const majorVersion = parseInt(systemVersion.split(".")[0], 10); - const isTahoeOrLater = majorVersion >= 26; - return { x: 16, y: 16 }; } diff --git a/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx b/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx index 2ba9d55d..dc8eff0c 100644 --- a/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx +++ b/apps/desktop/src/renderer/recording-widget/waveform-stop-anchor.tsx @@ -114,11 +114,13 @@ export function WaveformStopAnchor({ ); + const tooltipText = isError ? "Dismiss" : "Stop Recording"; + return ( {button} - Stop Recording + {tooltipText} ); diff --git a/apps/desktop/tests/main/window-manager-bounds.test.ts b/apps/desktop/tests/main/window-manager-bounds.test.ts index e6429227..cbe11c6b 100644 --- a/apps/desktop/tests/main/window-manager-bounds.test.ts +++ b/apps/desktop/tests/main/window-manager-bounds.test.ts @@ -81,3 +81,48 @@ describe("WindowManager.snapMeetingWidgetToEdge", () => { expect(mgr.snapMeetingWidgetToEdge(100, 100)).toBeNull(); }); }); + +describe("WindowManager.updateMeetingWidgetWindowPositionFree", () => { + it("places the window so the grip point stays under the cursor", () => { + // Window starts at (100, 100). User grabbed the handle at clientX=370, + // clientY=20 inside the window (so the grip was at screen (470, 120)). + // They now drag the cursor to screen (800, 400). Expected window + // top-left: (800 - 370, 400 - 20) = (430, 380), so the grip lands + // back under the cursor. + const mgr = makeManager(); + attachFakeWindow(mgr, { x: 100, y: 100 }); + const next = mgr.updateMeetingWidgetWindowPositionFree(800, 400, 370, 20); + expect(next).not.toBeNull(); + expect(next?.x).toBe(430); + expect(next?.y).toBe(380); + }); + + it("clamps the window to the work area", () => { + // Cursor + grip would put the window at (-200, -200); clamp keeps the + // window inside the 1440×900 work area at (0, 0). + const mgr = makeManager(); + attachFakeWindow(mgr, { x: 100, y: 100 }); + const next = mgr.updateMeetingWidgetWindowPositionFree(170, 170, 370, 370); + expect(next?.x).toBe(0); + expect(next?.y).toBe(0); + }); + + it("clamps against the bottom-right when dragged past the edge", () => { + // 380×240 window. Work area 1440×900. Max x = 1440-380 = 1060, + // max y = 900-240 = 660. screen (2000, 1500) with no grip offset + // would land at (2000, 1500); clamp pins to (1060, 660). + const mgr = makeManager(); + attachFakeWindow(mgr, { x: 100, y: 100 }); + const next = mgr.updateMeetingWidgetWindowPositionFree(2000, 1500, 0, 0); + expect(next?.x).toBe(1060); + expect(next?.y).toBe(660); + }); + + it("returns null when the widget window is absent", () => { + const mgr = makeManager(); + (mgr as any).meetingWidgetWindow = null; + expect( + mgr.updateMeetingWidgetWindowPositionFree(100, 100, 0, 0), + ).toBeNull(); + }); +}); From 6145a1037fff5f3894fcd1c8aa618408ea450a4e Mon Sep 17 00:00:00 2001 From: Naomi Chopra Date: Wed, 27 May 2026 14:31:18 -0700 Subject: [PATCH 14/14] feat(notes): drag handle hover, grab cursor, tooltip (PRSM-68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drag handle had no visual hover feedback, no cursor change, and no tooltip — easy to miss as an interactive element. - Match IconButton's hover language (opaque black + white/55 border + brighter dots) so the handle lifts on hover instead of staying dim. - `cursor-grab` on hover, `cursor-grabbing` while pressed. - Shadcn/radix tooltip "Drag to move", with `tooltipSide` matching the pill's other buttons so it doesn't clip at the screen edge. --- .../renderer/recording-widget/drag-handle.tsx | 41 ++++++++++++++++--- .../renderer/recording-widget/idle-pill.tsx | 7 +++- .../recording-widget/recording-pill.tsx | 7 +++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/recording-widget/drag-handle.tsx b/apps/desktop/src/renderer/recording-widget/drag-handle.tsx index a45145fe..fc5ca12d 100644 --- a/apps/desktop/src/renderer/recording-widget/drag-handle.tsx +++ b/apps/desktop/src/renderer/recording-widget/drag-handle.tsx @@ -1,15 +1,28 @@ import { motion } from "framer-motion"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import type { MeetingWidgetEdge } from "@/types/meeting-widget"; export interface DragHandleProps { edge: MeetingWidgetEdge; visible: boolean; onPointerDown: (event: React.PointerEvent) => void; + /** Side the tooltip floats on; matches the sibling buttons. */ + tooltipSide?: "top" | "right" | "bottom" | "left"; } -export function DragHandle({ edge, visible, onPointerDown }: DragHandleProps) { +export function DragHandle({ + edge, + visible, + onPointerDown, + tooltipSide = "left", +}: DragHandleProps) { const isVertical = edge === "right"; - return ( + + const button = (
); + + return ( + + {button} + + Drag to move + + + ); } diff --git a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx index 6fbac454..a27818f9 100644 --- a/apps/desktop/src/renderer/recording-widget/idle-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/idle-pill.tsx @@ -198,7 +198,12 @@ export function IdlePill({ {/* Drag handle — opposite side of Take Notes, follows hover state. */}
- +
); diff --git a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx index ba78ef67..ea73f479 100644 --- a/apps/desktop/src/renderer/recording-widget/recording-pill.tsx +++ b/apps/desktop/src/renderer/recording-widget/recording-pill.tsx @@ -102,7 +102,12 @@ export function RecordingPill({ {/* Drag handle — always visible while recording. */}
- +
);