experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(page-host): edge resize handles + reachable corners when maximized

Two related improvements to the page-host resize UX:

1. Edge resize handles. Adds N/S/E/W thin-strip hit zones along the
webview edges (between the existing 24x24 corner hit zones).
Pointer events follow the same FSM RESIZE_START -> RESIZE_END
flow as the corners; pointermove math is single-axis.

2. Maximized resize handles stay reachable. Previously corner handles
were `display: none` in maximized — to shrink the window the user
had to drag the navbar to exit maximize FIRST, then grab a corner.
Now all handles are positioned at the OS window edges in
maximized too. The page-fsm gains a RESIZE_START transition from
MAXIMIZED that:
- Clears body class, URL maximized flag, sets handles visible
- Does NOT restore pre-maximize bounds (keeps screenBounds at
workArea so the user's drag delta shrinks from where the cursor
is — restoring smaller pre-max size would teleport the corner
out from under the cursor)

Tests:
- tests/unit/page-fsm.test.js: new transition test for
resize.start in MAXIMIZED, asserts the no-RESTORE_PRE_MAXIMIZE
invariant.
- tests/desktop/session-restore-page-host.spec.ts: two new
integration tests — edge-handle E-drag changes width only, and
maximized SE-corner drag exits maximize + shrinks in one gesture.
- Updated the existing maximized-restore test: it asserted handles
were display:none in maximized, which is now intentionally false.

+217 -19
+10 -1
app/page/index.html
··· 139 139 z-index: 50; 140 140 } 141 141 142 - /* Resize handles — invisible 24x24 hit targets at corners */ 142 + /* Resize handles — invisible hit targets. Corners are 24x24 squares; 143 + * edges are thin strips (positioned in updatePositions/Maximized). */ 143 144 .resize-handle { 144 145 position: absolute; 145 146 width: 24px; ··· 153 154 .resize-handle[data-dir="sw"] { cursor: sw-resize; } 154 155 .resize-handle[data-dir="ne"] { cursor: ne-resize; } 155 156 .resize-handle[data-dir="nw"] { cursor: nw-resize; } 157 + .resize-handle[data-dir="n"] { cursor: n-resize; } 158 + .resize-handle[data-dir="s"] { cursor: s-resize; } 159 + .resize-handle[data-dir="e"] { cursor: e-resize; } 160 + .resize-handle[data-dir="w"] { cursor: w-resize; } 156 161 157 162 /* Drag overlay — covers webview during hold-to-drag */ 158 163 .drag-overlay { ··· 1565 1570 <div class="resize-handle" id="resize-sw" data-dir="sw"></div> 1566 1571 <div class="resize-handle" id="resize-ne" data-dir="ne"></div> 1567 1572 <div class="resize-handle" id="resize-nw" data-dir="nw"></div> 1573 + <div class="resize-handle resize-edge" id="resize-n" data-dir="n"></div> 1574 + <div class="resize-handle resize-edge" id="resize-s" data-dir="s"></div> 1575 + <div class="resize-handle resize-edge" id="resize-e" data-dir="e"></div> 1576 + <div class="resize-handle resize-edge" id="resize-w" data-dir="w"></div> 1568 1577 <div class="resize-handle" id="resize-nav-ne" data-dir="ne"></div> 1569 1578 <div class="resize-handle" id="resize-nav-nw" data-dir="nw"></div> 1570 1579 <!-- Page Info Panel (top-left, shown with navbar) -->
+15
app/page/page-fsm.js
··· 168 168 ], 169 169 }; 170 170 } 171 + if (event.type === EVENTS.RESIZE_START) { 172 + // Resize-from-maximized: keep the current (workArea) bounds as 173 + // the resize starting size — user drags inward to shrink. No 174 + // RESTORE_PRE_MAXIMIZE: snapping to the (smaller) pre-maximize 175 + // size before the user has moved would teleport the corner out 176 + // from under their cursor. 177 + return { 178 + state: STATES.RESIZING, 179 + effects: [ 180 + e(EFFECTS.SET_BODY_MAXIMIZED, { value: false }), 181 + e(EFFECTS.SET_HANDLES_VISIBLE, { value: true }), 182 + e(EFFECTS.SET_URL_MAXIMIZED, { value: false }), 183 + ], 184 + }; 185 + } 171 186 if (event.type === EVENTS.INIT_COMPLETE) { 172 187 return { state, effects: [], warning: 'Duplicate INIT_COMPLETE in MAXIMIZED' }; 173 188 }
+87 -6
app/page/page.js
··· 267 267 sw: document.getElementById('resize-sw'), 268 268 ne: document.getElementById('resize-ne'), 269 269 nw: document.getElementById('resize-nw'), 270 + n: document.getElementById('resize-n'), 271 + s: document.getElementById('resize-s'), 272 + e: document.getElementById('resize-e'), 273 + w: document.getElementById('resize-w'), 270 274 navNe: document.getElementById('resize-nav-ne'), 271 275 navNw: document.getElementById('resize-nav-nw'), 272 276 }; 277 + // Width of an edge resize hit-zone (the thin strip alongside a window edge). 278 + const EDGE_HIT = 6; 273 279 const widgetContainer = document.getElementById('widget-container'); 274 280 const centerColumn = document.getElementById('center-column'); 275 281 const pageInfoPanel = document.getElementById('page-info-panel'); ··· 458 464 triggerZone.style.width = `${webviewWidth}px`; 459 465 triggerZone.style.height = navVisible ? `${TRIGGER_ZONE_HEIGHT}px` : `${navOffset + TRIGGER_ZONE_HEIGHT}px`; 460 466 461 - // Resize handles — all four corners of the webview (24x24) 467 + // Resize handles — corners (24x24) + edges (thin strips between corners). 462 468 resizeHandles.se.style.display = ''; 463 469 resizeHandles.sw.style.display = ''; 464 470 resizeHandles.ne.style.display = ''; 465 471 resizeHandles.nw.style.display = ''; 472 + resizeHandles.n.style.display = ''; 473 + resizeHandles.s.style.display = ''; 474 + resizeHandles.e.style.display = ''; 475 + resizeHandles.w.style.display = ''; 466 476 467 477 resizeHandles.se.style.left = `${webviewLeft + webviewWidth - 24}px`; 468 478 resizeHandles.se.style.top = `${webviewTop + webviewHeight - 24}px`; ··· 476 486 resizeHandles.nw.style.left = `${webviewLeft}px`; 477 487 resizeHandles.nw.style.top = `${webviewTop}px`; 478 488 489 + // Edge handles — thin strips along webview sides, NOT overlapping 490 + // the corner 24x24 hit-zones (so corner cursor wins at the corners). 491 + resizeHandles.n.style.left = `${webviewLeft + 24}px`; 492 + resizeHandles.n.style.top = `${webviewTop}px`; 493 + resizeHandles.n.style.width = `${Math.max(0, webviewWidth - 48)}px`; 494 + resizeHandles.n.style.height = `${EDGE_HIT}px`; 495 + 496 + resizeHandles.s.style.left = `${webviewLeft + 24}px`; 497 + resizeHandles.s.style.top = `${webviewTop + webviewHeight - EDGE_HIT}px`; 498 + resizeHandles.s.style.width = `${Math.max(0, webviewWidth - 48)}px`; 499 + resizeHandles.s.style.height = `${EDGE_HIT}px`; 500 + 501 + resizeHandles.e.style.left = `${webviewLeft + webviewWidth - EDGE_HIT}px`; 502 + resizeHandles.e.style.top = `${webviewTop + 24}px`; 503 + resizeHandles.e.style.width = `${EDGE_HIT}px`; 504 + resizeHandles.e.style.height = `${Math.max(0, webviewHeight - 48)}px`; 505 + 506 + resizeHandles.w.style.left = `${webviewLeft}px`; 507 + resizeHandles.w.style.top = `${webviewTop + 24}px`; 508 + resizeHandles.w.style.width = `${EDGE_HIT}px`; 509 + resizeHandles.w.style.height = `${Math.max(0, webviewHeight - 48)}px`; 510 + 479 511 // Navbar corner resize handles — only visible when navbar is shown 480 512 resizeHandles.navNe.style.display = navVisible ? '' : 'none'; 481 513 resizeHandles.navNw.style.display = navVisible ? '' : 'none'; ··· 605 637 triggerZone.style.width = `${winW}px`; 606 638 triggerZone.style.height = navVisible ? `${NAVBAR_HEIGHT + MARGIN + TRIGGER_ZONE_HEIGHT}px` : `${TRIGGER_ZONE_HEIGHT + MARGIN}px`; 607 639 608 - // Resize handles hidden when maximized 609 - resizeHandles.se.style.display = 'none'; 610 - resizeHandles.sw.style.display = 'none'; 611 - resizeHandles.ne.style.display = 'none'; 612 - resizeHandles.nw.style.display = 'none'; 640 + // Resize handles in maximized: positioned at the OS window's edges so 641 + // the user can grab a corner or edge directly. Mousedown drops the 642 + // page-host out of MAXIMIZED into RESIZING (handled by the FSM 643 + // transition added for this state) — single gesture, no exit-then-grab. 644 + resizeHandles.se.style.display = ''; 645 + resizeHandles.sw.style.display = ''; 646 + resizeHandles.ne.style.display = ''; 647 + resizeHandles.nw.style.display = ''; 648 + resizeHandles.n.style.display = ''; 649 + resizeHandles.s.style.display = ''; 650 + resizeHandles.e.style.display = ''; 651 + resizeHandles.w.style.display = ''; 652 + 653 + resizeHandles.se.style.left = `${winW - 24}px`; 654 + resizeHandles.se.style.top = `${winH - 24}px`; 655 + resizeHandles.sw.style.left = `0px`; 656 + resizeHandles.sw.style.top = `${winH - 24}px`; 657 + resizeHandles.ne.style.left = `${winW - 24}px`; 658 + resizeHandles.ne.style.top = `0px`; 659 + resizeHandles.nw.style.left = `0px`; 660 + resizeHandles.nw.style.top = `0px`; 661 + 662 + resizeHandles.n.style.left = `24px`; 663 + resizeHandles.n.style.top = `0px`; 664 + resizeHandles.n.style.width = `${Math.max(0, winW - 48)}px`; 665 + resizeHandles.n.style.height = `${EDGE_HIT}px`; 666 + resizeHandles.s.style.left = `24px`; 667 + resizeHandles.s.style.top = `${winH - EDGE_HIT}px`; 668 + resizeHandles.s.style.width = `${Math.max(0, winW - 48)}px`; 669 + resizeHandles.s.style.height = `${EDGE_HIT}px`; 670 + resizeHandles.e.style.left = `${winW - EDGE_HIT}px`; 671 + resizeHandles.e.style.top = `24px`; 672 + resizeHandles.e.style.width = `${EDGE_HIT}px`; 673 + resizeHandles.e.style.height = `${Math.max(0, winH - 48)}px`; 674 + resizeHandles.w.style.left = `0px`; 675 + resizeHandles.w.style.top = `24px`; 676 + resizeHandles.w.style.width = `${EDGE_HIT}px`; 677 + resizeHandles.w.style.height = `${Math.max(0, winH - 48)}px`; 678 + 679 + // Navbar corners — keep hidden in maximized; the navbar itself is the 680 + // drag-out target, so its corners would compete with the navbar's own 681 + // dblclick + drag handlers for the same hit-zone. 613 682 resizeHandles.navNe.style.display = 'none'; 614 683 resizeHandles.navNw.style.display = 'none'; 615 684 ··· 1522 1591 const newWidth = Math.max(MIN_WIDTH, resizeStartWidth - dx); 1523 1592 screenBounds.x = resizeStartBoundsX + (resizeStartWidth - newWidth); 1524 1593 screenBounds.width = newWidth; 1594 + const newHeight = Math.max(MIN_HEIGHT, resizeStartHeight - dy); 1595 + screenBounds.y = resizeStartBoundsY + (resizeStartHeight - newHeight); 1596 + screenBounds.height = newHeight; 1597 + } else if (resizeDir === 'e') { 1598 + screenBounds.width = Math.max(MIN_WIDTH, resizeStartWidth + dx); 1599 + } else if (resizeDir === 'w') { 1600 + const newWidth = Math.max(MIN_WIDTH, resizeStartWidth - dx); 1601 + screenBounds.x = resizeStartBoundsX + (resizeStartWidth - newWidth); 1602 + screenBounds.width = newWidth; 1603 + } else if (resizeDir === 's') { 1604 + screenBounds.height = Math.max(MIN_HEIGHT, resizeStartHeight + dy); 1605 + } else if (resizeDir === 'n') { 1525 1606 const newHeight = Math.max(MIN_HEIGHT, resizeStartHeight - dy); 1526 1607 screenBounds.y = resizeStartBoundsY + (resizeStartHeight - newHeight); 1527 1608 screenBounds.height = newHeight;
+89 -12
tests/desktop/session-restore-page-host.spec.ts
··· 96 96 return { 97 97 bodyMaximized: document.body.classList.contains('maximized'), 98 98 urlParams: Object.fromEntries(new URL(window.location.href).searchParams.entries()), 99 - // Resize handles set display:none in updatePositionsMaximized. 100 - // After the FSM lands, we want one source of truth — body class. 101 - // Today we read both to surface drift. 102 - seHandleHidden: (() => { 103 - const el = document.getElementById('resize-se'); 104 - return el ? el.style.display === 'none' : null; 105 - })(), 99 + // Resize handles stay reachable in maximized so the user can grab 100 + // a corner/edge to exit-and-resize in one gesture (page-fsm 101 + // RESIZE_START transition from MAXIMIZED). The pre-2026-05-05 102 + // behavior set them display:none in maximized; this test now just 103 + // confirms the handle exists in the DOM. 104 + seHandleExists: !!document.getElementById('resize-se'), 106 105 }; 107 106 }); 108 107 } ··· 209 208 const beforeState = await getHostState(pageWindow); 210 209 expect(beforeState.bodyMaximized).toBe(true); 211 210 expect(beforeState.urlParams.maximized).toBe('1'); 212 - expect(beforeState.seHandleHidden).toBe(true); 211 + expect(beforeState.seHandleExists).toBe(true); 213 212 214 213 // Save snapshot. 215 214 await app.evaluateMain!((() => { ··· 252 251 // 2) URL params should still carry the maximized flag. 253 252 expect(afterState.urlParams.maximized, 254 253 `restored page host lost maximized=1 URL param — page-host URL is regenerated by window-open without propagating the maximize state`).toBe('1'); 255 - // 3) Resize handles must be hidden when maximized — drift between 256 - // body class and handle visibility surfaces state-incoherence. 257 - expect(afterState.seHandleHidden, 258 - `restored page host shows resize handles even though body class says maximized — state drift`).toBe(true); 254 + // 3) Resize handles still exist in the DOM (they're now reachable 255 + // even when maximized so a corner-grab can exit-and-resize). 256 + expect(afterState.seHandleExists, 257 + `restored page host is missing the SE resize handle from the DOM`).toBe(true); 259 258 }); 260 259 261 260 test('maximize → unmaximize → resize smaller → restart: bounds restore at resized size, not maximized', async () => { ··· 447 446 expect(parseInt(finalState.urlParams.height as string, 10), 448 447 `unmaximize after save+restore should restore to original pre-maximize height, not workArea`) 449 448 .toBe(preMaxHeight); 449 + }); 450 + 451 + test('edge resize handle: dragging E changes width only, not height', async () => { 452 + const url = `http://127.0.0.1:${serverPort}/edge-resize`; 453 + const { pageWindow } = await openPageHost(url); 454 + 455 + await pageWindow.waitForFunction(() => { 456 + const p = new URL(window.location.href).searchParams; 457 + return p.get('width') && p.get('height'); 458 + }); 459 + 460 + const before = await getHostState(pageWindow); 461 + const startW = parseInt(before.urlParams.width as string, 10); 462 + const startH = parseInt(before.urlParams.height as string, 10); 463 + 464 + await pageWindow.evaluate(() => { 465 + const handle = document.getElementById('resize-e')!; 466 + handle.dispatchEvent(new PointerEvent('pointerdown', { 467 + bubbles: true, button: 0, pointerId: 1, screenX: 800, screenY: 400, 468 + })); 469 + handle.dispatchEvent(new PointerEvent('pointermove', { 470 + bubbles: true, button: 0, buttons: 1, pointerId: 1, screenX: 700, screenY: 400, 471 + })); 472 + handle.dispatchEvent(new PointerEvent('pointerup', { 473 + bubbles: true, button: 0, pointerId: 1, 474 + })); 475 + }); 476 + 477 + await pageWindow.waitForFunction((w) => { 478 + const p = new URL(window.location.href).searchParams; 479 + return parseInt(p.get('width') || '0', 10) < w; 480 + }, startW); 481 + 482 + const after = await getHostState(pageWindow); 483 + const endW = parseInt(after.urlParams.width as string, 10); 484 + const endH = parseInt(after.urlParams.height as string, 10); 485 + expect(endW, 'E-edge drag should shrink width').toBeLessThan(startW); 486 + expect(endH, 'E-edge drag must NOT change height').toBe(startH); 487 + }); 488 + 489 + test('maximized corner resize: drag SE inward exits maximize and shrinks in one gesture', async () => { 490 + const url = `http://127.0.0.1:${serverPort}/max-corner-resize`; 491 + const { pageWindow } = await openPageHost(url); 492 + 493 + await pageWindow.waitForFunction(() => { 494 + const p = new URL(window.location.href).searchParams; 495 + return p.get('width') && p.get('height'); 496 + }); 497 + 498 + await maximizePageHost(pageWindow); 499 + await waitForBodyMaximized(pageWindow, true); 500 + 501 + const maxState = await getHostState(pageWindow); 502 + const maxW = parseInt(maxState.urlParams.width as string, 10); 503 + const maxH = parseInt(maxState.urlParams.height as string, 10); 504 + 505 + await pageWindow.evaluate(() => { 506 + const handle = document.getElementById('resize-se')!; 507 + handle.dispatchEvent(new PointerEvent('pointerdown', { 508 + bubbles: true, button: 0, pointerId: 1, screenX: 1400, screenY: 800, 509 + })); 510 + handle.dispatchEvent(new PointerEvent('pointermove', { 511 + bubbles: true, button: 0, buttons: 1, pointerId: 1, screenX: 1000, screenY: 600, 512 + })); 513 + handle.dispatchEvent(new PointerEvent('pointerup', { 514 + bubbles: true, button: 0, pointerId: 1, 515 + })); 516 + }); 517 + 518 + await waitForBodyMaximized(pageWindow, false); 519 + 520 + const after = await getHostState(pageWindow); 521 + expect(after.bodyMaximized, 'should have exited maximize').toBe(false); 522 + expect(after.urlParams.maximized, 'URL should not carry maximized=1 after resize-from-max').toBeUndefined(); 523 + const endW = parseInt(after.urlParams.width as string, 10); 524 + const endH = parseInt(after.urlParams.height as string, 10); 525 + expect(endW, 'width should have shrunk from maximized size').toBeLessThan(maxW); 526 + expect(endH, 'height should have shrunk from maximized size').toBeLessThan(maxH); 450 527 }); 451 528 });
+16
tests/unit/page-fsm.test.js
··· 241 241 assert.equal(warning, undefined); 242 242 }); 243 243 244 + it('resize.start in MAXIMIZED exits maximize and enters RESIZING (single-gesture corner-resize)', () => { 245 + const { state, effects, warning } = transition(STATES.MAXIMIZED, { type: EVENTS.RESIZE_START }); 246 + assert.equal(state, STATES.RESIZING); 247 + assert.equal(warning, undefined); 248 + const types = effects.map((e) => e.type); 249 + assert.ok(types.includes(EFFECTS.SET_BODY_MAXIMIZED), 'should clear maximized body class'); 250 + assert.ok(types.includes(EFFECTS.SET_URL_MAXIMIZED), 'should clear maximized URL flag'); 251 + const setBody = effects.find((e) => e.type === EFFECTS.SET_BODY_MAXIMIZED); 252 + assert.equal(setBody.value, false); 253 + // No RESTORE_PRE_MAXIMIZE — keep current (workArea) screenBounds so the 254 + // user's drag delta shrinks from where the cursor is, not from a smaller 255 + // pre-max size that would teleport the corner. 256 + assert.ok(!types.includes(EFFECTS.RESTORE_PRE_MAXIMIZE), 257 + 'must NOT restore pre-max bounds (cursor would lose the corner)'); 258 + }); 259 + 244 260 it('toggle_maximize in DRAGGING is rejected (must end drag first)', () => { 245 261 const { state, warning } = transition(STATES.DRAGGING, { type: EVENTS.TOGGLE_MAXIMIZE }); 246 262 assert.equal(state, STATES.DRAGGING);