Skip to content

Roadmap

ChiFungHillmanChan edited this page Oct 13, 2026 · 23 revisions

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.

Shipped in v0.8.2

  • 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.setObserved diffs the observed set across displays instead of rebuilding on every apply. DragSwapController treats 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, not CGRect.contains, for "pointer on this display". On a display's top row NSEvent.mouseLocation.y == frame.maxY, which contains excludes, 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 / AXDrawer never 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 of TilingFrame.stickyCandidates (current visibleFrame, 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.

Shipped in v0.8.1

  • 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 NavigationSplitView nested inside another. WorkspacesTab was a NavigationSplitView in the detail column of SettingsRoot'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 (an HSplitView in 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: WorkspacesTab is an HSplitView, like LayoutsTab. 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 NavigationSplitView inside SettingsRoot's detail column. Use HSplitView.
  • Tests: 438, unchanged. Verified only on macOS 27.0.1.

Shipped in v0.8.0

  • 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 while AXIsProcessTrusted returns false, they would experience it as "the app stopped working" rather than "I need to re-grant". Application Support stays Scene. What users see is CFBundleDisplayName.
  • 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. UpdateInstaller now resolves whichever .app the 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.acceptedSchemes holds 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 -O without the SIL optimizer ICE that forced SWIFT_OPTIMIZATION_LEVEL=-Onone under 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, with cask_renames.json mapping the old one so brew upgrade keeps resolving. Tested: brew info --cask chifunghillmanchan/tap/scene resolves to chifunghillmanchan/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.

Renamed: Scene is now Setpiece

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.

Shipped in v0.7.6

  • Dialogs, sheets, inspectors and floating palettes are no longer tiled. AXWindowEnumerator accepted every layer-0 window whose AX frame matched its CGWindowList bounds — 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 into toMinimize: a three-way split could be scrambled by ⌘I in Finder. New WindowSubrole (SceneCore/AX) reads kAXSubroleAttribute and rejects AXDialog, AXSystemDialog, AXFloatingWindow, AXSystemFloatingWindow, AXSheet and AXDrawer; 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 AXStandardWindow would 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. AXUnknown is 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, the AXDialog and AXFloatingWindow ones are now skipped and the document windows still tile.
  • SettingsStore.dockReserveAllDisplays makes 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 raw visibleFrame: 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.init turns any store-init throw into fatalError, and decodeWithMigration threw 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 upgraded settings.json, and the v0.7.5-era code then died at startup with Unsupported settings schema version 4. A version above currentVersion is now decoded leniently through a LenientFile shape (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. decodeWithMigration also returns a struct instead of a 4-tuple — a fifth positional Bool was 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 to UserDefaults rather than settings.json, because it ticks on a hotkey-rate path while SettingsStore.persist() does an atomic whole-file write — the same reasoning as hasShownFirstLaunchWelcomeV1. 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 of performApplyLayout count: 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 API UpdateChecker polls (301 → 200), release-asset downloads the Homebrew cask resolves (200), and the web URL (301).
  • Tests 394 → 433. Twelve WindowSubroleTests pin the deny-list contract including the missing / empty / unrecognized guarantees; ten DockReserveTests pin the reserve on both displays, the default, and single-display identity; four SettingsForwardCompatibilityTests pin the future-file path; thirteen StarPromptStateTests pin threshold, permanence and decode robustness. Universal (arm64 + x86_64), macOS 14+, Apple notarized.

Shipped in v0.7.5

  • 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 NSVisualEffectView container view controller the window's contentViewController and nested the SwiftUI NSHostingController inside it as a child, constrained to the container's edges. NSHostingController only publishes its SwiftUI .toolbar content into view.window.toolbar when 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 had window.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 their window.toolbar inspected after layout. Only the two wrapped cases produced nil; 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 NSHostingController is the window's root contentViewController again, and the .underWindowBackground / .behindWindow material moves into SwiftUI as a background layer via a small private NSViewRepresentable, applied through a WindowBackdrop view 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 else branch. 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 reports Add, square.on.square.squareshape.controlhandles, Bin, arrow.counterclockwise, Restore Default Presets and Workspaces reports New, Duplicate, Bin. Universal (arm64 + x86_64), macOS 14+, Apple notarized.

Shipped in v0.7.4

  • Dock-stable tiling frame. NSScreen.visibleFrame reserves 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. New TilingFrame (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 to visibleFrame, and the maximum over a single screen is that screen's own visibleFrame — 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. DragSwapController and SeamResizeController are constructed with a visibleFrameOverride pointing at TilingFrame (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 off visibleFrame: 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. frame is the correct question for "which display does this window belong to"; visibleFrame never was.
  • Active-layout tick. Coordinator.activeLayoutID is @Published and set on the success path of performApplyLayout only, so a fire that finds no windows or loses AX mid-call never ticks a row the screen does not reflect. MenuBarContentView.layoutRow renders 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 by created_at — where created_at is 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. UpdateChecker now lists releases?per_page=100, drops drafts and prereleases, and selects via indexOfHighestVersion — numeric per-component comparison, so v0.10.0 correctly beats v0.9.0 where 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 TilingFrameTests cases 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). Eleven HighestVersionSelectionTests cases pin the release selector, including the out-of-order created_at case and the v0.9.0 vs v0.10.0 trap. Universal (arm64 + x86_64), macOS 14+, Apple notarized.

Shipped in v0.7.3

  • Sticky slot re-apply. LayoutEngine.plan mapped 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 existing rectsApproxEqual) 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 to layout.slots. Placement now carries its slot index explicitly and Coordinator.rebuildDragSwapObservers builds lastWindowToSlotIdx from it instead of the enumerated array position — the map that drag-swap and seam-resize both read. Sticky windows deliberately stay in placements (targeting their current frame) rather than being omitted, so they keep their drag-swap observers.
  • Window-style menu panel. MenuBarExtra moves from .menu to .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. MenuBarContentView is now a 280 pt panel of custom MenuRowButtonStyle rows (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 hosting NSWindow (PanelWindowAccessor) lets Settings, the onboarding path, the update alert, and Escape dismiss it explicitly.
  • Tests 363 → 371. Eight cases in LayoutEngineStickyPlanTests pin 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.

Shipped in v0.7.2

  • Liquid Glass Settings window (macOS 26 Tahoe). SettingsWindowController gains an availability-gated chrome block — .fullSizeContentView, transparent titlebar, hidden title — and the hosting controller is embedded in an NSVisualEffectView container (.behindWindow, .underWindowBackground): SwiftUI's containerBackground(for: .window) does not bridge into a manually hosted NSWindow. 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 DetailTabChrome modifier in SettingsRoot gives 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.

Shipped in v0.7.1

  • 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, but Slot.absoluteRect(in:) mapped unit y straight into the bottom-left-origin NS visibleFrame — 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 (mirroring AXWindow.setFrame's single NS→AX flip), and LayoutReflow — the NS→unit inverse used by seam drags — flips yFrac and swaps the row templates' seam-edge mapping to match.
  • Fix — seam-resize vs tree layouts. Canvas-built custom layouts carry stale template / slotProportions fields (leftovers from whatever they were duplicated from); SeamResizeController's context fed those into LayoutReflow, 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 returns nil for tree-based layouts (free resize; reflow stays template-only).
  • Tests 357 → 363. Six new cases pin the orientation contract: Slot.absoluteRect top/bottom anchoring, the exact reported 5-slot regression (2-top/3-bottom materializes top row above bottom row), and rewritten LayoutReflow row semantics (slot 0 = top band; its seam is the window's NS minY edge). 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.

Shipped in v0.7.0

  • Automation surface. New scene:// URL scheme (URLRouter + AutomationCommand in SceneCore, wired through AppDelegate + AutomationDispatcher) and 5 AppIntents (Activate Workspace, Apply Layout, List Workspaces, Toggle Free Mode, Set Free Mode) with SceneAppShortcuts phrases for Siri. All commands funnel through Coordinator.handleAutomationCommand, gated by AX permission + Free Mode. URL scheme works on macOS 14+; AppIntents require macOS 14.1+.
  • Per-display Workspace layouts. Workspace gains displayLayouts: [DisplayLayoutAssignment] (displayName → layoutID) + resolvedLayoutID(forDisplay:). On activation WorkspaceActivator iterates NSScreen.screens and applies each display's assigned layout via a new screen-targeted Coordinator.applyLayout(id:on:), falling back to the workspace's primary layoutID. Decoded with decodeIfPresent so older workspaces.json migrates cleanly.
  • Fix — drag-to-swap stale snapshot. The window→slot snapshot (Coordinator.lastWindowToSlotIdx) only refreshed on applyLayout, 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). DragSwapController now emits an onSwap callback and the Coordinator keeps the snapshot authoritative between swaps. Regression test testConsecutiveSwapsUseUpdatedSlotMap added (RED-checked).
  • Fix — Free Mode toggle-back. MenuBarContentView observed freeMode via @EnvironmentObject, which MenuBarExtra menus 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.

Shipped in v0.6.1

  • Free Mode toggle. A new @Published var freeMode: Bool lives on Coordinator (SceneApp). When set, Coordinator.applyLayout(_:) and Coordinator.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). TriggerSupervisor gains a parallel var paused: Bool = false, checked at the head of handleDirectActivation(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 AX onMove / onResize observer closures in Coordinator.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 MenuBarContentView between the Layouts section and the Settings divider. The row carries a leading Image(systemName: "checkmark") when active and a 14pt Spacer when inactive (matches the existing Workspaces section's checkmark slot, so width stays stable). Layout and Workspace Button rows gain .disabled(coordinator.freeMode), dimming both lists while paused. A new private MenuBarLabel view in SceneAppApp.swift @ObservedObjects the coordinator and switches the MenuBarExtra icon between rectangle.3.group (active) and pause.rectangle (paused) — the label closure of MenuBarExtra doesn't auto-subscribe to environment objects, hence the explicit @ObservedObject.
  • State lifecycle. freeMode is in-memory only — never written to settings.json, no schema migration. Every Setpiece launch starts with freeMode = false, regardless of whether the previous session was paused. Coordinator.freeMode.didSet mirrors the value into triggerSupervisor?.paused for 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_mode key in Localizable.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 test coverage today — manual smoke checklist appended to docs/TESTING.md is 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).

Shipped in v0.6.0

  • 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), EnvironmentSnapshot with integer-field ScreenRecord (resolution, visible frame, scale100, main flag — deterministic SHA prefix signature), EventLog 200-entry ring buffer (@unchecked Sendable lock-protected), DiagnosticWriter actor wrapping a single AsyncStream + sole consumer task (race-safe drain(): no detached Task {} 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 system gunzip), SaltStore mode 0600 with regeneration on disable→enable for forward secrecy, DiagnosticHasher salted SHA256 prefix=8 → 11-char base64url, Sanitized* DTOs (Layout / Workspace / FocusMode / Trigger / Hotkey) and ExportSanitizer pure transformation. SceneApp adds DiagnosticController composition root, EnvironmentCapture (NSScreen → ScreenRecord), ProcessRunner protocol, and DiagnosticExporter orchestrating the zip via /usr/bin/ditto.
  • Hooks across orchestration. Coordinator.performApplyLayout logs layoutFired (with snapshot) + layoutOutcomeInstant; WorkspaceActivator.activate emits a workspaceStep per stage (quit / launch / settle / applyLayout / focusOn / setActive / banner) with timing + appCount + survivorCount; TriggerSupervisor logs triggerFired and triggerSuppressed (with reason: cooldown / alreadyActive / inFlight); MonitorTriggerWatcher logs screenDiff on every didChangeScreenParameters (not just connect/disconnect by name); WindowAnimator instruments setFrameFailures per run and emits layoutOutcomeAnimated on completion — including the interruption case where a previous in-flight run's summary fires BEFORE replacement tracks. All injection via constructor default diagnostics: DiagnosticSink = .noop so test code keeps working.
  • Export bundle + GitHub issue handoff. "Export Diagnostics for Bug Report" (About tab) packages events-*.jsonl (active + gzipped past days), sanitized layouts.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 a README.txt explaining the privacy contract. Saves via NSSavePanel, then NSWorkspace.activateFileViewerSelecting reveals the zip + NSWorkspace.open fires a pre-filled GitHub issue URL with title Bug report (Setpiece v<version>), environment body, matching hashID, and labels bug,diagnostics.
  • Persistent Workspaces (V0.7 features shipped together). Workspace gains assignedDesktop: Int? (1-9), pinnedApps: [String], and enforcementMode: WorkspaceEnforcementMode (.off / .arrangeOnly / .hideWhenInactive / .quitWhenInactive). All decoded via decodeIfPresent so V0.4-style workspaces.json migrates cleanly. DesktopSwitcher posts a synthetic Ctrl+N CGEvent for Mission Control's "Switch to Desktop N" shortcut. WorkspaceAppPolicyEnforcer subscribes to NSWorkspace.didLaunchApplicationNotification + didActivateApplicationNotification + workspaceStore.onChange and 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. WorkspaceEditorView carries a savedBaseline: Workspace snapshot. isDirty = draft != savedBaseline drives .disabled(!isDirty) on the Save button; successful update promotes savedBaseline = draft so Save disables again. justSaved flag flips for 2 seconds after commit, drives a green checkmark.circle.fill "Saved ✓" Label; if user edits while it's still up, the badge clears (no false sense of saved state). Adds workspace.editor.saved + workspace.editor.unsaved localizations across en, zh-HK (粵語), zh-TW (書面語).
  • Settings v2 → v3 migration. SettingsStore gains diagnosticsEnabled: Bool (default true), with the existing decodeWithMigration extended to handle v2 input by defaulting the new field. Re-persists on first read. SettingsStoreViewModel exposes setDiagnosticsEnabled plus an onDiagnosticsToggle: ((Bool) async -> Void)? callback that AppDelegate wires into DiagnosticController.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 + system gunzip round-trip), DiagnosticWriterTests (race test enqueue 100 → drain → all on disk, day-boundary rotation, deleteAllArtifacts, written lines are valid DiagnosticEntry JSON, 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). SettingsStoreTests adds v2 → v3 migration round-trip + persistence test. Total 73 new cases.

Shipped in v0.5.7

  • User-authored custom layouts. A new LayoutNode indirect enum in SceneCore (.leaf / .hSplit(ratio, top, bottom) / .vSplit(ratio, left, right)) carries a binary split-tree alongside the fixed LayoutTemplate cases. CustomLayout gains an optional customTree: LayoutNode? field, decoded via decodeIfPresent for forward+backward compatibility with v0.5.6 layouts.json. toLayout() prefers the tree when present — slot flattening happens in a pure LayoutNode.flatten() -> [Slot] that recursively carves unit rects, so LayoutEngine.plan needs no changes. SceneApp/SceneApp/Settings/CustomLayoutCanvasEditor.swift is a new recursive SwiftUI view — leaf taps open a Menu (split horizontally / split vertically / delete this slot), and seams are draggable rectangles whose .onChanged handler updates the ratio Binding in place. LayoutsTab gains a "+ Custom" toolbar button that inserts a CustomLayout with customTree = .leaf; LayoutDraftEditor branches to the canvas editor when the tree is non-nil.
  • Companion resize on drag (seam reflow). kAXResizedNotification is now observed alongside kAXMovedNotification in AXMoveObserverGroup. A new SeamResizeController (mirroring DragSwapController's injected-probe pattern) reads the AX-reported new frame, runs LayoutReflow.reflow(template:proportions:slotIdx:newWindowFrame:visibleFrame:) — a pure function supporting .twoCol / .twoRow / .threeCol / .threeRow with middle-slot edge disambiguation — and applies the re-flowed rect to the non-initiating neighbour via setFrame. Self-fire guard uses a mouse-button probe + an activeInitiatingID latch so Setpiece's own setFrame echoes never re-enter. Gated on the existing DragSwapConfig.enabled toggle; 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 draft wrapper for LayoutEditorView + CustomLayoutCanvasEditor) carries a lastSaved snapshot. canSave = draft != lastSaved is threaded down to both sub-editors and binds to .disabled(!canSave) on the Save button; successful store.update updates lastSaved so Save darkens immediately. Cancel reverts to lastSaved (not the initial load), so save → edit → cancel returns to the last saved state. Separately, the editor now uses a proper @State draft with 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.swift centralizes AX (top-left global) ↔ NSScreen (bottom-left per-display) conversion. AXWindow.frame now exposes NS-flipped frames to callers; AXMoveObserverGroup's observer callback applies the flip exactly once before invoking onMove / 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], offset visibleFrame (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. CustomLayoutTests gains 5 new cases (customTree override, template fallback, round-trip, legacy JSON without the key, no-customTree encoding). DisplayCoordinatesTests covers the flip helpers.

Shipped in v0.5.6

  • 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, UpdateInstaller downloads the new DMG to ~/Library/Caches/Scene/, verifies the signature with codesign -dv and confirms TeamIdentifier=22K6G3HH9G, writes a detached helper bash script to /tmp/, launches it via nohup, and quits Setpiece. The helper waits for Setpiece's PID to exit, mounts the DMG, replaces the installed bundle (taken from Bundle.main.bundleURL so non-/Applications installs are also handled) via ditto --noqtn, strips the destination com.apple.quarantine xattr, refreshes LaunchServices, unmounts, deletes the cached DMG, and opens 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. ditto preserves the com.apple.macl xattr — TCC's per-file pointer that anchors the grant to the bundle on disk. The new install launches with AXIsProcessTrusted already true: no re-grant, no tccutil reset, no toggling System Settings.
  • Rollback on install failure. Helper backs up the old app to /tmp/Scene.app.bak-$$ before ditto. If ditto fails, the backup is restored and the DMG is unmounted. Logs go to /tmp/scene-update-<pid>.log for 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 in UpdateChecker to surface the DMG asset URL from the GitHub release assets[].

Shipped in v0.5.5

  • Quit-and-relaunch refreshes the update check — UpdateChecker.startPeriodicChecks() now bypasses the 24-hour lastCheckedAt debounce on launch via the new forceCheck() 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 — WorkspacesTab now has a trash button next to "+ New" and "Duplicate", matching LayoutsTab. The previous List.onDelete swipe 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.

Shipped in v0.5.4

  • Welcome window decoupled from Accessibility — the one-time welcome no longer waits for AXIsProcessTrusted to return true before showing. Anyone stuck on the v0.4.x → v0.5.x cdhash mismatch (notarized signature invalidates the ad-hoc TCC grant — AXIsProcessTrusted keeps 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, then FirstLaunchWindowController.afterDismiss chains into the AX onboarding window if permission is still missing.
  • AX recovery hint shown upfront — OnboardingView no longer gates the tccutil reset Accessibility com.hillman.SceneApp block (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.applicationDidFinishLaunching now calls openOnboardingIfMissingAX() 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.

Shipped in v0.5.3

  • 60Hz animation for native apps — WindowAnimator now 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 durationMs is 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.sh now 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_TARGET restored to 14.0 (Xcode had silently bumped it to 26.4 matching the build machine's OS).
  • Xcode 26 workaround — build-dmg.sh forces SWIFT_OPTIMIZATION_LEVEL=-Onone because 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).

Shipped in v0.5.2

  • 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 ⌘Q mid-welcome doesn't cause a re-show. Re-openable from Settings → About → "Show welcome screen again".
  • Dynamic version string — About tab now reads CFBundleShortVersionString directly from the bundle instead of a stale localized constant, so future version bumps no longer require a String Catalog edit.

Shipped in v0.5.1

  • Continuous update detection — UpdateChecker now 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.

Shipped in v0.5.0

  • 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 cdhash binding no longer invalidates on every new DMG. Users stay granted across V0.5.x → V0.5.y upgrades.

Shipped in v0.4.3

  • Passive update nudge — new UpdateChecker polls GitHub's releases/latest endpoint at most once per 24h (per-device debounce in UserDefaults) and shows a tinted "Update available: v%@ — click to download" item at the top of the granted menu when the remote tag is newer than CFBundleShortVersionString. 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). UpdateChecker keeps only I/O and state; the semver math is testable without Bundle / URLSession / Xcode.
  • +19 unit tests covering patch / minor / major bumps, v prefix 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 return false so no spurious nudge.
  • Icon re-compression — the 9 AppIcon PNGs were reintroduced uncompressed in the working tree; re-ran pngquant to restore the v0.4.1 size win (~672 KB → 156 KB total).
  • Total SceneCore tests: 158 → 177.

Shipped in v0.4.2

  • Animation lag on ProMotion (120Hz) displays — WindowAnimator now 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" — activeWorkspaceID is now session-only state. Removed from the WorkspaceStore disk model so a prior session's activation no longer presents a checkmark against a workspace the user did not pick. Legacy on-disk activeWorkspaceID keys are ignored by the default JSONDecoder unknown-key policy and stripped on the next write.
  • Instant workspace click — AppLauncher.quit returns immediately on an empty bundleIDs list instead of waiting out the 5s gentle-quit grace; WorkspaceActivator skips the 1.5s settle when appsToLaunch is empty. Default seeded workspaces fire within ~50ms instead of ~6.5s.
  • Layout re-fire catches newly-opened windows — Coordinator.applyLayout records the last applied layout ID and timestamp. A re-fire of the same layout within 5s defers enumeration by 200ms, giving CGWindowListCopyWindowInfo time to register a window opened right before the hotkey. First fire of any layout remains instant.
  • 158 SceneCore unit tests — testSetActivePersists renamed to testActiveWorkspaceIsSessionOnly with inverted assertion; otherwise no count change.

Shipped in v0.4.1

  • DMG size 2.8MB → 1.4MB — -Osize Swift flag, LZFSE DMG compression, and pngquant app icon pass. Styled installer window with custom background, directional arrow, pinned icon positions, hidden toolbar/sidebar.
  • Workspace activation reliability — WorkspaceActivator no longer posts a false-success banner when applyLayout fails; TriggerSupervisor single-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.SceneApp command with a one-click Copy button, replacing the less reliable "toggle OFF/ON" advice.
  • WorkspaceStore.insert duplicate-ID guard — throws .duplicateID(id:) instead of silently overwriting.

Shipped in v0.4.0

V0.4 — Workspaces + layout thumbnails + vertical seeds + multilingual UI

  • 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 applyFutureSeeds mechanism (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.xcstrings with 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 setHotkeyConflictProbe setter 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.

V0.3 — Drag-to-swap

  • 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 AXObserver bridge (AXMoveObserverGroup, AXWindowLookup, DragSwapAnimationSink) wires the SceneCore DragSwapController (pre-existing since V0.1) to real windows.
  • 24 new SceneCore tests (92 → 116).

V0.2 — Customization & smooth animation

  • 11 grid templates — single, 2-col/3-col, 2-row/3-row, 2×2/3×2 grid, 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() via AXIsProcessTrustedWithOptions(prompt: false) for the running-process trust cache trap.
  • 66 new SceneCore unit tests (26 → 92).

V0.5+ — Candidates

High priority

  • 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.

Medium priority

  • Pattern learning — observe manual window arrangements (user-driven setFrame over 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 forceTerminate unclosed 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.

Lower priority / stretch

  • 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.

Non-goals

  • 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.

Clone this wiki locally