···11+# Desktop Windows Implementation Plan
22+33+## Overview
44+Implement desktop window management features including titlebar controls, size/position persistence, pin controls, escape behavior, and animations.
55+66+## Current Architecture
77+- **Window creation**: `backend/electron/ipc.ts:window-open` handler creates `BrowserWindow` instances
88+- **Frontend API**: `window.app.window.open(url, options)` exposed via preload
99+- **Window options**: `width`, `height`, `x`, `y`, `modal`, `keepLive`, `alwaysOnTop`, `resizable`, `transparent`, `frame`
1010+- **Default**: `frame: false` (frameless windows)
1111+- **Dragging**: `app/drag.js` - click-and-hold (300ms) then manual move via IPC
1212+- **Settings**: `app/config.js` (core prefs), `app/settings/settings.js` (UI)
1313+- **Animation**: `animate.js` exists but unused - window bounds animation
1414+1515+---
1616+1717+## Implementation Tasks
1818+1919+### 1. Title Bar Hide/Show Preference
2020+**Files to modify:**
2121+- `app/config.js` - Add `hideTitleBar` boolean pref (default: true)
2222+- `app/settings/settings.js` - Pref UI is auto-generated from schema, no changes needed
2323+- `backend/electron/ipc.ts` - Read pref when opening windows, set `frame` accordingly
2424+2525+**Implementation:**
2626+```javascript
2727+// app/config.js prefsSchema.properties
2828+"hideTitleBar": {
2929+ "description": "Hide window title bars by default",
3030+ "type": "boolean",
3131+ "default": true
3232+}
3333+```
3434+3535+In `ipc.ts:window-open`:
3636+- Get core prefs via pubsub or direct datastore read
3737+- Apply `frame: !hideTitleBar` unless explicitly overridden by `options.frame`
3838+3939+---
4040+4141+### 2. Titlebar Show on Hover at Top Edge
4242+**Files to create/modify:**
4343+- `app/titlebar-hover.js` - New file for titlebar hover detection
4444+- `backend/electron/ipc.ts` - Add `window-set-frame` IPC handler to toggle titlebar
4545+4646+**Implementation:**
4747+- Create mouse event listener that detects when cursor is at screen top edge (y < 5px)
4848+- When hovering at top edge for 200ms, send IPC to enable frame temporarily
4949+- When cursor moves away from titlebar area, send IPC to hide frame again
5050+- This requires recreating the BrowserWindow or using `setAutoHideMenuBar` (limited)
5151+- **Alternative**: Use a custom titlebar overlay instead of native frame
5252+5353+**Note**: Native frame cannot be toggled at runtime in Electron. Better approach:
5454+- Always use frameless windows
5555+- Create a custom titlebar component that shows on hover
5656+- Implement in each window's HTML or via content script injection
5757+5858+---
5959+6060+### 3. Fix Links Opening with Titlebars
6161+**Investigation needed:**
6262+- In `backend/electron/main.ts:setWindowOpenHandler`, check how `window.open()` from web content works
6363+- The handler uses `featuresMap` from window.open features string
6464+- If no frame feature specified, it may default differently
6565+6666+**Fix:**
6767+- Ensure `frame: false` is explicitly set in `setWindowOpenHandler` options (line ~844)
6868+- Already has `...(featuresMap as Electron.BrowserWindowConstructorOptions)` - need to ensure default
6969+7070+---
7171+7272+### 4. Window Draggable API Option
7373+**Files to modify:**
7474+- `backend/electron/ipc.ts:window-open` - Store `draggable` param in window info
7575+- `app/drag.js` - Check window's draggable setting before enabling drag
7676+7777+**Implementation:**
7878+```javascript
7979+// window-open options
8080+draggable: true // default
8181+8282+// In drag.js, check via IPC:
8383+const isDraggable = await window.app.invoke('window-is-draggable');
8484+if (!isDraggable) return;
8585+```
8686+8787+Add new IPC handler `window-is-draggable` that reads from window params.
8888+8989+---
9090+9191+### 5. Window Position/Size Persistence
9292+**Files to modify:**
9393+- `app/config.js` - Add `persistWindowState` boolean pref
9494+- `backend/electron/ipc.ts` - Save/restore window bounds based on key/URL
9595+- `backend/electron/datastore.ts` - Add window_state table or use extension_settings
9696+9797+**Implementation:**
9898+1. Add pref `persistWindowState` (default: false)
9999+2. When window with `persistState: true` or `key` is created, check datastore for saved bounds
100100+3. When window is moved/resized, save bounds (debounced)
101101+4. Store in `extension_settings` table with namespace `window_state`
102102+103103+```javascript
104104+// Schema for window_state
105105+{
106106+ key: string, // window key or URL hash
107107+ x: number,
108108+ y: number,
109109+ width: number,
110110+ height: number,
111111+ lastUpdated: timestamp
112112+}
113113+```
114114+115115+Add IPC handlers:
116116+- `window-state-save`: Save bounds for key
117117+- `window-state-load`: Load bounds for key
118118+119119+Add to `window-open`:
120120+- If `persistState: true` and key exists, load saved bounds
121121+- On window move/resize, save (via 'moved'/'resized' events)
122122+123123+---
124124+125125+### 6. Pin Window Controls
126126+**Files to modify:**
127127+- `backend/electron/ipc.ts` - Add `window-set-always-on-top` IPC handler
128128+- `extensions/cmd/` - Add pin commands
129129+130130+**Implementation:**
131131+1. Add IPC handler:
132132+```javascript
133133+ipcMain.handle('window-set-always-on-top', async (ev, msg) => {
134134+ const win = BrowserWindow.fromId(msg.id);
135135+ win.setAlwaysOnTop(msg.value, msg.level || 'normal');
136136+ // levels: 'normal', 'floating', 'torn-off-menu', 'modal-panel', 'main-menu', 'status', 'pop-up-menu', 'screen-saver'
137137+ return { success: true };
138138+});
139139+```
140140+141141+2. Add commands:
142142+- `pin`: Toggle always-on-top for current window
143143+- `pin app`: Set alwaysOnTop with level 'floating' (above other app windows)
144144+- `pin os`: Set alwaysOnTop with level 'screen-saver' (above all windows)
145145+- `unpin`: Remove always-on-top
146146+147147+---
148148+149149+### 7. Configurable Escape Behavior
150150+**Already implemented** in `windows.ts:addEscHandler`:
151151+- Supports `escapeMode: 'close' | 'navigate' | 'auto'`
152152+153153+**Documentation needed** - Update `docs/api.md` to document this option.
154154+155155+**Add to window.open API** - Already works, just needs to be passed in options:
156156+```javascript
157157+window.app.window.open(url, { escapeMode: 'navigate' });
158158+```
159159+160160+---
161161+162162+### 8. Window Animation API
163163+**Files to modify:**
164164+- `backend/electron/ipc.ts` - Add animation support to window-open and new `window-animate` handler
165165+- Move `animate.js` logic into backend
166166+167167+**Implementation:**
168168+1. Add new IPC handler `window-animate`:
169169+```javascript
170170+ipcMain.handle('window-animate', async (ev, msg) => {
171171+ const { id, from, to, duration } = msg;
172172+ const win = BrowserWindow.fromId(id);
173173+ // Animate from {x,y,w,h} to {x,y,w,h} over duration ms
174174+ // Use setInterval with ~10ms ticks
175175+});
176176+```
177177+178178+2. Add animation options to window-open:
179179+```javascript
180180+// Options
181181+{
182182+ animation: {
183183+ from: { x, y, width, height }, // Start position (optional, default: edge of screen)
184184+ duration: 150 // ms
185185+ }
186186+}
187187+```
188188+189189+3. Frontend API wrapper:
190190+```javascript
191191+window.app.window.animate(windowId, { from, to, duration });
192192+```
193193+194194+---
195195+196196+### 9. Update Slides to Use Animation
197197+**Files to modify:**
198198+- `extensions/slides/background.js` - Use animation API instead of direct positioning
199199+200200+**Implementation:**
201201+In `executeItem()`:
202202+```javascript
203203+// Instead of opening at final position, open at off-screen position
204204+// Then animate to final position
205205+const offScreen = calculateOffScreenPosition(item.screenEdge);
206206+const finalPos = { x, y, width, height };
207207+208208+const result = await api.window.open(item.address, {
209209+ ...params,
210210+ x: offScreen.x,
211211+ y: offScreen.y,
212212+ animation: {
213213+ to: finalPos,
214214+ duration: 150
215215+ }
216216+});
217217+```
218218+219219+---
220220+221221+## Implementation Order
222222+223223+1. **Phase 1: Core Window Options** (Low risk, foundation)
224224+ - Fix links opening with titlebars
225225+ - Window draggable API option
226226+ - Document escape behavior
227227+228228+2. **Phase 2: Preferences** (Medium complexity)
229229+ - Titlebar hide/show pref
230230+ - Window persistence pref and implementation
231231+232232+3. **Phase 3: Pin Controls** (Standalone feature)
233233+ - Add IPC handler
234234+ - Add commands
235235+236236+4. **Phase 4: Titlebar Hover** (Complex, may need custom solution)
237237+ - Investigate native vs custom approach
238238+ - Implement chosen solution
239239+240240+5. **Phase 5: Animation** (Standalone feature)
241241+ - Add animation IPC handler
242242+ - Update slides extension
243243+244244+---
245245+246246+## Testing Strategy
247247+- Test each feature in isolation
248248+- Test with existing extensions (slides, peeks, cmd)
249249+- Test persistence across app restarts
250250+- Test animation smoothness
251251+- Test pin levels work correctly on macOS
252252+253253+---
254254+255255+## Files Summary
256256+257257+### New Files
258258+- `app/titlebar-hover.js` - Titlebar hover detection (if custom approach)
259259+260260+### Modified Files
261261+- `app/config.js` - Add prefs: `hideTitleBar`, `persistWindowState`
262262+- `backend/electron/ipc.ts` - Add handlers: `window-set-always-on-top`, `window-animate`, `window-state-save`, `window-state-load`, `window-is-draggable`
263263+- `backend/electron/main.ts` - Ensure `frame: false` default in setWindowOpenHandler
264264+- `app/drag.js` - Check draggable param
265265+- `extensions/slides/background.js` - Use animation API
266266+- `extensions/cmd/commands/` - Add pin commands
267267+- `docs/api.md` - Document new options
268268+269269+### Files to Remove
270270+- `animate.js` - Logic moved to backend
+1-1
AGENTS.md
···99**Railway (Server Deployment):**
1010- Project: `amusing-courtesy`
1111- Service: `peek-node`
1212-- URL: (set in .env as PEEK_PROD_URL)
1212+- URL: `https://peek-node.up.railway.app`
1313- Link command: `cd backend/server && railway link -p amusing-courtesy`
1414- Deploy: `cd backend/server && railway up -d`
1515- Logs: `railway logs -n 50`
+1-1
DEVELOPMENT.md
···417417railway logs -n 50
418418419419# Health check
420420-curl $PEEK_PROD_URL/
420420+curl https://peek-node.up.railway.app/
421421```
422422423423**Deployment Order (Server + Mobile):**
+15-15
TODO.md
···1313Today
1414- [~][release] build and deploy release versions of desktop, ios, and server
1515- [ ][workflow] agents need policy to never read outside workspace; spawned explore agents don't inherit policies
1616+- [ ][security] remove production server endpoint from source - should only be in .env files or user-entered
16171718Later
1819- [ ][desktop] access to notes on filesystem, syncing them as markdown files in ~/sync/Notes/peek
···124125## Desktop windows
125126126127Title bar and controls
127127-- [ ] add universal titlebar hide/show pref (default hide)
128128-- [ ] add pref to settings ui
128128+- [x] add universal titlebar hide/show pref (default hide)
129129+- [x] add pref to settings ui
129130- [ ] show titlebar on hover at top edge
130130-- [ ] why do some links in web pages open windows that show titlebars even when default is to hide?
131131+- [x] why do some links in web pages open windows that show titlebars even when default is to hide?
131132132133Size and position
133133-- [ ] windows are movable by default
134134-- [ ] windows are resizable by default
135135-- [ ] window.open api param for whether a window is draggable or not
136136-- [ ] window.open api param for whether a window is resizable or not
134134+- [x] windows are movable by default
135135+- [x] windows are resizable by default
136136+- [x] window.open api param for whether a window is draggable or not
137137+- [x] window.open api param for whether a window is resizable or not
137138138139Persistence
139139-- [ ] pref to persist keyed/url window position+size across app restarts
140140+- [x] pref to persist keyed/url window position+size across app restarts
140141141142Pin control in titlebar
142142-- [ ] pin window on top (app)
143143-- [ ] pin window on top (os)
144144-- [ ] cmds for all of this
143143+- [x] pin window on top (app)
144144+- [x] pin window on top (os)
145145+- [x] cmds for all of this
145146146147Interaction/integration
147147-- [ ] configurable escape behavior per-window, as a window.open api option
148148+- [x] configurable escape behavior per-window, as a window.open api option
148149149150Animations
150150-- [ ] add animation (to/from coords, time) option to window.open api
151151-- [ ] update slides impl to use animation (see ./animation.js, can remove when done)
151151+- [x] add animation (to/from coords, time) option to window.open api
152152+- [x] update slides impl to use animation (see ./animation.js, can remove when done)
152153153154## UI Componentry
154155···471472472473### 2026-W04
473474474474-- [x][security] remove production server endpoint from source - should only be in .env files or user-entered
475475- [x][desktop] fix groups extension - add visit tracking, filter for URLs only
476476- [x][workflow] fix TODO archival - updated agent templates with clearer instructions
477477- [x][workflow] clarify ./app rule - now about respecting front-end/back-end architecture boundary
···56565757 holdTimer = setTimeout(async () => {
5858 try {
5959+ // Check if window is draggable (API option can disable this)
6060+ const draggableResult = await window.app.invoke('window-is-draggable');
6161+ if (!draggableResult?.draggable) return;
6262+5963 // Get window ID and position
6064 windowId = await window.app.invoke('get-window-id');
6165 if (!windowId) return;
+285-3
backend/electron/ipc.ts
···104104 closeWindow,
105105 modWindow,
106106 getSystemThemeBackgroundColor,
107107+ getPrefs,
107108} from './windows.js';
108109109110import {
···14071408 }
14081409 }
1409141014111411+ // Determine frame setting based on explicit option or preference
14121412+ // If frame is explicitly set in options, use that; otherwise use hideTitleBar pref
14131413+ let frameDefault = false; // Default to frameless if pref not available
14141414+ if (options.frame === undefined) {
14151415+ const prefs = getPrefs();
14161416+ // hideTitleBar: true means frame: false (no titlebar)
14171417+ // hideTitleBar: false means frame: true (show titlebar)
14181418+ frameDefault = prefs.hideTitleBar === false;
14191419+ }
14201420+14101421 // Prepare browser window options
14111411- // Default to frameless windows unless explicitly requested
14121422 const winOptions: Electron.BrowserWindowConstructorOptions = {
14131413- frame: false, // Default to no titlebar
14231423+ frame: frameDefault, // Default based on hideTitleBar pref
14141424 ...options,
14151425 width: parseInt(options.width) || APP_DEF_WIDTH,
14161426 height: parseInt(options.height) || APP_DEF_HEIGHT,
···14231433 }
14241434 };
1425143514261426- // Make sure position parameters are correctly handled
14361436+ // Load saved window state if persistState is enabled and window has a key
14371437+ let savedState: { x?: number; y?: number; width?: number; height?: number } | null = null;
14381438+ if (options.key && (options.persistState === true || getPrefs().persistWindowState)) {
14391439+ try {
14401440+ const rowId = `window_state:${options.key}`;
14411441+ const row = getRow('extension_settings', rowId);
14421442+ if (row && row.value) {
14431443+ savedState = JSON.parse(row.value as string);
14441444+ DEBUG && console.log('Loaded saved window state for key:', options.key, savedState);
14451445+ }
14461446+ } catch (e) {
14471447+ DEBUG && console.log('Failed to load window state:', e);
14481448+ }
14491449+ }
14501450+14511451+ // Apply saved state or explicit position parameters
14521452+ if (savedState) {
14531453+ if (savedState.x !== undefined) winOptions.x = savedState.x;
14541454+ if (savedState.y !== undefined) winOptions.y = savedState.y;
14551455+ if (savedState.width !== undefined) winOptions.width = savedState.width;
14561456+ if (savedState.height !== undefined) winOptions.height = savedState.height;
14571457+ }
14581458+ // Explicit options override saved state
14271459 if (options.x !== undefined) {
14281460 winOptions.x = parseInt(options.x);
14291461 }
···14851517 trackContentWindowFocus(win);
14861518 if (!options.modal) {
14871519 trackVisibleWindowFocus(win);
15201520+ }
15211521+15221522+ // Set up window state persistence if enabled
15231523+ const shouldPersist = options.key && (options.persistState === true || getPrefs().persistWindowState);
15241524+ if (shouldPersist) {
15251525+ // Debounce timer for saving state
15261526+ let saveTimer: NodeJS.Timeout | null = null;
15271527+ const saveState = () => {
15281528+ if (saveTimer) clearTimeout(saveTimer);
15291529+ saveTimer = setTimeout(() => {
15301530+ if (win.isDestroyed()) return;
15311531+ const bounds = win.getBounds();
15321532+ const rowId = `window_state:${options.key}`;
15331533+ const data = {
15341534+ extensionId: 'window_state',
15351535+ key: options.key,
15361536+ value: JSON.stringify({
15371537+ x: bounds.x,
15381538+ y: bounds.y,
15391539+ width: bounds.width,
15401540+ height: bounds.height
15411541+ }),
15421542+ updatedAt: Date.now()
15431543+ };
15441544+ setRow('extension_settings', rowId, data);
15451545+ DEBUG && console.log('Saved window state for key:', options.key, bounds);
15461546+ }, 500); // Debounce 500ms
15471547+ };
15481548+15491549+ win.on('move', saveState);
15501550+ win.on('resize', saveState);
14881551 }
1489155214901553 // Set up DevTools if requested
···17661829 }
17671830 });
1768183118321832+ // Set window always-on-top with level support
18331833+ // Levels (macOS): 'normal', 'floating', 'torn-off-menu', 'modal-panel', 'main-menu', 'status', 'pop-up-menu', 'screen-saver'
18341834+ // For cross-platform, use 'floating' for app-level and 'screen-saver' for OS-level
18351835+ ipcMain.handle('window-set-always-on-top', async (ev, msg) => {
18361836+ DEBUG && console.log('window-set-always-on-top', msg);
18371837+18381838+ try {
18391839+ // If no ID provided, use the sender's window
18401840+ let win: BrowserWindow | null = null;
18411841+ if (msg?.id) {
18421842+ win = BrowserWindow.fromId(msg.id);
18431843+ } else {
18441844+ win = BrowserWindow.fromWebContents(ev.sender);
18451845+ }
18461846+18471847+ if (!win) {
18481848+ return { success: false, error: 'Window not found' };
18491849+ }
18501850+18511851+ const value = msg.value !== false; // Default to true
18521852+ const level = msg.level || 'normal'; // 'normal', 'floating', 'screen-saver', etc.
18531853+18541854+ // setAlwaysOnTop(flag, level, relativeLevel)
18551855+ // relativeLevel is only used on macOS
18561856+ if (process.platform === 'darwin') {
18571857+ win.setAlwaysOnTop(value, level as any);
18581858+ } else {
18591859+ // On Windows/Linux, level is ignored but always-on-top still works
18601860+ win.setAlwaysOnTop(value);
18611861+ }
18621862+18631863+ return { success: true, alwaysOnTop: value, level };
18641864+ } catch (error) {
18651865+ console.error('Failed to set always-on-top:', error);
18661866+ const message = error instanceof Error ? error.message : String(error);
18671867+ return { success: false, error: message };
18681868+ }
18691869+ });
18701870+18711871+ // Get window always-on-top state
18721872+ ipcMain.handle('window-is-always-on-top', async (ev, msg) => {
18731873+ DEBUG && console.log('window-is-always-on-top', msg);
18741874+18751875+ try {
18761876+ let win: BrowserWindow | null = null;
18771877+ if (msg?.id) {
18781878+ win = BrowserWindow.fromId(msg.id);
18791879+ } else {
18801880+ win = BrowserWindow.fromWebContents(ev.sender);
18811881+ }
18821882+18831883+ if (!win) {
18841884+ return { success: false, error: 'Window not found' };
18851885+ }
18861886+18871887+ return { success: true, alwaysOnTop: win.isAlwaysOnTop() };
18881888+ } catch (error) {
18891889+ const message = error instanceof Error ? error.message : String(error);
18901890+ return { success: false, error: message };
18911891+ }
18921892+ });
18931893+18941894+ // Animate window bounds (position and/or size)
18951895+ // Animates from current bounds (or specified 'from') to target bounds over duration
18961896+ ipcMain.handle('window-animate', async (ev, msg) => {
18971897+ DEBUG && console.log('window-animate', msg);
18981898+18991899+ try {
19001900+ let win: BrowserWindow | null = null;
19011901+ if (msg?.id) {
19021902+ win = BrowserWindow.fromId(msg.id);
19031903+ } else {
19041904+ win = BrowserWindow.fromWebContents(ev.sender);
19051905+ }
19061906+19071907+ if (!win) {
19081908+ return { success: false, error: 'Window not found' };
19091909+ }
19101910+19111911+ const currentBounds = win.getBounds();
19121912+ const from = msg.from || currentBounds;
19131913+ const to = msg.to;
19141914+ const duration = msg.duration || 150; // ms
19151915+19161916+ if (!to) {
19171917+ return { success: false, error: 'Target bounds (to) are required' };
19181918+ }
19191919+19201920+ // Calculate animation parameters
19211921+ const startX = from.x ?? currentBounds.x;
19221922+ const startY = from.y ?? currentBounds.y;
19231923+ const startW = from.width ?? currentBounds.width;
19241924+ const startH = from.height ?? currentBounds.height;
19251925+19261926+ const endX = to.x ?? startX;
19271927+ const endY = to.y ?? startY;
19281928+ const endW = to.width ?? startW;
19291929+ const endH = to.height ?? startH;
19301930+19311931+ // Animation timing
19321932+ const timerInterval = 10; // ms
19331933+ const numTicks = Math.max(1, Math.floor(duration / timerInterval));
19341934+ let tick = 0;
19351935+19361936+ return new Promise((resolve) => {
19371937+ const timer = setInterval(() => {
19381938+ tick++;
19391939+19401940+ if (tick >= numTicks || win!.isDestroyed()) {
19411941+ clearInterval(timer);
19421942+ // Set final bounds
19431943+ if (!win!.isDestroyed()) {
19441944+ win!.setBounds({ x: endX, y: endY, width: endW, height: endH });
19451945+ }
19461946+ resolve({ success: true });
19471947+ return;
19481948+ }
19491949+19501950+ // Calculate progress (0 to 1)
19511951+ const progress = tick / numTicks;
19521952+ // Use easeOutQuad for smooth deceleration
19531953+ const eased = 1 - (1 - progress) * (1 - progress);
19541954+19551955+ const x = Math.round(startX + (endX - startX) * eased);
19561956+ const y = Math.round(startY + (endY - startY) * eased);
19571957+ const w = Math.round(startW + (endW - startW) * eased);
19581958+ const h = Math.round(startH + (endH - startH) * eased);
19591959+19601960+ win!.setBounds({ x, y, width: w, height: h });
19611961+ }, timerInterval);
19621962+ });
19631963+ } catch (error) {
19641964+ console.error('Failed to animate window:', error);
19651965+ const message = error instanceof Error ? error.message : String(error);
19661966+ return { success: false, error: message };
19671967+ }
19681968+ });
19691969+17691970 ipcMain.handle('window-exists', async (_ev, msg) => {
17701971 DEBUG && console.log('window-exists', msg);
17711972···18642065 }
18652066 const [x, y] = win.getPosition();
18662067 return { success: true, x, y };
20682068+ } catch (error) {
20692069+ const message = error instanceof Error ? error.message : String(error);
20702070+ return { success: false, error: message };
20712071+ }
20722072+ });
20732073+20742074+ // Check if window is draggable (default: true)
20752075+ ipcMain.handle('window-is-draggable', (ev) => {
20762076+ try {
20772077+ const win = BrowserWindow.fromWebContents(ev.sender);
20782078+ if (!win) {
20792079+ return { success: false, error: 'Window not found' };
20802080+ }
20812081+ const winInfo = getWindowInfo(win.id);
20822082+ // Default to draggable if not specified
20832083+ const draggable = winInfo?.params?.draggable !== false;
20842084+ return { success: true, draggable };
20852085+ } catch (error) {
20862086+ const message = error instanceof Error ? error.message : String(error);
20872087+ return { success: false, error: message };
20882088+ }
20892089+ });
20902090+20912091+ // Save window state (position and size) for persistence
20922092+ // Stored in extension_settings table with namespace 'window_state'
20932093+ ipcMain.handle('window-state-save', async (_ev, msg) => {
20942094+ DEBUG && console.log('window-state-save', msg);
20952095+20962096+ try {
20972097+ const { key, x, y, width, height } = msg;
20982098+ if (!key) {
20992099+ return { success: false, error: 'Window key is required' };
21002100+ }
21012101+21022102+ // Check if persistence is enabled
21032103+ const prefs = getPrefs();
21042104+ if (!prefs.persistWindowState) {
21052105+ return { success: false, error: 'Window state persistence is disabled' };
21062106+ }
21072107+21082108+ const rowId = `window_state:${key}`;
21092109+ const data = {
21102110+ extensionId: 'window_state',
21112111+ key,
21122112+ value: JSON.stringify({ x, y, width, height }),
21132113+ updatedAt: Date.now()
21142114+ };
21152115+21162116+ setRow('extension_settings', rowId, data);
21172117+ return { success: true };
21182118+ } catch (error) {
21192119+ const message = error instanceof Error ? error.message : String(error);
21202120+ return { success: false, error: message };
21212121+ }
21222122+ });
21232123+21242124+ // Load window state for a given key
21252125+ ipcMain.handle('window-state-load', async (_ev, msg) => {
21262126+ DEBUG && console.log('window-state-load', msg);
21272127+21282128+ try {
21292129+ const { key } = msg;
21302130+ if (!key) {
21312131+ return { success: false, error: 'Window key is required' };
21322132+ }
21332133+21342134+ // Check if persistence is enabled
21352135+ const prefs = getPrefs();
21362136+ if (!prefs.persistWindowState) {
21372137+ return { success: false, data: null };
21382138+ }
21392139+21402140+ const rowId = `window_state:${key}`;
21412141+ const row = getRow('extension_settings', rowId);
21422142+21432143+ if (!row || !row.value) {
21442144+ return { success: true, data: null };
21452145+ }
21462146+21472147+ const state = JSON.parse(row.value as string);
21482148+ return { success: true, data: state };
18672149 } catch (error) {
18682150 const message = error instanceof Error ? error.message : String(error);
18692151 return { success: false, error: message };
+11-1
backend/electron/main.ts
···1616import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js';
1717import { scopes, publish, subscribe, setExtensionBroadcaster, getSystemAddress } from './pubsub.js';
1818import { APP_DEF_WIDTH, APP_DEF_HEIGHT, WEB_CORE_ADDRESS, getPreloadPath, isTestProfile, isDevProfile, isHeadless, getProfile, DEBUG } from './config.js';
1919-import { addEscHandler, winDevtoolsConfig, closeOrHideWindow, getSystemThemeBackgroundColor } from './windows.js';
1919+import { addEscHandler, winDevtoolsConfig, closeOrHideWindow, getSystemThemeBackgroundColor, getPrefs } from './windows.js';
20202121// Configuration
2222export interface AppConfig {
···840840 }
841841 }
842842843843+ // Determine frame setting based on explicit option or preference
844844+ let frameDefault = false; // Default to frameless if pref not available
845845+ if (featuresMap.frame === undefined) {
846846+ const prefs = getPrefs();
847847+ // hideTitleBar: true means frame: false (no titlebar)
848848+ // hideTitleBar: false means frame: true (show titlebar)
849849+ frameDefault = prefs.hideTitleBar === false;
850850+ }
851851+843852 // Prepare browser window options
844853 const winOptions: Electron.BrowserWindowConstructorOptions = {
854854+ frame: frameDefault, // Default based on hideTitleBar pref
845855 ...(featuresMap as Electron.BrowserWindowConstructorOptions),
846856 width: parseInt(String(featuresMap.width)) || APP_DEF_WIDTH,
847857 height: parseInt(String(featuresMap.height)) || APP_DEF_HEIGHT,
+2-2
backend/electron/sync.ts
···103103 // If query fails, fall through to defaults
104104 }
105105106106- // Fall back to env var (empty string disables sync)
107107- return process.env.SYNC_SERVER_URL || '';
106106+ // Fall back to env var or default
107107+ return process.env.SYNC_SERVER_URL || 'https://peek-node.up.railway.app';
108108}
109109110110/**
+7
backend/electron/windows.ts
···4242}
43434444/**
4545+ * Get current preferences
4646+ */
4747+export function getPrefs(): Record<string, unknown> {
4848+ return _getPrefs();
4949+}
5050+5151+/**
4552 * Modify window state (close, hide, show)
4653 */
4754export function modWindow(bw: BrowserWindow, params: { action: string }): void {
+1-1
backend/server/.env.example
···6677# Production testing
88PEEK_PROD_KEY=your-production-api-key
99-PEEK_PROD_URL=https://your-server.railway.app
99+PEEK_PROD_URL=https://peek-node.up.railway.app
+1-7
backend/server/test-api.js
···2424 BASE_URL = "http://localhost:3000";
2525 API_KEY = process.env.PEEK_LOCAL_KEY;
2626} else if (isProd) {
2727- BASE_URL = process.env.PEEK_PROD_URL;
2727+ BASE_URL = process.env.PEEK_PROD_URL || "https://peek-node.up.railway.app";
2828 API_KEY = process.env.PEEK_PROD_KEY;
2929} else {
3030 // Fallback to legacy env vars for backwards compatibility
3131 BASE_URL = process.env.BASE_URL || "http://localhost:3000";
3232 API_KEY = process.env.API_KEY;
3333-}
3434-3535-if (isProd && !BASE_URL) {
3636- console.error("ERROR: PEEK_PROD_URL not set for production tests");
3737- console.error("Set PEEK_PROD_URL in .env or environment");
3838- process.exit(1);
3933}
40344135if (!API_KEY) {
+4-7
backend/tests/sync-e2e-prod.test.js
···2525const __dirname = dirname(fileURLToPath(import.meta.url));
26262727// Configuration from environment
2828-const PROD_URL = process.env.PEEK_PROD_URL;
2828+const PROD_URL = process.env.PEEK_PROD_URL || 'https://peek-node.up.railway.app';
2929const PROD_KEY = process.env.PEEK_PROD_KEY;
30303131// Test marker for this run - used for identification and cleanup
···436436 console.log('');
437437438438 // Check for required env vars
439439- if (!PROD_URL || !PROD_KEY) {
440440- console.error('ERROR: Required environment variables missing');
441441- if (!PROD_URL) console.error(' - PEEK_PROD_URL not set');
442442- if (!PROD_KEY) console.error(' - PEEK_PROD_KEY not set');
439439+ if (!PROD_KEY) {
440440+ console.error('ERROR: PEEK_PROD_KEY environment variable is required');
443441 console.error('');
444444- console.error('Set them with:');
445445- console.error(' export PEEK_PROD_URL=https://your-server.railway.app');
442442+ console.error('Set it with:');
446443 console.error(' export PEEK_PROD_KEY=your-api-key');
447444 console.error('');
448445 process.exit(1);
+57-1
docs/api.md
···6161 alwaysOnTop: false, // Stay on top
6262 visible: true, // Initially visible
6363 resizable: true, // Allow resize
6464- keepLive: false // Keep window alive when closed
6464+ draggable: true, // Allow click-and-hold drag (default: true)
6565+ keepLive: false, // Keep window alive when closed
6666+ escapeMode: 'close' // ESC key behavior: 'close', 'navigate', or 'auto'
6567});
6668// Returns: { success: true, id: 'window_label' }
6769```
···119121```javascript
120122const result = await window.app.window.exists('settings');
121123// Returns: { success: true, data: true }
124124+```
125125+126126+### `window.app.invoke('window-animate', options)`
127127+128128+Animate a window's position and/or size.
129129+130130+```javascript
131131+// Animate to new position
132132+await window.app.invoke('window-animate', {
133133+ id: windowId, // Window ID (optional, defaults to current)
134134+ to: { x: 100, y: 100 }, // Target bounds (required)
135135+ duration: 150 // Animation duration in ms (default: 150)
136136+});
137137+138138+// Animate from specific position
139139+await window.app.invoke('window-animate', {
140140+ id: windowId,
141141+ from: { x: 0, y: -600 }, // Starting bounds (optional, defaults to current)
142142+ to: { x: 0, y: 0, width: 800, height: 600 },
143143+ duration: 200
144144+});
145145+// Uses easeOutQuad easing for smooth deceleration
146146+```
147147+148148+### `window.app.invoke('window-set-always-on-top', options)`
149149+150150+Pin a window to stay on top of other windows.
151151+152152+```javascript
153153+// Pin with normal level
154154+await window.app.invoke('window-set-always-on-top', {
155155+ id: windowId,
156156+ value: true
157157+});
158158+159159+// Pin above other app windows (macOS)
160160+await window.app.invoke('window-set-always-on-top', {
161161+ id: windowId,
162162+ value: true,
163163+ level: 'floating'
164164+});
165165+166166+// Pin above all windows (macOS)
167167+await window.app.invoke('window-set-always-on-top', {
168168+ id: windowId,
169169+ value: true,
170170+ level: 'screen-saver'
171171+});
172172+173173+// Unpin
174174+await window.app.invoke('window-set-always-on-top', {
175175+ id: windowId,
176176+ value: false
177177+});
122178```
123179124180---
+37-3
extensions/slides/background.js
···124124 }
125125126126 function openNewSlide() {
127127+ // Calculate off-screen starting position for animation
128128+ let startX = x, startY = y;
129129+ const animationDuration = 150; // ms
130130+131131+ switch(item.screenEdge) {
132132+ case 'Up':
133133+ startY = -height; // Start above screen
134134+ break;
135135+ case 'Down':
136136+ startY = screen.height; // Start below screen
137137+ break;
138138+ case 'Left':
139139+ startX = -width; // Start left of screen
140140+ break;
141141+ case 'Right':
142142+ startX = screen.width; // Start right of screen
143143+ break;
144144+ }
145145+127146 const params = {
128147 address: item.address,
129148 height,
···138157 keepLive: item.keepLive || false,
139158 persistState: item.persistState || false,
140159141141- x,
142142- y,
160160+ // Start at off-screen position for animation
161161+ x: startX,
162162+ y: startY,
143163144164 // tracking
145165 trackingSource: 'slide',
···147167 title: item.title || ''
148168 };
149169150150- api.window.open(item.address, params).then(result => {
170170+ api.window.open(item.address, params).then(async result => {
151171 if (result.success) {
152172 console.log('[ext:slides] Successfully opened slide with ID:', result.id);
153173 slideWindows.set(key, result.id);
174174+175175+ // Animate to final position
176176+ if (startX !== x || startY !== y) {
177177+ try {
178178+ await api.invoke('window-animate', {
179179+ id: result.id,
180180+ to: { x, y, width, height },
181181+ duration: animationDuration
182182+ });
183183+ console.log('[ext:slides] Animation complete');
184184+ } catch (err) {
185185+ console.error('[ext:slides] Animation failed:', err);
186186+ }
187187+ }
154188 } else {
155189 console.error('[ext:slides] Failed to open slide:', result.error);
156190 }