Windows: guard focus(), unstick the opening class, add child windows - #615
Conversation
Three related pieces of window-integrity work, all sharing one symptom — a window present in the DOM that the user cannot use. focus() took a Window with no runtime check, and pushed its argument onto _stack BEFORE calling setZIndex() across every member. A string id — the form the docs and built-in-commands already show — left a non-Window wedged in the stack, and every later focus() threw on it: click-to-focus, dock activation and open-reuse all dead until a reload. It now accepts `Window | string`, resolves ids, and rejects anything else before touching the stack. Unknown ids stay a silent no-op, matching raise(): a window closing between an id being captured and focus being requested is a routine race. raise() was reported alongside it and is not affected — it already takes a string and returns early on an unknown id. Two call sites reached for `window.openStation.windowManager`, a global the shell never defines (the namespace is `wp.os`), with fabricated signatures to match. Optional chaining meant neither threw: conflict-toast's "View folder" silently did nothing, and leaving a shared folder silently left its window open. Both now go through wp.os, and the folder-close goes through the Window, where close() actually lives. .os-window--opening had its removal hanging on a single once-only, unfiltered animationend. Two failures: animationend bubbles, so a spinner finishing inside the window body consumed the listener and cut the open animation short; and a hidden document never advances the animation at all, so the event never arrives and the class — whose animation's `from` frame is opacity 0 / scale(0.92) — leaves the window painted invisible. Now filtered on animationName + target, paired with animationcancel and a timeout deadline, and skipped outright while the document is hidden. Child windows are the new API: openChild( parentWindowId, config ) opens a real window — own chrome, drag, resize, taskbar entry — that its owner can never sit above. Clicking the owner shakes the child and leaves focus there. The redirect lives inside focus(), so every focus path is covered by construction rather than per call site. The owner stays fully usable; only its z-order is constrained. That is the point of using a window instead of a dialog. Ownership cascades on close, minimize and restore; a minimized child stops blocking; children are excluded from session snapshots so a reload cannot restore a child blocking an owner that failed to come back. Two subtleties carry comments where they live: the stack pass exempts minimized windows, or hoisting an invisible child to the top would hand focus to a window nobody can see; and blockingChildOf carries a cycle guard, since a plugin declaring A-owns-B-owns-A would otherwise spin on the first click. 28 tests across two new files. Docs: javascript-reference, event-driven-framework, api-index, plus a new docs/examples/child-windows.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@claude can you review? |
|
Claude finished @AllTerrainDeveloper's task in 5m 24s —— View job Review — window focus guard, opening-class fix, child windows
Note on tooling:
|
Review follow-ups on #615. openChild() computed a width and height from the owner's rect, used them for the centering math, and then never passed them to open(). Unless the caller pinned a size explicitly, open() resolved its own from the desktop rect (or saved geometry) — so the x/y computed for an 80%-of-owner-sized child got applied to a child that opened at some other size, and the result was not centered. The no-explicit-size call shape is the one the docs show, and the only placement test passed width/height, so nothing caught it. Size and position are now pinned together on the centering path, with a clamp to the desktop area so a child of an owner hanging off an edge still opens on screen. While there: a remembered geometry for the child now wins over centering. A child is a real window and gets the same per-baseId geometry memory as any other — pinning size unconditionally would have reset the user's own placement on every open. Second review point: an ownership cascade emitted a focus change per window it moved. Each cascaded child's minimize() re-entered onMinimize, which settled focus on an intermediate window before the cascade finished, so minimizing an owner with three children fired four WINDOW_FOCUSED/WINDOW_BLURRED pairs for one user action. Same shape in the children-close loop in remove(). A depth counter now suppresses intermediate focus work and lets the outermost cascade settle it once. Restore needed an ordering change rather than a guard. Window.restore() fired onFocusRequest before the children came back, when none of them blocked yet — so focus landed on the owner and had to be corrected the moment they reappeared. Restoring children first lets the single focus request that follows resolve through the normal redirect. The manager's onRestore no longer focuses anything itself. 9 tests: the documented no-size call shape, concentricity as an invariant rather than arithmetic, the off-edge clamp, saved-geometry precedence, and one-focus-change across minimize, close and restore cascades — plus a guard that an ordinary minimize with no ownership in play still settles focus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three related pieces of window-integrity work. The first two are fixes for reported bugs, both of which produced the same symptom — a window that is present, painted and unusable. The third is a new API that came out of the same conversation.
WindowManager.focus()— argument guardfocus()took aWindowwith no runtime check, and pushed its argument onto_stackbefore callingsetZIndex()across every member. Sofocus( 'some-id' )— the formjavascript-reference.mdand the comment insrc/built-in-commands.tsalready show — left a non-Windowwedged in the stack, and every laterfocus()threw on it. Click-to-focus, dock activation and open-reuse were all dead until a reload, with nothing in the console pointing at the original bad call.It now accepts
Window | string, resolves ids, and rejects anything else before touching the stack. An unknown id stays a silent no-op, matchingraise(): a window closing between the moment its id was captured and the moment focus is requested is a routine race, not a programming error. A non-Window, non-string argument warns.Two corrections to the report this came from:
raise()is not affected. It already takes astring, resolves viagetById(), and returns early onidx === -1. The describedsplice( -1, 1 )on a miss cannot happen.conflict-toast.tswas broken, but not by this. It declared its owndeclare globalassertingwindow.openStation.windowManager.focus( id ) => Window— a global the shell never defines (the namespace iswp.os) with a signature that matches nothing. Optional chaining meant it never threw and never poisoned the stack; "View folder" just silently did nothing.share-menu-items.tscarried the identical fabricated global, calling aclose()that doesn't exist on the manager either, so leaving a shared folder quietly left its window open. Both now go throughwp.os, and the folder close goes through theWindow, whereclose()actually lives..os-window--openingalways comes offRemoval hung on a single once-only, unfiltered
animationendlistener. Two ways that fails:animationendbubbles. A spinner, shimmer or holo drift finishing anywhere inside the window body consumed the listener and cut the open animation short.fromframe isopacity: 0; scale(0.92)— so a window opened in a backgrounded tab (session restore the user tabs away from, anos-open-requestedfrom another surface, automation) stays painted invisible.Now filtered on
animationName+target, paired withanimationcancel, backed by a timeout deadline, and skipped outright whiledocument.hidden— nothing to animate for a document nobody is watching, so the stuck state can't arise at the source.One correction here too:
.os-window--openingcarries nopointer-events: nonein our CSS, and neither do unfocused windows. The reported "unclickable" framing isn't ours — that's either the third-party plugin's own CSS or the focus-stack poisoning above. The stuck-class hazard is real regardless; it just makes the window invisible rather than inert.New: child windows
openChild( parentWindowId, config )opens a real window — own chrome, drag, resize, minimize, taskbar entry — with one rule layered on top: its owner can never sit above it. Clicking the owner shakes the child and leaves focus there.For the shapes a modal dialog gets reached for when what you actually want is a window: a full editor for one row of a list, a wizard beside the page it configures, a diff over the revision it belongs to.
The owner stays fully usable — scrollable, draggable, resizable, minimizable. Only its z-order is constrained; that's the reason to use a window rather than a dialog in the first place. The redirect lives inside
focus(), so click-to-focus, dock, taskbar, alt-tab and open-reuse are covered by construction rather than per call site.Surface:
openChild,ownerOf,childrenOf,blockingChildOf, theos-window-child-blockedevent, theos.window.child-blockedaction, andWindowConfig.parentWindowIdas the primitive.Behaviors, each with a test:
Two subtleties carry comments where they live: the ownership stack pass exempts minimized windows, or hoisting an invisible child to the top would make
setFocused( i === length - 1 )hand focus to a window nobody can see; andblockingChildOfcarries a cycle guard, since a plugin declaring A-owns-B-owns-A would otherwise spin on the first click.Testing
28 new tests in
tests/vitest/window-focus-guard-and-opening-class.test.tsandtests/vitest/window-child-ownership.test.ts.npm run test:js— 4458 passnpm run lint— cleannpm run typecheck— cleannpm run build— runNo PHP touched.
Worth a manual pass before merge: that the opening animation still looks right on an ordinary open, and that a child window's shake-on-blocked-focus reads as helpful rather than annoying. Those two are feel, not logic, and no browser was driven for this.
🤖 Generated with Claude Code