Repository navigation
Roadmap
V0.1 shipped a minimal slice (7 hardcoded layouts, hotkeys, AX permission, DMG). V0.2 + V0.3 + V0.4 bundle together as the first public release (v0.4.0, April 2026): customization, smooth animation, drag-to-swap, Workspaces, thumbnails, vertical seeds, multilingual UI. V0.4.1, V0.4.2, V0.4.3 are patch releases on top. V0.5.0 shipped Apple notarization, V0.5.1 continuous update detection, V0.5.2 the first-launch welcome window, V0.5.3 smoother animation + universal binary (Intel support), V0.5.4 first-launch reliability + Accessibility upgrade recovery, V0.5.5 quit-relaunch update detection + Workspaces delete button, V0.5.6 in-app update installer, V0.5.7 user-authored custom layouts + companion resize on drag + dirty-state save, V0.6.0 privacy-first diagnostics + persistent workspaces with assigned Desktops + enforcement modes, V0.6.1 Free Mode toggle that pauses all of Setpiece's automatic behavior without quitting. V0.7.0 opens an automation surface (scene:// URL scheme + AppIntents for Shortcuts / Siri), adds per-display Workspace layouts, and fixes drag-to-swap reliability + the Free Mode toggle-back. V0.7.1 fixes vertically asymmetric layouts applying upside down and seam-resize scrambling tree-based custom layouts. V0.7.2 dresses the Settings window in macOS 26's Liquid Glass — transparent chrome, floating glass sidebar, and a translucent window on Tahoe. V0.7.3 makes re-applying a layout non-destructive (windows already in a slot stay put) and turns the menu bar dropdown into a panel that survives clicks. V0.7.4 makes layout geometry independent of where the Dock is, so re-applying on a multi-display Mac is genuinely stationary, ticks the active layout in the menu panel, and fixes update checks walking users up one version at a time. V0.7.5 restores the Settings toolbar on macOS 26, where V0.7.2's translucency backdrop had removed every create / duplicate / delete button from Layouts and Workspaces. V0.7.6 stops dialogs, sheets and floating panels from being tiled, makes V0.7.4's every-display Dock reserve optional so windows reach the bottom edge again, and closes a crash-on-launch where a settings file from a newer build made an older one refuse to start.
- Drag-to-swap and seam resize work on every display at once. Three failures on a two-display Mac, all reproduced on a real MacBook + external monitor: a window dragged to the other display stayed recorded in its old slot, so the next swap pulled it back; a drop with the pointer on the other display, while the window's centre was still over the layout's display, fought macOS's own move-to-display and left the window straddling both at the wrong size; and the coordinator's single global snapshot meant applying on one display dropped drag-swap on the other.
-
Fix:
PlacementRegistry, the per-screen snapshot map this roadmap had planned. One snapshot per display. Recording a display strips its windows from every other snapshot and prunes unplugged displays; controllers look up the snapshot holding the dragged window.AXMoveObserverGroup.setObserveddiffs the observed set across displays instead of rebuilding on every apply.DragSwapControllertreats a drop off the layout's display as the window leaving the layout (onWindowsLeftLayout) and never pulls back a displaced window that has left. -
Gotcha: use
NSMouseInRect, notCGRect.contains, for "pointer on this display". On a display's top rowNSEvent.mouseLocation.y == frame.maxY, whichcontainsexcludes, so a drag through the menu bar would be released mid-drag. -
WhatsApp tiles again. Its main window reports
AXSubrole = AXDialog, which V0.7.6's deny-list skipped. A denied subrole is now rescued when the full-screen button is enabled, or minimise and zoom both are (WindowChrome).AXSheet/AXDrawernever are. -
The every-display Dock reserve is off by default, and settings v5 turns it off for upgraders. V0.7.4's stability goal is kept another way:
LayoutEngine.plan(alternateFrames:)treats a window as already in its slot if it matches that slot in any ofTilingFrame.stickyCandidates(currentvisibleFrame, Dock reserved, menu bar only). -
CI fixed: the Xcode project referenced SceneCore as
../../macbook-resizer, which broke every push since the repo rename; it is now... - Tests 438 -> 467. Re-apply after a real Dock hop is unit-tested only, since synthetic pointer events do not move the Dock. Verified on macOS 27.
-
Settings no longer freezes and crashes on open under macOS 27. On v0.8.0 under macOS 27.0.1, the window locked up as soon as it appeared, then the process died with
NSGenericException: "The window has been marked as needing another Update Constraints in Window pass, but it has already had more Update Constraints in Window passes than there are views in the window." It happened on every open, because Settings always opens on the Workspaces tab. -
Cause: a
NavigationSplitViewnested inside another.WorkspacesTabwas aNavigationSplitViewin the detail column ofSettingsRoot's. On the window's first layout, the inner split's SwiftUI column kept reporting a new minimum size (SplitViewChildController.hostingView(_:didUpdateMinSize:)), and each report invalidated the root hosting view's constraints mid-pass, so the pass never settled. Bisected in a real build: starting on Layouts (anHSplitViewin the same detail column) or About opens cleanly, and turning off the macOS 26 title-bar chrome does not help. Switching to Workspaces after the window is up, resizing, and close-and-reopen never crashed. -
Fix:
WorkspacesTabis anHSplitView, likeLayoutsTab. The toolbar moved to the split view and still bridges into the window toolbar, because the hosting controller is still the window's root (the V0.7.5 constraint). The editor pane is 304 pt wide before and after at the default window size. -
Rule for future Settings work: never nest a
NavigationSplitViewinsideSettingsRoot's detail column. UseHSplitView. - Tests: 438, unchanged. Verified only on macOS 27.0.1.
-
Renamed Scene -> Setpiece, and nothing that identifies an existing install changed. The bundle identifier stays
com.hillman.SceneApp, because TCC anchors the Accessibility grant to the identifier and signature — a new one would silently revoke permission for every user at once, and since macOS keeps showing the toggle as ON whileAXIsProcessTrustedreturns false, they would experience it as "the app stopped working" rather than "I need to re-grant". Application Support staysScene. What users see isCFBundleDisplayName. -
The bundle on disk stays
Scene.app, and this is the constraint that shaped the whole rename. Every updater shipped up to v0.7.6 checks[ ! -d "$MOUNT/Scene.app" ]and aborts if it is missing, so a DMG carrying any other filename would break in-app updates for the entire install base.UpdateInstallernow resolves whichever.appthe mounted DMG carries (find "$MOUNT" -maxdepth 1 -name "*.app"), which makes a real bundle rename a one-release change once this updater has been in the field. -
scene://is permanent,setpiece://is additive. Shortcuts, Raycast scripts and bookmarks built against the old scheme are invisible to the app and cannot be migrated, so the old scheme is never dropped.URLRouter.acceptedSchemesholds both; five tests pin them and were run against the pre-rename router first, where three fail. - Version chosen as minor, not patch. The version string is the only thing the updater puts in front of a user; "0.8.0 — now called Setpiece" gets read where "0.7.7" gets skipped. Cut when v0.7.6 had six downloads, so nearly the whole install base jumps straight from a Scene-named build to a Setpiece-named one and sees exactly one rename.
-
Xcode 27.0 builds Release at
-Owithout the SIL optimizer ICE that forcedSWIFT_OPTIMIZATION_LEVEL=-Ononeunder Xcode 26.x — verified against this project, universal, deployment target 14.0. The override is deliberately left in for v0.8.0: a rename release should not also change codegen, or a regression becomes ambiguous. The optimized binary measured 3.2MB against 120KB, which is worth understanding before shipping. -
Homebrew token
scene->setpiece, withcask_renames.jsonmapping the old one sobrew upgradekeeps resolving. Tested:brew info --cask chifunghillmanchan/tap/sceneresolves tochifunghillmanchan/tap/setpiece. -
Install docs now cover
brew trust. Unrelated to the rename: Homebrew 7 raises rather than prompting for untrusted third-party taps, so the previous one-line install instruction fails with no hint of the fix. - Tests 433 -> 438. Universal (arm64 + x86_64), macOS 14+, Apple notarized.
The app was called Scene through v0.7.6. This wiki now uses the new name
throughout, but nothing about an existing install changed: the bundle
identifier is still com.hillman.SceneApp so the Accessibility grant survives,
data still lives in ~/Library/Application Support/Scene, the bundle on disk is
still Scene.app, and scene:// URLs keep working alongside the new
setpiece://. The rename is presentation only.
-
Dialogs, sheets, inspectors and floating palettes are no longer tiled.
AXWindowEnumeratoraccepted every layer-0 window whose AX frame matched itsCGWindowListbounds — which is every window an app owns, dialogs included. A preferences dialog or a Get Info panel therefore consumed a layout slot, re-shuffled every real window and pushed the last one intotoMinimize: a three-way split could be scrambled by ⌘I in Finder. NewWindowSubrole(SceneCore/AX) readskAXSubroleAttributeand rejectsAXDialog,AXSystemDialog,AXFloatingWindow,AXSystemFloatingWindow,AXSheetandAXDrawer; sheets and drawers are attached to a parent window and cannot be positioned independently at all. -
The subrole test is a deny-list, never an allow-list. Many apps leave the subrole unset or report something bespoke. Requiring
AXStandardWindowwould silently stop tiling an entire app rather than one stray panel — a failure a user cannot diagnose and would reasonably read as "Setpiece is broken". So a missing, empty or unrecognized subrole still tiles.AXUnknownis deliberately off the list for the same reason: it means "the app did not classify this", which is ambiguous rather than disqualifying, and the layer-0 test already discards the overlay windows that usually carry it. Verified against live windows rather than only unit tests: of four probe windows reaching the layer-0 gate, theAXDialogandAXFloatingWindowones are now skipped and the document windows still tile. -
SettingsStore.dockReserveAllDisplaysmakes V0.7.4's trade-off a choice. Reserving the Dock's thickness on every display keeps the tiling rect stable while the Dock roams, at the cost of a dead strip along the bottom of whichever display the Dock is not on — measured at 55 pt on a real two-display setup. Settings → Interaction → Dock space turns the reserve off, restoring rawvisibleFrame: windows sit flush on the bottom edge, and the roaming-Dock shift returns with it. The hint text states that cost plainly instead of presenting the toggle as a free win. Default on, and upgraders migrate with it on — they already have the stable behaviour and must not have it changed under them. Single-display Macs are unaffected either way, since the maximum inset over one screen is that screen's own. -
All three consumers of the tiling rect read the same setting —
LayoutEngine.plan, drag-swap nearest-slot and seam reflow — because a drag measured against a different rect than the layout was tiled into snaps windows to the wrong slot. -
Settings schema v3 → v4, and forward-compatible from here on.
AppDelegate.initturns any store-init throw intofatalError, anddecodeWithMigrationthrew on every schema version it did not recognize — so anyone who ran a newer build and went back got an app that refused to launch with no indication why. This was reproduced live while testing this release, not theorised: running the v0.7.6 build upgradedsettings.json, and the v0.7.5-era code then died at startup withUnsupported settings schema version 4. A version abovecurrentVersionis now decoded leniently through aLenientFileshape (every field optional): take what this build understands, default the rest. Genuinely unsupported versions still throw, since those are corruption rather than a file from the future. The file is deliberately not rewritten on load, so returning to the newer build finds its settings intact; saving a setting does rewrite at this build's version, but that is the user's own action.decodeWithMigrationalso returns a struct instead of a 4-tuple — a fifth positionalBoolwas one transposition away from a silent bug. -
A one-shot GitHub star nudge.
StarPromptState(SceneCore) holds the rules as a pure value type: count layouts that actually landed, prompt at 20, and treat either answer as permanent. The counter saturates at the threshold so a long-lived install cannot overflow it, stops moving once answered, and decodes defensively so a hand-edited or future-written payload can neither crash the app nor re-arm a prompt already dismissed.StarPromptTracker(SceneApp) persists toUserDefaultsrather thansettings.json, because it ticks on a hotkey-rate path whileSettingsStore.persist()does an atomic whole-file write — the same reasoning ashasShownFirstLaunchWelcomeV1. The row sits above Settings in the menu panel so it never displaces a workspace or layout the user opened the menu to reach. Only applies that reach the end ofperformApplyLayoutcount: a fire that finds no windows returns early and does not. -
Repository renamed
macbook-resizer→scene-macos, and the README leads with an animated demo instead of a still frame linking to a 13 MB MP4 that GitHub will not inline-play. GitHub's permanent redirects were verified against all three paths already-shipped builds depend on: the releases APIUpdateCheckerpolls (301 → 200), release-asset downloads the Homebrew cask resolves (200), and the web URL (301). -
Tests 394 → 433. Twelve
WindowSubroleTestspin the deny-list contract including the missing / empty / unrecognized guarantees; tenDockReserveTestspin the reserve on both displays, the default, and single-display identity; fourSettingsForwardCompatibilityTestspin the future-file path; thirteenStarPromptStateTestspin threshold, permanence and decode robustness. Universal (arm64 + x86_64), macOS 14+, Apple notarized.
-
Settings toolbar restored on macOS 26. Every toolbar button was missing on Tahoe:
+,+ Custom, delete, reset-to-seed and Restore Default Presets on Layouts; New, Duplicate and Delete on Workspaces. Layouts and workspaces could still be edited, fired by hotkey, and activated from the menu bar — but the entire create/duplicate/delete surface was unreachable, so a new Tahoe user opening Settings found no way to add anything. Reported as #4. -
Root cause: a child hosting controller publishes no toolbar. V0.7.2's whole-window translucency made an
NSVisualEffectViewcontainer view controller the window'scontentViewControllerand nested the SwiftUINSHostingControllerinside it as a child, constrained to the container's edges.NSHostingControlleronly publishes its SwiftUI.toolbarcontent intoview.window.toolbarwhen it is the window's content view controller; as a child it publishes nothing — no warning, no compile error, no runtime log. The window therefore hadwindow.toolbar == nil: not an empty toolbar, no toolbar at all, so nothing any tab declared could render. -
How it was isolated. All four combinations of (hosting controller wrapped in a container vs. as the window's root) × (Tahoe
fullSizeContentView+ transparent title bar + hidden title vs. plain chrome) were built and theirwindow.toolbarinspected after layout. Only the two wrapped cases producednil; both root cases produced a visible three-item toolbar. The transparent title bar — the obvious suspect, since it was introduced alongside the regression — was innocent. -
Fix. The
NSHostingControlleris the window's rootcontentViewControlleragain, and the.underWindowBackground/.behindWindowmaterial moves into SwiftUI as abackgroundlayer via a small privateNSViewRepresentable, applied through aWindowBackdropview modifier that is a no-op below macOS 26. The Liquid Glass appearance is unchanged — floating glass sidebar, transparent title bar, whole-window blur — and the two-branch window construction collapses into one path shared by every macOS version. The call site carries a comment naming the trap so it is not reintroduced. -
Scope. macOS 14 and 15 were never affected; they took the untouched
elsebranch. Affected releases were v0.7.2, v0.7.3 and v0.7.4, on macOS 26 only. -
Tests 394/394, unchanged. The defect lives in AppKit/SwiftUI window plumbing inside SceneApp, which
swift test(SceneCore only, framework-neutral by design) structurally cannot reach — so no unit test would have caught it and none was added to imply otherwise. Verified against the running app instead: the Layouts toolbar reportsAdd, square.on.square.squareshape.controlhandles, Bin, arrow.counterclockwise, Restore Default Presetsand Workspaces reportsNew, Duplicate, Bin. Universal (arm64 + x86_64), macOS 14+, Apple notarized.
-
Dock-stable tiling frame.
NSScreen.visibleFramereserves Dock space only on the display the Dock currently occupies, and the Dock hops between displays with the pointer — so the tiling rect was a function of recent mouse history rather than of the screen. Moving the Dock to another display and re-firing a layout shifted every slot by the Dock's thickness, well outside V0.7.3's 10 pt sticky tolerance, so every window was resized and re-assigned by z-order: the sticky guarantee silently evaporated on exactly the setups it mattered most on. NewTilingFrame(SceneCore/Display) unifies the Dock reserve across all screens — max of the left/bottom/right insets, top kept per-screen because the menu bar does not follow the pointer. Since the Dock occupies exactly one display at a time, that maximum is its thickness and stays constant as it moves, making the rect a pure function of the display arrangement. Degenerate arrangements fall back tovisibleFrame, and the maximum over a single screen is that screen's ownvisibleFrame— single-display setups are byte-identical to V0.7.3. Accepted trade-off: the display the Dock is not on also reserves the strip; zero cost under Dock auto-hide. -
Interaction controllers share the frame.
DragSwapControllerandSeamResizeControllerare constructed with avisibleFrameOverridepointing atTilingFrame(reusing the injection hook that already existed for tests), so nearest-slot and reflow math measure against the frame the layout was actually tiled into rather than a rect ~70 pt different. -
Display ownership uses
screen.frame.AXWindowEnumerator's containment test moves offvisibleFrame: a window whose center sat in the Dock strip was dropped from the plan entirely, and dropped only while the Dock happened to be on that screen.frameis the correct question for "which display does this window belong to";visibleFramenever was. -
Active-layout tick.
Coordinator.activeLayoutIDis@Publishedand set on the success path ofperformApplyLayoutonly, so a fire that finds no windows or loses AX mid-call never ticks a row the screen does not reflect.MenuBarContentView.layoutRowrenders it as a trailing checkmark after the hotkey chord, in a fixed-width column present on every row so chords stay aligned. In-memory like Free Mode — a relaunch ticks nothing, because Setpiece cannot know whether the windows are still tiled. -
Update checks pick the highest version, not GitHub's
releases/latest. That endpoint is defined as the most recent non-prerelease, non-draft release sorted bycreated_at— wherecreated_atis the date of the commit the tag points at, not the publish date. A hotfix cut from an older commit therefore sorts below the release it supersedes, so a user far behind could be offered the older release, install it, and be offered the next one on relaunch: climbing versions one at a time.UpdateCheckernow listsreleases?per_page=100, drops drafts and prereleases, and selects viaindexOfHighestVersion— numeric per-component comparison, sov0.10.0correctly beatsv0.9.0where a string sort does not. The banner also clears when no newer release exists, so it cannot survive a manual DMG install. -
Tests 371 → 394. Twelve
TilingFrameTestscases pin the Dock contract (single-screen identity in both Dock states, Dock-on-A vs Dock-on-B yielding identical frames, left- and right-edge Docks, per-screen top inset, degenerate fallback, and end-to-end stationary quad slot rects). ElevenHighestVersionSelectionTestscases pin the release selector, including the out-of-ordercreated_atcase and thev0.9.0vsv0.10.0trap. Universal (arm64 + x86_64), macOS 14+, Apple notarized.
-
Sticky slot re-apply.
LayoutEngine.planmapped windows to slots purely by z-order, so re-firing the same layout after any change reshuffled every window — closing one of four Quads windows and opening a different app moved all four. Planning now runs a sticky pass first: a window whose current frame matches a slot rect within 10 pt (via the existingrectsApproxEqual) claims that slot, then the remaining windows fill the remaining slots in z-order, and leftovers minimize as before. Stateless by design — no persisted window→slot map to go stale. Sticky placements target the slot's exact rect, so sub-tolerance drift self-heals; drag-swapped positions survive re-applies because swapped windows sit on slot rects; switching to a different layout still remaps everything by z-order since no frame matches. Known limitation: apps that refuse AX resizing (Apple System Settings) never land exactly on a slot rect and are therefore never sticky. -
Placement.slotIndex. Sticky assignment can claim slots {0, 2, 3} while slot 1 stays free, so placements are no longer index-parallel tolayout.slots.Placementnow carries its slot index explicitly andCoordinator.rebuildDragSwapObserversbuildslastWindowToSlotIdxfrom it instead of the enumerated array position — the map that drag-swap and seam-resize both read. Sticky windows deliberately stay inplacements(targeting their current frame) rather than being omitted, so they keep their drag-swap observers. -
Window-style menu panel.
MenuBarExtramoves from.menuto.window: native macOS menus always dismiss on item click and that is not overridable, so Free Mode used to close the dropdown and force a re-open before firing a layout.MenuBarContentViewis now a 280 pt panel of customMenuRowButtonStylerows (rounded hover highlight, dimmed when disabled) with the same content, order, thumbnails, hotkey glyphs, and localization keys as the old menu — no new strings. Free Mode / layout / workspace clicks leave it open and update checkmarks live; a captured hostingNSWindow(PanelWindowAccessor) lets Settings, the onboarding path, the update alert, and Escape dismiss it explicitly. -
Tests 363 → 371. Eight cases in
LayoutEngineStickyPlanTestspin the contract: full re-apply is visually stationary, close-one-add-one moves only the newcomer, swaps persist, overflow minimizes leftovers not sticky windows, a different layout remaps by z-order, tolerance edges at 8 pt vs 12 pt, stacked windows resolve by z-order, and underflow gaps report the right empty-slot count. Universal (arm64 + x86_64), macOS 14+, Apple notarized.
-
Liquid Glass Settings window (macOS 26 Tahoe).
SettingsWindowControllergains an availability-gated chrome block —.fullSizeContentView, transparent titlebar, hidden title — and the hosting controller is embedded in anNSVisualEffectViewcontainer (.behindWindow,.underWindowBackground): SwiftUI'scontainerBackground(for: .window)does not bridge into a manually hostedNSWindow. The sidebar renders as the system floating glass panel; the whole window blurs whatever is behind it. macOS 14/15 keep the opaque window. -
Consistent tab chrome. A shared
DetailTabChromemodifier inSettingsRootgives all five tabs the same title strip and clears scroll backgrounds on Tahoe (previously only Workspaces/Layouts — the tabs with toolbar items — got a strip);WorkspacesTab's list gains an 8pt top content margin so the first row clears the floating panel. -
Top alignment on all OS versions.
SettingsRoot's detail frame alignment changed center →.top; About and Interaction no longer render vertically centered. - Tests 363/363 — SceneApp-only, no SceneCore changes. Universal (arm64 + x86_64), macOS 14+, Apple notarized.
-
Fix — vertical layout mirror. Every authoring surface (canvas editor, thumbnails, template slot math in
LayoutTemplate.slots/LayoutNode.flatten) describes unit slots in top-left-origin space, butSlot.absoluteRect(in:)mapped unit y straight into the bottom-left-origin NSvisibleFrame— so any vertically asymmetric layout applied mirrored (a custom 2-top/3-bottom tree landed 3-top/2-bottom; Main + Side (Vertical) put the main window at the bottom; lShapeTop/Bottom swapped). The symmetric Halves/Thirds Vertical presets masked it since V0.4. The y-axis now flips exactly once at the unit→NS materialization boundary (mirroringAXWindow.setFrame's single NS→AX flip), andLayoutReflow— the NS→unit inverse used by seam drags — flipsyFracand swaps the row templates' seam-edge mapping to match. -
Fix — seam-resize vs tree layouts. Canvas-built custom layouts carry stale
template/slotProportionsfields (leftovers from whatever they were duplicated from);SeamResizeController's context fed those intoLayoutReflow, so a mid-drag resize of any placed window could push the other windows to slot rects belonging to a completely different layout — which windows moved depended on which window echoed the resize event. The Coordinator's seam context provider now returnsnilfor tree-based layouts (free resize; reflow stays template-only). -
Tests 357 → 363. Six new cases pin the orientation contract:
Slot.absoluteRecttop/bottom anchoring, the exact reported 5-slot regression (2-top/3-bottom materializes top row above bottom row), and rewrittenLayoutReflowrow semantics (slot 0 = top band; its seam is the window's NSminYedge). Universal (arm64 + x86_64), macOS 14+, Apple notarized. - Upgrade note. Users who rebuilt a layout upside down to compensate for the old behavior need to re-edit it once — layouts now apply exactly as drawn.
-
Automation surface. New
scene://URL scheme (URLRouter+AutomationCommandin SceneCore, wired throughAppDelegate+AutomationDispatcher) and 5 AppIntents (Activate Workspace, Apply Layout, List Workspaces, Toggle Free Mode, Set Free Mode) withSceneAppShortcutsphrases for Siri. All commands funnel throughCoordinator.handleAutomationCommand, gated by AX permission + Free Mode. URL scheme works on macOS 14+; AppIntents require macOS 14.1+. -
Per-display Workspace layouts.
WorkspacegainsdisplayLayouts: [DisplayLayoutAssignment](displayName → layoutID) +resolvedLayoutID(forDisplay:). On activationWorkspaceActivatoriteratesNSScreen.screensand applies each display's assigned layout via a new screen-targetedCoordinator.applyLayout(id:on:), falling back to the workspace's primarylayoutID. Decoded withdecodeIfPresentso olderworkspaces.jsonmigrates cleanly. -
Fix — drag-to-swap stale snapshot. The window→slot snapshot (
Coordinator.lastWindowToSlotIdx) only refreshed onapplyLayout, so the second consecutive swap read stale positions — the displaced-window lookup missed and two windows ended up overlapping in one slot (also corrupted seam-resize, which shares the map).DragSwapControllernow emits anonSwapcallback and the Coordinator keeps the snapshot authoritative between swaps. Regression testtestConsecutiveSwapsUseUpdatedSlotMapadded (RED-checked). -
Fix — Free Mode toggle-back.
MenuBarContentViewobservedfreeModevia@EnvironmentObject, whichMenuBarExtramenus don't reliably re-subscribe to — so toggling Free Mode off could leave the menu showing stale state. The menu now takes the coordinator as an@ObservedObject(the same fix already used for the menu-bar icon). -
Tests 317 → 357. New SceneCore coverage for
URLRouter/AutomationCommand,DisplayLayoutAssignment, and the two-swap drag regression. Universal (arm64 + x86_64), macOS 14+, Apple notarized.
-
Free Mode toggle. A new
@Published var freeMode: Boollives onCoordinator(SceneApp). When set,Coordinator.applyLayout(_:)andCoordinator.applyWorkspace(id:)early-return at the top — gating both menu clicks and Carbon-hotkey paths from a single check (the hotkey handlers route through these two entry points already, so a single guard covers ⌘⌃1-9,0 and ⌘⌥1-4 simultaneously).TriggerSupervisorgains a parallelvar paused: Bool = false, checked at the head ofhandleDirectActivation(workspaceID:kind:source:)so all three auto-trigger watchers (monitor / time / calendar) short-circuit without updating their cooldown timestamps. Drag-swap and seam-resize gates live inside the AXonMove/onResizeobserver closures inCoordinator.rebuildDragSwapObservers(guard let self, !self.freeMode else { return }) — the observer itself stays installed so toggling Free Mode off resumes instantly with no re-arm. -
Menu UI. New "Free Mode" row in
MenuBarContentViewbetween the Layouts section and the Settings divider. The row carries a leadingImage(systemName: "checkmark")when active and a 14ptSpacerwhen inactive (matches the existing Workspaces section's checkmark slot, so width stays stable). Layout and WorkspaceButtonrows gain.disabled(coordinator.freeMode), dimming both lists while paused. A new privateMenuBarLabelview inSceneAppApp.swift@ObservedObjects the coordinator and switches theMenuBarExtraicon betweenrectangle.3.group(active) andpause.rectangle(paused) — the label closure ofMenuBarExtradoesn't auto-subscribe to environment objects, hence the explicit@ObservedObject. -
State lifecycle.
freeModeis in-memory only — never written tosettings.json, no schema migration. Every Setpiece launch starts withfreeMode = false, regardless of whether the previous session was paused.Coordinator.freeMode.didSetmirrors the value intotriggerSupervisor?.pausedfor the auto-trigger gate; the optional chain handles the brief pre-configure(triggerSupervisor:)window during launch (impossible UX-wise to toggle that early, but defensively correct). -
Localization. New
menu.free_modekey inLocalizable.xcstrings:Free Mode(en),閒置模式(zh-HK 粵語; using 閒置 rather than 自由 since Cantonese 自由 reads as "leisure"),自由模式(zh-TW 書面語). -
No SceneCore changes; tests still 317/317. Pure SceneApp glue. SceneApp has no
swift testcoverage today — manual smoke checklist appended todocs/TESTING.mdis the verification gate (10 steps: cold launch is OFF, hotkey/drag-swap/auto-trigger silent when ON, dimmed rows + ✓ + paused icon, toggle-off recovery, no persistence across relaunch).
-
Diagnostics subsystem. New
Diagnostics/directory in SceneCore:DiagnosticEvent(10 cases — layout fired / instant + animated outcome / 7-step workspace step / trigger fired / suppressed / screen diff / AX permission / app launch timeout / app quit survivors),EnvironmentSnapshotwith integer-fieldScreenRecord(resolution, visible frame, scale100, main flag — deterministic SHA prefix signature),EventLog200-entry ring buffer (@unchecked Sendablelock-protected),DiagnosticWriteractor wrapping a singleAsyncStream+ sole consumer task (race-safedrain(): no detachedTask {}per call site, so disable cannot race a queued write),DiagnosticBudget(newline-aware truncation, oldest-rotated eviction at 2 MB cap, 7-day age cleanup),GzipWriter(Apple Compression framework + manual gzip header/CRC32/ISIZE — round-trip verified via systemgunzip),SaltStoremode 0600 with regeneration on disable→enable for forward secrecy,DiagnosticHashersalted SHA256 prefix=8 → 11-char base64url,Sanitized*DTOs (Layout / Workspace / FocusMode / Trigger / Hotkey) andExportSanitizerpure transformation. SceneApp addsDiagnosticControllercomposition root,EnvironmentCapture(NSScreen→ScreenRecord),ProcessRunnerprotocol, andDiagnosticExporterorchestrating the zip via/usr/bin/ditto. -
Hooks across orchestration.
Coordinator.performApplyLayoutlogslayoutFired(with snapshot) +layoutOutcomeInstant;WorkspaceActivator.activateemits aworkspaceStepper stage (quit / launch / settle / applyLayout / focusOn / setActive / banner) with timing + appCount + survivorCount;TriggerSupervisorlogstriggerFiredandtriggerSuppressed(with reason: cooldown / alreadyActive / inFlight);MonitorTriggerWatcherlogsscreenDiffon everydidChangeScreenParameters(not just connect/disconnect by name);WindowAnimatorinstrumentssetFrameFailuresper run and emitslayoutOutcomeAnimatedon completion — including the interruption case where a previous in-flight run's summary fires BEFORE replacement tracks. All injection via constructor defaultdiagnostics: DiagnosticSink = .noopso test code keeps working. -
Export bundle + GitHub issue handoff. "Export Diagnostics for Bug Report" (About tab) packages
events-*.jsonl(active + gzipped past days), sanitizedlayouts.json/workspaces.json/settings.json, a recent-events tail from the in-memory ring buffer,system-info.txt(Setpiece + macOS version, AX granted, screen count,hashID = SHA256(salt)[0:8]— never the salt itself), and aREADME.txtexplaining the privacy contract. Saves viaNSSavePanel, thenNSWorkspace.activateFileViewerSelectingreveals the zip +NSWorkspace.openfires a pre-filled GitHub issue URL with titleBug report (Setpiece v<version>), environment body, matchinghashID, and labelsbug,diagnostics. -
Persistent Workspaces (V0.7 features shipped together).
WorkspacegainsassignedDesktop: Int?(1-9),pinnedApps: [String], andenforcementMode: WorkspaceEnforcementMode(.off/.arrangeOnly/.hideWhenInactive/.quitWhenInactive). All decoded viadecodeIfPresentso V0.4-styleworkspaces.jsonmigrates cleanly.DesktopSwitcherposts a syntheticCtrl+NCGEventfor Mission Control's "Switch to Desktop N" shortcut.WorkspaceAppPolicyEnforcersubscribes toNSWorkspace.didLaunchApplicationNotification+didActivateApplicationNotification+workspaceStore.onChangeand continuously enforces — disallowed = (other workspaces' pinned − active's pinned − Setpiece's own bundle), then.hide()or.terminate()per mode. Activation flow updated to: quit → switchToDesktop → launch (deduped pinned ∪ appsToLaunch) → settle (skipped when nothing launched) → applyLayout → focusOn → setActive → banner. -
Workspace editor Save UX.
WorkspaceEditorViewcarries asavedBaseline: Workspacesnapshot.isDirty = draft != savedBaselinedrives.disabled(!isDirty)on the Save button; successfulupdatepromotessavedBaseline = draftso Save disables again.justSavedflag flips for 2 seconds after commit, drives a greencheckmark.circle.fill"Saved ✓" Label; if user edits while it's still up, the badge clears (no false sense of saved state). Addsworkspace.editor.saved+workspace.editor.unsavedlocalizations across en, zh-HK (粵語), zh-TW (書面語). -
Settings v2 → v3 migration.
SettingsStoregainsdiagnosticsEnabled: Bool(default true), with the existingdecodeWithMigrationextended to handle v2 input by defaulting the new field. Re-persists on first read.SettingsStoreViewModelexposessetDiagnosticsEnabledplus anonDiagnosticsToggle: ((Bool) async -> Void)?callback that AppDelegate wires intoDiagnosticController.enable()/disable()— the toggle-off path drains the writer + deletes all artifacts + regenerates the salt for next-enable forward secrecy. -
Tests 244 → 317. New
Diagnostics/test directory with 9 files:DiagnosticEventTests(Codable round-trip per case + flat{ts, t, p}JSON shape + per-event byte caps + privacy contract no-plaintext-leak),EnvironmentSnapshotTests(signature determinism + sensitivity to resolution / Sidecar reconnect at different scale / main-display change / active-display change),HashingTests(salt determinism + output charset + length 11),ExportSanitizerTests(PII drop across layouts + workspaces + triggers, within-bundle correlation, cross-salt non-correlation, Codable round-trip on sanitized DTOs),EventLogTests(200-cap FIFO eviction + observer fire + 1000-thread concurrent appends),DiagnosticBudgetTests(filename day parsing, newline-aware truncate, retention by embedded day, total-budget eviction preserving active jsonl, gzip compress + systemgunzipround-trip),DiagnosticWriterTests(race test enqueue 100 → drain → all on disk, day-boundary rotation,deleteAllArtifacts, written lines are validDiagnosticEntryJSON, active-file truncates at cap),GzipWriterTests(round-trip via/usr/bin/gunzip, RFC 1952 CRC32 vector, repetitive-input compression),SaltStoreTests(file mode 0600, regenerate produces different bytes, corrupted file triggers regeneration, idempotent delete).SettingsStoreTestsadds v2 → v3 migration round-trip + persistence test. Total 73 new cases.
-
User-authored custom layouts. A new
LayoutNodeindirect enumin SceneCore (.leaf/.hSplit(ratio, top, bottom)/.vSplit(ratio, left, right)) carries a binary split-tree alongside the fixedLayoutTemplatecases.CustomLayoutgains an optionalcustomTree: LayoutNode?field, decoded viadecodeIfPresentfor forward+backward compatibility with v0.5.6layouts.json.toLayout()prefers the tree when present — slot flattening happens in a pureLayoutNode.flatten() -> [Slot]that recursively carves unit rects, soLayoutEngine.planneeds no changes.SceneApp/SceneApp/Settings/CustomLayoutCanvasEditor.swiftis a new recursive SwiftUI view — leaf taps open a Menu (split horizontally / split vertically / delete this slot), and seams are draggable rectangles whose.onChangedhandler updates the ratio Binding in place.LayoutsTabgains a "+ Custom" toolbar button that inserts aCustomLayoutwithcustomTree = .leaf;LayoutDraftEditorbranches to the canvas editor when the tree is non-nil. -
Companion resize on drag (seam reflow).
kAXResizedNotificationis now observed alongsidekAXMovedNotificationinAXMoveObserverGroup. A newSeamResizeController(mirroringDragSwapController's injected-probe pattern) reads the AX-reported new frame, runsLayoutReflow.reflow(template:proportions:slotIdx:newWindowFrame:visibleFrame:)— a pure function supporting.twoCol/.twoRow/.threeCol/.threeRowwith middle-slot edge disambiguation — and applies the re-flowed rect to the non-initiating neighbour viasetFrame. Self-fire guard uses a mouse-button probe + anactiveInitiatingIDlatch so Setpiece's ownsetFrameechoes never re-enter. Gated on the existingDragSwapConfig.enabledtoggle; custom-tree layouts don't reflow yet (follow-up for v0.6). -
Dirty-state Save button + save-persist bug fix.
LayoutDraftEditor(introduced in this release as a local@State draftwrapper forLayoutEditorView+CustomLayoutCanvasEditor) carries alastSavedsnapshot.canSave = draft != lastSavedis threaded down to both sub-editors and binds to.disabled(!canSave)on the Save button; successfulstore.updateupdateslastSavedso Save darkens immediately. Cancel reverts tolastSaved(not the initial load), so save → edit → cancel returns to the last saved state. Separately, the editor now uses a proper@State draftwith explicit commit — fixes the earlier silent no-op where.onChange(of: draft.template)would fire on sidebar-swap-induced binding changes and write template-default proportions back through the auto-binding, wiping user customisations on return. -
Multi-display AX ↔ NS coordinate fix. A new
Sources/SceneCore/Display/DisplayCoordinates.swiftcentralizes AX (top-left global) ↔ NSScreen (bottom-left per-display) conversion.AXWindow.framenow exposes NS-flipped frames to callers;AXMoveObserverGroup's observer callback applies the flip exactly once before invokingonMove/onResize;AXWindowEnumerator's center-in-visibleFrame check routes through the shared helper. All downstream code (drag-swap, seam resize, plan/apply) operates on a single coordinate system. Secondary-display-below / -left setups now produce correct seam targets instead of landing windows off-screen. -
Tests 177 → 244.
LayoutReflowTests(20) covers all four supported templates, middle-slot disambiguation, clamp-to-[0.1, 0.9], offsetvisibleFrame(multi-display), unsupported templates returning nil, and degenerate inputs.SeamResizeControllerTests(12) covers core reflow, self-fire guards (mouse / option / disabled / echoed event), nil/unknown-window short-circuits, 3-way reflow smoke, and mouse-up state reset.LayoutNodeTests(17) covers slot-count, flatten across splits, a verbatim check of the canonical 5-slot example, 100%-coverage invariant across multiple trees, clamp for negative / >1 / NaN ratios, and Codable round-trip including a stable on-disk JSON shape assertion.CustomLayoutTestsgains 5 new cases (customTree override, template fallback, round-trip, legacy JSON without the key, no-customTree encoding).DisplayCoordinatesTestscovers the flip helpers.
-
In-app update installer. The "Update available" menu item now opens a confirmation alert with three actions: Install and Restart (default), Release Notes (opens the GitHub release page in the browser, the pre-V0.5.6 behavior), and Later (dismiss). On Install,
UpdateInstallerdownloads the new DMG to~/Library/Caches/Scene/, verifies the signature withcodesign -dvand confirmsTeamIdentifier=22K6G3HH9G, writes a detached helper bash script to/tmp/, launches it vianohup, and quits Setpiece. The helper waits for Setpiece's PID to exit, mounts the DMG, replaces the installed bundle (taken fromBundle.main.bundleURLso non-/Applicationsinstalls are also handled) viaditto --noqtn, strips the destinationcom.apple.quarantinexattr, refreshes LaunchServices, unmounts, deletes the cached DMG, andopens the new Setpiece. -
Accessibility permission survives across updates. TCC binds the grant to the binary's Designated Requirement (
anchor apple generic and identifier "com.hillman.SceneApp" and certificate ... and certificate leaf[subject.OU] = "22K6G3HH9G"), which is stable across any release signed with the same Developer ID.dittopreserves thecom.apple.maclxattr — TCC's per-file pointer that anchors the grant to the bundle on disk. The new install launches withAXIsProcessTrustedalready true: no re-grant, notccutil reset, no toggling System Settings. -
Rollback on install failure. Helper backs up the old app to
/tmp/Scene.app.bak-$$beforeditto. Ifdittofails, the backup is restored and the DMG is unmounted. Logs go to/tmp/scene-update-<pid>.logfor post-mortems. - Bootstrap caveat. V0.5.5 ships the old release-page-opening menu item, so the V0.5.5 → V0.5.6 hop still needs a manual install. Subsequent V0.5.6 → V0.5.7+ updates run through the in-app installer.
-
No SceneCore changes; tests still 177/177. New file
SceneApp/SceneApp/UpdateInstaller.swift(~150 LOC) plus a few lines inUpdateCheckerto surface the DMG asset URL from the GitHub releaseassets[].
-
Quit-and-relaunch refreshes the update check —
UpdateChecker.startPeriodicChecks()now bypasses the 24-hourlastCheckedAtdebounce on launch via the newforceCheck()helper. The previous behavior cached the check across relaunches, which silently swallowed update notifications when a new release was published inside the same 24-hour window as a prior cached check (the exact case where a user who had Setpiece running before the new release would never see it until tomorrow). The hourly background timer and the wake-from-sleep trigger keep the 24-hour debounce — those fire automatically without explicit user intent, and the GitHub rate-limit guard belongs there, not on user-driven relaunches. -
Delete button in Workspaces tab toolbar —
WorkspacesTabnow has a trash button next to "+ New" and "Duplicate", matchingLayoutsTab. The previousList.onDeleteswipe affordance is preserved as a secondary entry point but is barely discoverable on macOS NavigationSplitView — multiple users believed the four seeded workspaces (Coding / Meeting / Reading / Streaming) couldn't be removed. - No SceneCore changes; tests still 177/177 — pure SceneApp UI / lifecycle fix.
-
Welcome window decoupled from Accessibility — the one-time welcome no longer waits for
AXIsProcessTrustedto return true before showing. Anyone stuck on the v0.4.x → v0.5.x cdhash mismatch (notarized signature invalidates the ad-hoc TCC grant —AXIsProcessTrustedkeeps returning false even with the toggle ON) used to never see the welcome at all. New flow: welcome shows on first launch regardless of permission state, thenFirstLaunchWindowController.afterDismisschains into the AX onboarding window if permission is still missing. -
AX recovery hint shown upfront —
OnboardingViewno longer gates thetccutil reset Accessibility com.hillman.SceneAppblock (with its Copy button + paste-into-Terminal instructions) behind a failed "Check again" click. Most users grant in System Settings and let the 2-second polling timer pick it up — they never click Check again, so they never discovered the rescue path. Always-visible now. -
Returning users without AX get the prompt automatically —
AppDelegate.applicationDidFinishLaunchingnow callsopenOnboardingIfMissingAX()when the welcome flag is already set, so users with broken TCC don't have to discover the hidden "Grant Accessibility" item in the menu bar to recover. - No SceneCore changes; tests still 177/177 — pure SceneApp UI / lifecycle fix.
-
60Hz animation for native apps —
WindowAnimatornow picks its AX-write throttle per-animation: 30Hz when any window belongs to a known Electron bundle ID (VS Code, Cursor, Chrome, Brave, Slack, Discord, Figma, Notion, Obsidian, Teams), 60Hz when all windows are native. Native-app animations get roughly twice the frame count inside the same animation window, which reads as noticeably smoother on ProMotion displays. -
Distance-proportional duration — the configured
durationMsis now a reference for a ~600pt diagonal move. Short moves contract toward ~70% of base, long moves expand toward ~140%, clamped both ways.AnimationConfig's own [100, 500]ms range is the final safety net. - Tighter write de-dup — per-window AX write dedup tolerance dropped from 0.5pt → 0.2pt, reclaiming a few frames at the end of easeOut curves where tiny deltas were previously suppressed.
-
Universal binary (Intel support) —
scripts/build-dmg.shnow produces arm64 + x86_64 slices. One DMG for all Macs; no Rosetta on Apple Silicon. macOS Tahoe (26) is the last Intel-supporting macOS release, so this is a last-call window for Intel reach. -
Deployment target —
MACOSX_DEPLOYMENT_TARGETrestored to 14.0 (Xcode had silently bumped it to 26.4 matching the build machine's OS). -
Xcode 26 workaround —
build-dmg.shforcesSWIFT_OPTIMIZATION_LEVEL=-Ononebecause Xcode 26's Release optimizer crashes in the SIL pipeline at pre-26 deployment targets. Negligible impact on a menu bar app; will be removed once Apple ships a fixed toolchain. -
177 SceneCore unit tests unchanged — all three smoothness tweaks live in
WindowAnimator(the SceneApp-side bridge).
-
First-launch welcome window — new users see a short welcome on first launch after Accessibility is granted, with a SwiftUI-drawn menu bar mock and an "Open Settings" shortcut. Gated by
UserDefaults.hasShownFirstLaunchWelcomeV1, set at show-time so⌘Qmid-welcome doesn't cause a re-show. Re-openable from Settings → About → "Show welcome screen again". -
Dynamic version string — About tab now reads
CFBundleShortVersionStringdirectly from the bundle instead of a stale localized constant, so future version bumps no longer require a String Catalog edit.
-
Continuous update detection —
UpdateCheckernow polls GitHub every hour (still rate-limited to one network call per 24h per device) and on wake-from-sleep, so long-running Macs see new releases within an hour instead of only at next launch.
- Developer ID signing + Apple notarization — Setpiece is now Apple-notarized. Gatekeeper accepts the install without the "cannot be verified" prompt. Homebrew cask's quarantine strip still runs but is now redundant.
-
TCC Accessibility grant survives updates — notarization stabilizes the signing identity across rebuilds, so the TCC
cdhashbinding no longer invalidates on every new DMG. Users stay granted across V0.5.x → V0.5.y upgrades.
-
Passive update nudge — new
UpdateCheckerpolls GitHub'sreleases/latestendpoint at most once per 24h (per-device debounce inUserDefaults) and shows a tinted "Update available: v%@ — click to download" item at the top of the granted menu when the remote tag is newer thanCFBundleShortVersionString. The item opens the release page; the user still installs the DMG manually. -
Deliberately NOT Sparkle. Setpiece is ad-hoc signed, so every new build has a different
cdhash. A silent auto-install would invalidate the Accessibility grant on every update, breaking the core feature. Keeping the DMG path intact also preserves the Homebrew tap's postflight (quarantine strip). -
isVersionTag(_:newerThan:)moved into SceneCore as a public free function (Sources/SceneCore/Settings/VersionCompare.swift).UpdateCheckerkeeps only I/O and state; the semver math is testable without Bundle / URLSession / Xcode. -
+19 unit tests covering patch / minor / major bumps,
vprefix on either side, zero-padding equivalence (1.2==1.2.0), trailing zeros, pre-release suffix fail-safe, empty and garbage input — all malformed cases returnfalseso no spurious nudge. -
Icon re-compression — the 9 AppIcon PNGs were reintroduced uncompressed in the working tree; re-ran
pngquantto restore the v0.4.1 size win (~672 KB → 156 KB total). - Total SceneCore tests: 158 → 177.
-
Animation lag on ProMotion (120Hz) displays —
WindowAnimatornow throttles AX writes to 30Hz and dedupes per-window writes within 0.5pt. A 250ms swap between two Electron-family apps (Cursor, Chrome, VS Code) no longer stalls to a second-plus of back-pressured IPC. -
Workspace "preselected on launch" —
activeWorkspaceIDis now session-only state. Removed from theWorkspaceStoredisk model so a prior session's activation no longer presents a checkmark against a workspace the user did not pick. Legacy on-diskactiveWorkspaceIDkeys are ignored by the defaultJSONDecoderunknown-key policy and stripped on the next write. -
Instant workspace click —
AppLauncher.quitreturns immediately on an emptybundleIDslist instead of waiting out the 5s gentle-quit grace;WorkspaceActivatorskips the 1.5s settle whenappsToLaunchis empty. Default seeded workspaces fire within ~50ms instead of ~6.5s. -
Layout re-fire catches newly-opened windows —
Coordinator.applyLayoutrecords the last applied layout ID and timestamp. A re-fire of the same layout within 5s defers enumeration by 200ms, givingCGWindowListCopyWindowInfotime to register a window opened right before the hotkey. First fire of any layout remains instant. -
158 SceneCore unit tests —
testSetActivePersistsrenamed totestActiveWorkspaceIsSessionOnlywith inverted assertion; otherwise no count change.
-
DMG size 2.8MB → 1.4MB —
-OsizeSwift flag, LZFSE DMG compression, andpngquantapp icon pass. Styled installer window with custom background, directional arrow, pinned icon positions, hidden toolbar/sidebar. -
Workspace activation reliability —
WorkspaceActivatorno longer posts a false-success banner whenapplyLayoutfails;TriggerSupervisorsingle-flight guard prevents concurrent manual + auto activations from racing; empty Calendar keyword no longer matches every event. -
Re-install onboarding copy — when a rebuild invalidates the TCC cdhash binding, the onboarding window surfaces the exact
tccutil reset Accessibility com.hillman.SceneAppcommand with a one-click Copy button, replacing the less reliable "toggle OFF/ON" advice. -
WorkspaceStore.insertduplicate-ID guard — throws.duplicateID(id:)instead of silently overwriting.
- Workspaces (headline) — bundle a layout + apps to launch + apps to quit + Focus mode + auto-triggers. 4 seeds (Coding / Meeting / Reading / Streaming) bound to ⌘⌥1–4. One click activates the whole context.
-
5 trigger types —
.manual(hotkey / menu),.monitorConnect(displayName),.monitorDisconnect(displayName),.timeOfDay(weekdayMask, hour, minute),.calendarEvent(keywordContains). First-match-wins ordering, 30s cooldown per Workspace for auto-triggers (manual bypasses). - 7-step activation — previous Focus Off → quit (gentle + 5s grace + notify-on-fail; no force-terminate) → launch parallel → settle 1.5s → applyLayout → Focus On → setActive + banner.
- Layout thumbnails — every layout reference in the menu bar and Settings renders a live preview of its actual slot proportions. Reused at 24×16 (menu), 32×22 (Layouts tab), 40×26 (Workspace editor).
-
3 vertical preset seeds — Main + Side Vertical (⌘⌃8), Halves Vertical (⌘⌃9), Thirds Vertical (⌘⌃0). Migrated into existing V0.2 installs via the
applyFutureSeedsmechanism (its first real use). - Default hotkey reshuffle — all 10 layout seeds moved from ⌘⇧1-7 to ⌘⌃1-9,0 to dodge macOS ⌘⇧3/4/5 screenshot shortcuts. User-set hotkeys are preserved on upgrade.
-
Multilingual UI —
Localizable.xcstringswith 133 keys. Full English + 繁體中文(港) at ship; partial 繁體中文(台) + 日本語 (fall back to en on missing keys). -
Cross-store hotkey conflict — Workspaces and Layouts share a single chord namespace; two-phase
setHotkeyConflictProbesetter on both stores surfaces the conflicting resource's name on block-save. - 41 new SceneCore unit tests (total 157, was 116 at V0.3) — Workspace / WorkspaceTrigger / FocusModeReference Codable, WorkspaceSeeds, WorkspaceStore CRUD + conflict + seed migration, WeekdayMask OptionSet.
- Drag any placed window onto another to swap them. Source snaps; the displaced window animates via the V0.2 engine (250ms easeOut, respects the Animation tab toggle).
- Interaction tab — enable / disable drag-to-swap; tune drag-distance threshold (10–100pt).
- Escape hatches — hold ⌥ while dragging to free-move without swap; press Esc mid-drag to cancel and snap back to origin.
- App-side
AXObserverbridge (AXMoveObserverGroup,AXWindowLookup,DragSwapAnimationSink) wires the SceneCoreDragSwapController(pre-existing since V0.1) to real windows. - 24 new SceneCore tests (92 → 116).
-
11 grid templates —
single,2-col/3-col,2-row/3-row,2×2/3×2grid, plus 4 L-shape variants. Each editable via proportion sliders. -
Custom layouts — save / rename / delete; persisted as JSON in
~/Library/Application Support/Scene/layouts.json. The 7 V0.1 presets become editable seeds with stable UUIDs. - Per-layout custom hotkeys with block-save conflict policy (single owner per chord).
- Smooth animation — position+size simultaneous interpolation. User-tunable: enable toggle, duration 100-500ms, easing (Linear / Ease Out / Spring). Falls back to instant when 7+ windows are visible.
- Settings window — SwiftUI sidebar nav. V0.4 tab order: Workspaces / Layouts / Hotkeys / Interaction / About.
-
AX permission fix —
forceRecheck()viaAXIsProcessTrustedWithOptions(prompt: false)for the running-process trust cache trap. - 66 new SceneCore unit tests (26 → 92).
-
Per-display independent layouts — partially shipped in v0.7.0 and v0.8.2: Workspaces can assign a layout per display (
Workspace.displayLayouts), applied on activation, and since v0.8.2 drag-to-swap and seam resize work on every display at once (PlacementRegistry). Still open: standalone (non-Workspace) per-display layouts, and dropping a window on another display's slot to join that display's layout (today it is simply released there). - Launch at Login — currently no UI; users can add to Login Items manually. A toggle in the About or Workspaces tab would close the loop.
-
Pattern learning — observe manual window arrangements (user-driven
setFrameover time) and surface suggestions: "I notice you usually split 70/30 Cursor + Chrome in the afternoon — save as a preset?" Local-first; optional LLM pass with BYOK API key. -
AI / natural-language layout input — new hotkey (proposed ⌘⇧Space) opens a small input. User types "cursor left, chrome right" or the Cantonese equivalent; LLM parses → layout JSON → applied via
Coordinator.applyLayout. - Per-app rules — "always put Slack in slot 4 regardless of which preset fired".
-
Aggressive quit toggle — opt-in, per-Workspace flag to
forceTerminateunclosed apps after the 5s grace window. Off by default to preserve V0.4's "never destroy unsaved work" policy. - In-app language switcher — V0.4 uses system locale only. Letting users pick a language without changing macOS locale would be a small but useful addition.
- Free-form canvas-drag layout editor — the EpycZones approach. V0.2 picked constrained "grid template + sliders" because it produces cleaner animation paths and a smaller Settings UI surface. Canvas drag could ship alongside without replacing it.
- Stage Manager awareness — detect and respect Stage Manager groups.
- Additional Workspace triggers — location / network SSID / audio device / Bluetooth. Each adds a permission prompt and a watcher, so they're gated on demand.
- Homebrew Cask submission (depends on notarization).
- Cross-Mac iCloud sync of Workspaces + Layouts.
- Workspace marketplace / community sharing.
- Touch Bar / external display widgets for quick Workspace activation.
- Mac App Store distribution. Requires App Sandbox, which forbids controlling other apps' windows. Same reason Rectangle, Magnet, EpycZones, etc. distribute outside the Store.
- iOS / iPadOS port. No window management APIs apply.
- Cross-platform port. The entire codebase relies on macOS-specific APIs.
- Full automation / Shortcuts replacement. Workspaces have a focused trigger set (5 types). Richer automation belongs in Shortcuts.app.
English
繁體中文(香港)