Skip to content

feat(os): Aero-style window snap zones (NEXT.md 3.2) - #103

Merged
xcjs merged 3 commits into
mainfrom
feat/window-snap
Oct 4, 2026
Merged

xcjs merged 3 commits into
mainfrom
feat/window-snap

Conversation

@xcjs

@xcjs xcjs commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Closes #102 (NEXT.md step 3.2).

What

Aero-style snap zones on desktop (>= 768px; drag stays disabled below
md per 3.1 / ADR 0004):

  • Drag toward the left/right EDGE (24px) band: translucent preview
    overlay teleports to body showing the target half; release calls
    snapWindow(id, 'left'|'right').
  • Top edge: preview full work area, release maximizes.
  • Top/bottom corners (when also within the edge band): quarter tiles.
  • Dragging a snapped window away: the first pointermove sees
    win.preSnap and calls unsnapWindow to restore the rect held
    before the snap, then the drag continues from there.
  • Zones: corners outrank edges (tl/tr/bl/br then
    max/left/right); corners outrank max only when both edges match.

Store changes (os.ts): SnapZone type, preSnap on OsWindow,
snapWindow, unsnapWindow, setWindowRect (normal-state resize).
No kernel callers changed; single writer on the os store (this lane).

Files

  • app/features/os/stores/os.ts
  • app/features/os/components/WindowFrame.vue
  • tests/e2e/window-snap.spec.ts (new)

Verification

  • typecheck exit 0, production build exit 0
  • e2e window-snap.spec.ts: left half, drag-away restore, top-left
    quarter (y = 4 gap offset), top maximize; full suite 4/4 green
    locally against the production build

Not in this PR

Multi-desktop workspaces (split issue later per one-purpose-per-PR).

@m-vawter m-vawter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice feature, and the preview overlay reads well. CI is green, and the spec covers the left half, drag-away, a quarter and maximize.

Requesting changes for one interaction I measured in a production build (main plus this branch, 1280×800, Playwright).

Grabbing a snapped window starts inside the top snap band. A left-snapped window's title bar sits at y≈4–36, and EDGE is 24px, so grabbing it at the header's center (y=23) is already in the max zone. Any drag that stays in that band maximizes the window on release:

  • dragging the snapped window 40px sideways: {x: 0, y: 4, w: 640, h: 744} became maximized, {0, 0, 1280, 752};
  • a click with a 1px move on its title bar (ordinary mouse or trackpad jitter) also maximized it.

The spec's drag-away step moves 200px down first, so it never sees this. One way out is to arm the zones only after the pointer has left the band it started in, or has moved past a few-px threshold. Another is a thinner top trigger, closer to the screen edge, as Windows does.

Smaller, in the same area:

  1. Drag-away puts the window at the top. snapWindow stores preSnap.y: 0 for halves and quarters (and the width ternary returns win.width on both branches). From a left snap, a drag 40px right and 60px down ended at y=60; restoring the pre-snap y would give 150. y: win.y fixes it.
  2. A small drag threshold before unsnapWindow (a few px) would keep a click on a snapped title bar from restoring it.

For the spec (tests lane), three cheap regression guards: a sideways drag of a snapped window stays snapped, a 1px click leaves it snapped, and drag-away restores the pre-snap y. Optional:

  • the right half and one bottom corner;
  • the preview overlay checked mid-drag;
  • appWindow() from support/desktop instead of the repeated locator;
  • toBeCloseTo on the pixel checks;
  • asserting or dropping the unused restored.x/y.

Also, upperHalf in zoneAt is unused.

Heads-up: #100 also edits WindowFrame.vue's pointer handlers, so whichever lands second needs a rebase. The "drag stays disabled below md per 3.1" note depends on #100 landing first.

@xcjs

xcjs commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Fixed on cb73696. Chose the thinner-top-trigger option: atTop now uses a 6px band (TOP) instead of the 24px EDGE, so a left-snapped header (y 4-36) starts outside the max zone; grabbing it mid-header then only maximizes if the pointer actually reaches the top 6px. Added an ARM=8px threshold before zones arm (and before unsnapWindow fires), so 1px jitter clicks neither re-snap nor restore. preSnap.y now stores win.y (the y=0 bug), and the unused upperHalf is gone. Spec gains the three guards (in-band sideways drag never maximizes, 1px jitter keeps the snap, drag-away restores pre-snap y >500); full suite locally is 5/5 green. Heads-up noted: #100 lands first, this rebases after.

@xcjs
xcjs requested a review from m-vawter October 3, 2026 14:58

@m-vawter m-vawter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turn. I re-verified at cb73696 in a fresh container (this branch merged into current main; full e2e suite 8/8). Two of the three are fixed: a 1px click on a snapped title bar leaves it snapped, and a sideways drag no longer maximizes.

The drag-away restore still lands in the wrong place, and my suggestion last round (y: win.y) was the wrong fix, sorry. When the zone fires, win.x/y is where the snapping drag has carried the window, not where it started. In the probe, the chat window starts at (120, 90) and is dragged by its title bar to the left edge. After that:

  • a 40px sideways drag-away lands it at x −216, y 381, mostly off-screen and nowhere under the pointer;
  • with 60px of downward movement it lands at y 441.

Two ways to fix it:

  1. Keep the title bar under the pointer on unsnap, as Windows does: restore the width and height, then place the window so the pointer keeps its relative spot on the title bar.
  2. Or record the rect at pointerdown, before the drag moves it, and store that as preSnap.

A spec guard for either: after the drag-away, the pointer is still inside the window's header box.

xcjs added 2 commits October 3, 2026 20:34
Drag to the left/right edge tiles halves, to the top edge maximizes,
to a corner tiles quarters; a translucent preview shows the target
rect while a zone is active. os.ts gains snapWindow/unsnapWindow/
setWindowRect with pre-snap geometry stored on the window record, so
dragging a snapped window away restores its previous rect. Desktop
only: drag stays disabled below 768px. E2e covers half snap, restore,
quarter, and maximize.
@xcjs
xcjs force-pushed the feat/window-snap branch from cb73696 to d9df3bd Compare October 4, 2026 00:36
@xcjs

xcjs commented Oct 4, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main (now includes #100 mobile shell): WindowFrame.vue conflict resolved as union of both features — mobile's isPhone/scroll-into-view + snap's SnapZone constants (review fix commit d9df3bd unchanged on top). Verified locally: typecheck 0, build 0, e2e 6/6 (both snap tests + mobile test coexist). @m-vawter re-review requested.

@xcjs
xcjs requested a review from m-vawter October 4, 2026 00:36
@xcjs

xcjs commented Oct 4, 2026

Copy link
Copy Markdown
Owner Author

Windows-style unsnap landed (3f27ada): while \preSnap\ is set, the first armed move restores the saved width/height and re-places the window so the pointer keeps its relative grab spot on the title bar (grab fraction clamped so at least 8px of header stays grabbable, grab Y clamped into the 36px header); the movement delta on that event is skipped so there is no double-jump. \os.ts\ \snapWindow\ no longer stores the dragged-away position for maximized preSnap (\y: 0, real width). Spec guards updated: after both drag-away steps the restored window's header box must contain the final pointer position (the stale-origin bug teleported the window away from the pointer and fails this). typecheck 0, build 0, e2e 6/6. Re-review requested.

@m-vawter m-vawter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified at 3f27ada in a fresh container, merged into current main, so alongside #100's changes to WindowFrame.vue. The full e2e suite passes (9/9 with my probes).

The drag-away now keeps the title bar under the pointer. From a left snap, a 40px sideways drag-away restores 520×560 at (92, 4) with the pointer inside the header. With 60px of downward movement, the window follows to y 53. A 1px click still leaves it snapped, and a sideways drag doesn't maximize. Nice fix, and thanks for the header-contains-pointer guard. Approving.

@xcjs
xcjs requested a review from m-vawter October 4, 2026 02:23
@xcjs
xcjs merged commit 688046a into main Oct 4, 2026
4 checks passed
@xcjs
xcjs deleted the feat/window-snap branch October 4, 2026 02:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3.2 Window snapping and tiling: drag-to-edge halves, corner quarters, top maximize

2 participants