experiments in a post-browser web
10
fork

Configure Feed

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

fix(page): anchor load-error overlay to center-column so Cmd+L surfaces navbar without window growth

Pre-fix the overlay was position:fixed at z-index 9998 anchored to the
viewport, so on Cmd+L the navbar (z:100) got .visible in the DOM but was
hidden behind the overlay, and the dark overlay stretched to fill show()'s
panel-overhang window expansion — user perceived 'address bar didn't open'
and 'window maximized'. Overlay now appends to .center-column with
position:absolute inset:0 z-index:25, so it sits below navbar and stays
sized to the webview container regardless of window growth.

Adds tests/desktop/cmdl-on-error-page.spec.ts pinning the symptom: open
page-host on an unresolvable URL, wait for error overlay, sendInputEvent
Cmd+L into the page-host webContents, assert (a) webview width unchanged,
(b) overlay width = center-column width < document width, (c) body not
maximized, (d) navbar visible, (e) navbar z > overlay z.

Tasks: adds two follow-ups to docs/tasks.md (Playwright coverage for
IZUI transient-on-blur and for the slides feature).

+203 -7
+10 -6
app/page/index.html
··· 1263 1263 /* --- Load error overlay (server not found, DNS failure, etc.) --- */ 1264 1264 1265 1265 .load-error-overlay { 1266 - position: fixed; 1267 - top: 0; 1268 - left: 0; 1269 - right: 0; 1270 - bottom: 0; 1271 - z-index: 9998; 1266 + /* Anchored to the .center-column (its parent) — NOT to the viewport. 1267 + When the window expands to expose side-panel overhang, the 1268 + center-column width stays put, so the overlay (and the user's 1269 + perceived "content area") doesn't grow with the window. 1270 + Sits above the webview but BELOW the navbar (100), trigger zone 1271 + (50), and side panels (140) so Cmd+L / hover summon the address 1272 + bar to fix the URL. */ 1273 + position: absolute; 1274 + inset: 0; 1275 + z-index: 25; 1272 1276 display: flex; 1273 1277 align-items: center; 1274 1278 justify-content: center;
+6 -1
app/page/page.js
··· 2068 2068 card.appendChild(url); 2069 2069 card.appendChild(actions); 2070 2070 overlay.appendChild(card); 2071 - document.body.appendChild(overlay); 2071 + // Append into centerColumn (not body) so the overlay is anchored to the 2072 + // webview's container — when show() expands the window for side-panel 2073 + // overhang, the centerColumn stays put and the overlay doesn't stretch 2074 + // with the viewport. The user's perceived "content area" stays fixed 2075 + // unless the user actually resizes the window. 2076 + centerColumn.appendChild(overlay); 2072 2077 } 2073 2078 2074 2079 webview.addEventListener('did-fail-load', (e) => {
+4
docs/tasks.md
··· 37 37 - [ ] **Migrate bootstrap IPC channels to tile:lifecycle:* namespace.** `session-restore-pending` and `frontend-ready` in `backend/electron/entry.ts` are bare `ipcMain.handle()` registrations from before the tile system initializes. They're only reachable from trustedBuiltin renderers via `api.invoke()`, so the security gap is theoretical, but they're the last bare main-process IPC handlers outside `tile-ipc-gate.ts`. Decision skipped 2026-04-24: leaving them bare for now since they exist *before* any tile loads — the indirection through tile-ipc-gate would require either bootstrapping the gate earlier or having two IPC modes (bootstrap vs. post-init). Worth revisiting if tile-ipc-gate ever gains a "no token required for these specific channels" mode, or if entry.ts gets folded into a tile lifecycle. 38 38 - [x] **Page host window jumps to wrong position after switching primary monitor.** Surfaced 2026-04-24; repro: connect external monitor as primary, open page host (cmd+L), then disconnect/swap so the laptop becomes primary and cmd+L again — page-host appeared off-screen or on the wrong display. Shipped 2026-04-28 by the `window-placement` refactor sprint (Phases 1-6, see `docs/window-placement-refactor.md`): introduced pure `backend/electron/window-placement.ts` module with `computePlacement` + `Placement` discriminated union, recorded a `placement` intent on every window at registration time (never re-derived), and rewired the URL-reuse / keepLive-reuse paths, the fresh-open fallback, the display-watcher second pass, and the v2-tile path to all consult the same function. Stranded-rescue (<50% area on any display) is built into the pure module and replaces the earlier `isWindowAccessibleNow` + `repositionOnCursorDisplay` helpers, which are deleted. The "wrong display, both still exist" case now resolves correctly because page-host's `cursor-display-fallback` placement causes display-watcher to re-evaluate against current cursor display. Unit-tested with synthetic display layouts so the regression class is single-failing-test catchable in future. 39 39 - [x] **Slides anchor to stale window coordinates after display switch.** Surfaced 2026-04-28; slides reused cached coordinates from a prior display after a monitor connect/disconnect/primary-swap, opening off-screen or clipped. Shipped 2026-04-28 by the same `window-placement` refactor sprint: slides now pass `screenEdge` semantics (no x/y math in the renderer), main process records `placement: { mode: 'edge', edge }`, and `computePlacement` resolves coords against the actual cursor display every show. The renderer no longer reads `window.screen.width/height`. The stale-coordinates and "wrong display, both still exist" cases are both handled because every display change triggers a generic `computePlacement` pass via display-watcher. 40 + - [ ] **Playwright coverage for IZUI transient-on-blur auto-hide.** Surfaced 2026-04-28 with the regression that left slides + other panel-style transients accumulating on screen because macOS NSPanels don't fire `BrowserWindow.on('blur')` on intra-app focus shifts. Fixed in commit `091e2a43` by adding a main-process `win.on('focus')` loop that hides/closes other visible windows whose role is in `TRANSIENT_ROLES` (palette / quick-view / overlay), but **no e2e test asserts the behavior**. Spec to add (`tests/desktop/transient-on-blur.spec.ts` or extend `window-url.spec.ts`): open a `palette` (cmd panel) and a `quick-view` (e.g., a slide or settings), then open another `palette` / `quick-view` — assert the first one is no longer visible. Variants: focus moving to a `utility` window must NOT close transients (chain popup invariant); focus to a `workspace` / `content` window MUST close them. Hard to do with NSPanels and Playwright's focus model — may need to drive focus via main-process IPC rather than synthetic events. Likely the single biggest regression-risk surface in window management going forward. 41 + 42 + - [ ] **Playwright coverage for the slides feature (no spec exists today).** The whole slides feature has zero e2e coverage — surfaced 2026-04-28 when both a placement regression (slides anchored to stale `window.screen` coords) and a focus regression (slides not auto-closing on blur) shipped without tests catching either. Plan: a `tests/desktop/slides.spec.ts` covering the four edge anchors (Up/Down/Left/Right) opening at the expected screen edge, the close-on-blur invariant when another transient takes focus, and the `key=address:edge` identity contract (re-invoking the same slide reuses the window; opening a different edge gets a different window). The recent fix `f010a550` (skip URL-reuse when caller provides explicit `key`) is now covered by `window-url.spec.ts:120` — but the slides-specific user-facing flow ("Option+arrow opens slide on edge X") is still untested. 43 + 40 44 - [ ] **Proton Pass extension doesn't autofill.** Still broken — doesn't show up in form fields, no autofill suggestions. Likely a content-script injection or messaging API gap in the webview/canvas setup. Reproduce: install Proton Pass extension, navigate to any login page, observe no inline autofill UI. (2026-04-17: agent triaged, 5 hypotheses in agent-ae11e03d worktree — extension gitignored so couldn't repro.) 41 45 - [ ] **Proton Pass: permissions denied when configuring the extension.** Surfaced 2026-04-28. Trying to configure Proton in its options/popup page → permissions are being denied (likely OAuth/storage/host-permission requests fired from inside the extension UI). Probably interacts with the new `permission-handler.ts` policy + stored-decision flow shipped on 2026-04-27 — chrome-extension:// origins SHOULD be hard-allowed at `permission-policy.ts:82` (`if (url.startsWith('chrome-extension://')) return 'allow'`). Investigate: (a) what URL/origin the request actually arrives with (extension popup may run under a wrapped peek:// URL or its own chrome-extension:// origin); (b) whether permission-handler is even on the request path for extension-internal requests, or whether it's a separate chrome.permissions.request flow that needs its own handler; (c) whether the denial is on the Electron permission side or upstream in chrome-extensions.ts capability gating. 42 46 - [ ] **Hidden tags framework — `from:{...}` tags are internal and should be treated that way.** Surfaced 2026-04-28. Some tags are bookkeeping (`from:rss-feed`, `from:share-extension`, `from:import`, etc.) — they're tracked for provenance/debugging but pollute the user-facing tag list, autocomplete, and the Tags UI. We need a generalized notion of "hidden" tags. Sketch: (a) a naming convention or a tag-metadata flag (`hidden: true`) that excludes a tag from default surfaces (Tags page list, cmd autocomplete, Settings → Tags); (b) a way for users to surface them on demand — a special search operator like `tag:hidden` or `#:from:rss` (explicit prefix opt-in), or a "Show hidden tags" toggle in the Tags UI; (c) decide whether existing convention tags (`from:*`, `source:*`, `_internal:*`) all auto-qualify as hidden, or whether each tag explicitly opts in. Out-of-scope until designed. Touches: `backend/electron/tag.ts` (queries), the tag noun in `features/cmd/nouns.js`, Tags page widget, Settings → Tags.
+183
tests/desktop/cmdl-on-error-page.spec.ts
··· 1 + /** 2 + * Cmd+L on a page-host showing a domain error overlay. 3 + * 4 + * User-reported bug: 5 + * 1. Open https://openstreetmaps.org (typo — domain doesn't resolve) 6 + * 2. Page-host loads, webview shows DNS error → load-error-overlay renders 7 + * 3. Press Cmd+L 8 + * Expected: address bar (navbar) opens, window stays the same size 9 + * Actual: address bar does NOT open, window MAXIMIZES 10 + * 11 + * This test pins the symptom: open page-host on an unresolvable URL, wait 12 + * for error overlay, capture initial bounds, press Cmd+L on the page-host, 13 + * assert (a) bounds unchanged, (b) navbar visible. 14 + * 15 + * Run with: 16 + * yarn test:grep "Cmd\+L on error page" 17 + */ 18 + 19 + import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; 20 + import { Page } from '@playwright/test'; 21 + import { waitForExtensionsReady } from '../helpers/window-utils'; 22 + 23 + let sharedApp: DesktopApp; 24 + let sharedBgWindow: Page; 25 + 26 + test.beforeAll(async () => { 27 + sharedApp = await getSharedApp(); 28 + sharedBgWindow = await sharedApp.getBackgroundWindow(); 29 + await waitForExtensionsReady(sharedBgWindow); 30 + }); 31 + 32 + test.afterAll(async () => { 33 + await closeSharedApp(); 34 + }); 35 + 36 + async function openCanvasPage( 37 + bgWindow: Page, 38 + url: string, 39 + ): Promise<{ pageWindow: Page; windowId: number }> { 40 + const result = await bgWindow.evaluate(async (targetUrl: string) => { 41 + return await (window as any).app.window.open(targetUrl, { 42 + width: 800, 43 + height: 600, 44 + }); 45 + }, url); 46 + expect(result.success).toBe(true); 47 + const pageWindow = await sharedApp.getWindow('page/index.html', 15000); 48 + expect(pageWindow).toBeTruthy(); 49 + return { pageWindow, windowId: result.id }; 50 + } 51 + 52 + test.describe('Cmd+L on error page @desktop', () => { 53 + test('Cmd+L on a page-host with a load-error overlay shows navbar without maximizing', async () => { 54 + const badUrl = 'http://nonexistent-cmdl-bug-xyz.invalid/'; 55 + const { pageWindow, windowId } = await openCanvasPage(sharedBgWindow, badUrl); 56 + 57 + try { 58 + // Wait for the error overlay — same state the user reports. 59 + await pageWindow.waitForFunction( 60 + () => !!document.querySelector('.load-error-overlay'), 61 + undefined, 62 + { timeout: 15000 }, 63 + ); 64 + 65 + // Confirm navbar exists and is currently NOT visible (post-loading auto-hide). 66 + await pageWindow.waitForFunction( 67 + () => { 68 + const navbar = document.getElementById('navbar'); 69 + return navbar && !navbar.classList.contains('visible'); 70 + }, 71 + undefined, 72 + { timeout: 10000 }, 73 + ); 74 + 75 + const beforeMaximized = await pageWindow.evaluate( 76 + () => document.body.classList.contains('maximized'), 77 + ); 78 + expect(beforeMaximized).toBe(false); 79 + 80 + // Bring the page-host to front so Cmd+L is delivered to its webContents. 81 + await pageWindow.bringToFront(); 82 + 83 + // Synthesize Cmd+L into the page-host webContents via Electron's 84 + // sendInputEvent — this exercises the full before-input-event chain 85 + // (host + guest handlers in ipc.ts → publish page:show-navbar → 86 + // page.js subscriber → show()), and the main.ts before-input-event 87 + // fallback. Playwright's keyboard.press dispatches DOM events which 88 + // do NOT trigger Electron's input pipeline. 89 + const sendResult = await sharedApp.evaluateMain!((electron: any) => { 90 + const { BrowserWindow, webContents } = electron; 91 + const wins = BrowserWindow.getAllWindows(); 92 + const host = wins.find((w: Electron.BrowserWindow) => 93 + w.webContents.getURL().includes('app/page/index.html'), 94 + ); 95 + if (!host) return { error: 'no page-host window found' }; 96 + const targets: Electron.WebContents[] = [host.webContents]; 97 + const all = webContents.getAllWebContents(); 98 + for (const wc of all) { 99 + if ((wc as any).hostWebContents?.id === host.webContents.id) { 100 + targets.push(wc); 101 + } 102 + } 103 + for (const wc of targets) { 104 + wc.sendInputEvent({ type: 'keyDown', keyCode: 'l', modifiers: ['meta'] } as any); 105 + wc.sendInputEvent({ type: 'char', keyCode: 'l', modifiers: ['meta'] } as any); 106 + wc.sendInputEvent({ type: 'keyUp', keyCode: 'l', modifiers: ['meta'] } as any); 107 + } 108 + return { ok: true, targetCount: targets.length, hostId: host.id }; 109 + }) as any; 110 + expect(sendResult.error, JSON.stringify(sendResult)).toBeUndefined(); 111 + 112 + // Give the renderer time to react. We wait for *either* navbar visible 113 + // OR maximized class — both are observable state changes — then assert. 114 + await pageWindow.waitForFunction( 115 + () => { 116 + const navbar = document.getElementById('navbar'); 117 + const maxed = document.body.classList.contains('maximized'); 118 + return (navbar && navbar.classList.contains('visible')) || maxed; 119 + }, 120 + undefined, 121 + { timeout: 5000 }, 122 + ).catch(() => { /* swallow — assertions below report the actual state */ }); 123 + 124 + const afterMaximized = await pageWindow.evaluate( 125 + () => document.body.classList.contains('maximized'), 126 + ); 127 + const navbarVisible = await pageWindow.evaluate(() => { 128 + const navbar = document.getElementById('navbar'); 129 + return !!(navbar && navbar.classList.contains('visible')); 130 + }); 131 + // Stacking check — the navbar must sit ABOVE the load-error overlay so 132 + // the user can see and click the URL field. Pre-fix the overlay was at 133 + // z-index:9998 vs navbar:100 — the navbar got `.visible` in the DOM but 134 + // was hidden behind the overlay, so the user reported "address bar 135 + // didn't open". The dark overlay also stretched to fit show()'s 136 + // panel-overhang window expansion, which the user perceived as 137 + // "window maximizes". 138 + const stacking = await pageWindow.evaluate(() => { 139 + const navbar = document.getElementById('navbar'); 140 + const overlay = document.querySelector('.load-error-overlay'); 141 + const z = (el: Element | null) => el ? parseInt(getComputedStyle(el).zIndex || '0', 10) : NaN; 142 + return { navbarZ: z(navbar), overlayZ: z(overlay) }; 143 + }); 144 + 145 + // The user-perceived "content area" must stay anchored to the 146 + // webview/center-column even when show() expands the window for 147 + // side-panel overhang. Specifically: the load-error overlay must NOT 148 + // stretch to fill the wider window — it must stay sized to the 149 + // center-column it lives inside. Pre-fix, the overlay was 150 + // `position: fixed` anchored to the viewport and grew with the window 151 + // (1236px), making the user perceive their "content area" as having 152 + // ballooned to the screen edges on a mere Cmd+L. 153 + const sizes = await pageWindow.evaluate(() => { 154 + const wv = document.getElementById('content') as HTMLElement; 155 + const ov = document.querySelector('.load-error-overlay') as HTMLElement; 156 + const cc = document.getElementById('center-column') as HTMLElement | null; 157 + const r = (el: HTMLElement | null) => el ? { 158 + rectWidth: Math.round(el.getBoundingClientRect().width), 159 + rectLeft: Math.round(el.getBoundingClientRect().left), 160 + } : null; 161 + return { 162 + webview: r(wv), 163 + overlay: r(ov), 164 + centerColumn: r(cc), 165 + documentWidth: document.documentElement.clientWidth, 166 + }; 167 + }); 168 + // Webview width unchanged. 169 + expect(sizes.webview!.rectWidth).toBe(800); 170 + // Overlay sized to its container (center-column), NOT to the window. 171 + expect(sizes.overlay!.rectWidth).toBe(sizes.centerColumn!.rectWidth); 172 + expect(sizes.overlay!.rectWidth).toBeLessThan(sizes.documentWidth); 173 + 174 + expect(afterMaximized).toBe(false); 175 + expect(navbarVisible).toBe(true); 176 + expect(stacking.navbarZ).toBeGreaterThan(stacking.overlayZ); 177 + } finally { 178 + await sharedBgWindow.evaluate(async (id: number) => { 179 + return await (window as any).app.window.close(id); 180 + }, windowId); 181 + } 182 + }); 183 + });