Plan: PiP mini timer window

Backlog entry: 910-backlog - “Minified/PiP mode (Mac only)“.

The backlog sketch describes morphing the main window into a compact layout. This plan supersedes that sketch: the investigation below concluded a companion window (a second scene) is the better shape - the main window stays open and usable while a small floating timer panel sits on top of other apps. The same-window minify remains documented as the fallback.

Goal

A pip toolbar button next to the pin button (Mac Catalyst only). Clicking it opens a small, fixed-size, always-on-top window that shows:

  • one compact row per running timer: tag color dot, tag name, ticking elapsed time, stop button;
  • a play button that starts a new (untagged) timer, matching the floating play button’s behavior;
  • an empty state when nothing is running.

Clicking the button again (or closing the window) dismisses it. No editing, no history, no settings in the mini window - v1 is start/stop and glanceable elapsed time only.

Additionally, a menu bar (tray) icon showing a play symbol:

  • left click summons/hides the same mini timer window (positioned under the status item on first open);
  • the symbol reflects state: play (outline) when idle, play.fill when at least one timer is running;
  • right click opens a small menu: “Start timer”, “Stop all timers”, “Open Minuta”;
  • a Settings toggle (“Show menu bar icon”, default on) hides it for users who guard their menu bar space.

What exists today (investigation findings)

The hardest pieces are already in the codebase in working form:

  • AppKit bridge from Catalyst. WindowPinManager.getNSWindow() (MinutaApp.swift) reaches the real NSWindow via the private _hostWindowForUIWindow: delegate call, unwraps UINSWindowProxy.attachedWindow, and mutates level / collectionBehavior by KVC. There is also a working IMP-cast pattern for selectors KVC can’t express (setFrame:display: in moveToTestOrigin). Everything the mini window needs from AppKit (floating level, hidden zoom/minimize buttons, frame placement) uses these exact techniques.
  • Per-scene window configuration. SceneDelegate.scene(_:willConnectTo:options:) already configures windowScene.titlebar (toolbar, titleVisibility) and windowScene.sizeRestrictions (used to pin the UI-test window size). The mini scene reuses these hooks.
  • Shared state across scenes. AppState lives in AppContainer, held as App-level @State in MinutaApp. A second SwiftUI window scene receives the same instance, so running timers, startTimer, stopTimer, and the 60s auto-refresh / ExternalChangeMonitor ticks all work in the mini window for free.
  • Toolbar precedent. The pin / settings / update-pill items in SceneDelegate show exactly how to add and toggle the new button.
  • Ticking rows. Running rows tick via TimelineView(.periodic(from:by: 1)) (RecordEditorViews.swift) with DurationFormatter - reuse the mechanism, not the view.

The one missing piece: the app is single-scene. Minuta/Sources/Info.plist has no UIApplicationSceneManifest, so UIApplication.supportsMultipleScenes is false and no second window can be created.

Decision: companion window, not window minify, not AVKit PiP

Three candidate shapes were evaluated:

ShapeVerdict
ACompanion window - second UIWindowScene, small + floating via the existing NSWindow bridgeChosen
BCompact mode - main window morphs into the mini layout and back (the original backlog sketch)Fallback
CSystem PiP - AVPictureInPictureController + AVSampleBufferDisplayLayer rendering the timer as video framesRejected for Mac; only route for iOS, deferred

Why A over B: with B the full window is unavailable while minified - that is a mini-player, not PiP. The actual use case (“keep timers visible while working in another app”) wants the panel and the main window independently. B also needs careful save/restore of two window frames on one window and a layout that animates between two very different hierarchies; A composes from existing primitives instead.

Why A over C: on the Mac, C means rasterizing text into video frames, controls limited to the system play/pause/close (no per-timer stop, no tag colors as interactive rows), and AV background-mode plumbing - strictly worse than a real window. C stays relevant only as a future iOS feature and is out of scope here.

Cost of A: enabling UIApplicationSupportsMultipleScenes, which has side effects (see Risks).

Sub-decision: the tray summons the mini window, it does not duplicate it

NSStatusItem is AppKit-only - a Catalyst app can reach it solely through a small AppKit plugin bundle loaded at runtime (a documented, supported Catalyst technique). Two shapes were considered for what clicking the icon shows:

  • NSPopover with a rebuilt player UI inside the plugin - rejected. The plugin compiles against macOS AppKit, not Catalyst UIKit, so none of the app’s SwiftUI views, MinutaShared types, or AppState are usable there; the player would be a second, hand-rolled AppKit implementation of the same UI, drifting from the SwiftUI one forever, with all timer data marshalled across the bridge.
  • Toggle the existing companion mini window - chosen. The plugin stays a dumb shell (status item + click forwarding + icon state); all UI remains the one SwiftUI MiniTimerView. This is also exactly the backlog “Menu Bar app” bullet “click to summon/hide always-on-top widget”.

Consequence: the tray icon and the toolbar pip button are two triggers for the same MiniWindowManager.toggle().

Design

1. Enable multiple scenes

Minuta/Sources/Info.plist gains:

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <true/>
</dict>

No scene-configuration array - AppDelegate.application(_:configurationForConnecting:options:) keeps supplying SceneDelegate for every scene, as today.

2. Scene routing and role detection

The mini window is a second SwiftUI scene:

WindowGroup(id: "miniTimers") {
    MiniTimerView()
        .environment(appContainer.appState)
}
.handlesExternalEvents(matching: [MiniWindowManager.activityType])

Opening is driven from UIKit (the toolbar button lives in SceneDelegate), so skip SwiftUI’s openWindow and use the deterministic UIKit route:

let activity = NSUserActivity(activityType: MiniWindowManager.activityType) // "tools.minuta.app.miniTimers"
UIApplication.shared.requestSceneSessionActivation(existingMiniSession, userActivity: activity, options: nil)
  • First open: existingMiniSession is nil; SwiftUI matches the activity to the miniTimers WindowGroup via handlesExternalEvents, and the activity arrives in connectionOptions.userActivities in willConnectTo - the deterministic role signal.
  • Role stamping: on first connect, SceneDelegate writes session.userInfo = ["role": "mini"]. Restored scenes have empty userActivities, so the stamp is what survives relaunch; the willConnectTo branch checks activity-or-stamp.
  • Dedupe / toggle: MiniWindowManager (new @MainActor singleton, sibling of WindowPinManager) tracks the mini UISceneSession. Toggle = requestSceneSessionActivation with the existing session if backgrounded, requestSceneSessionDestruction if open, fresh activation otherwise.

Fallback if handlesExternalEvents misbehaves on Catalyst (see Risks): notification from the toolbar button to the main scene’s SwiftUI layer, which calls openWindow(id: "miniTimers"), and role detection via the com.apple.SwiftUI.openWindow activity in connectionOptions. Same architecture, less deterministic detection.

3. Mini window configuration (SceneDelegate, mini branch)

In willConnectTo, when the scene is the mini scene:

  • sizeRestrictions.minimumSize = miniSize; maximumSize = miniSize (start at ~320x220, tune against the real content) - fixed size, effectively non-resizable;
  • titlebar.titleVisibility = .hidden, titlebar.toolbar = nil;
  • via the NSWindow bridge: level = 3 (floating, unconditionally - independent of the main window’s pin state), collectionBehavior |= canJoinAllSpaces, and hide the minimize + zoom traffic lights via standardWindowButton: (IMP-cast call, same pattern as setFrame:display:; keep the close button so the window is dismissible);
  • restore the window origin from UserDefaults (miniWindowOrigin) with the existing setFrame:display: IMP pattern, clamped to the screen’s visibleFrame using the same math as moveToTestOrigin; save the origin in sceneDidDisconnect.

4. Prerequisite refactor: kill connectedScenes.first

Two call sites assume a single scene, and both become live bugs the moment the mini scene connects (a Set’s .first is unordered - the pin could target the mini window):

  • SceneDelegate.updateToolbarPinIcon() (MinutaApp.swift:360)
  • WindowPinManager.getNSWindow() (MinutaApp.swift:446-447)

Refactor: resolve scenes by role - mainWindowScene = first connected UIWindowScene whose session.userInfo["role"] != "mini"; getNSWindow() becomes getNSWindow(for: UIWindowScene). SceneDelegate already holds its own scene reference implicitly (it is the delegate) - pass it explicitly instead of re-deriving from the application. This lands as its own commit before any multi-scene work.

5. MiniTimerView

New file Minuta/Sources/Views/MiniTimerView.swift. Deliberately not a reuse of RecordEditor (which RunningTimersSection uses with alwaysEditing: true) - that is a full editor with comment field, tag picker, and image handling; the mini row is a fresh ~40-line view:

  • Row: tag color circle (tag’s stored color, same resolution as the main list), tag name or “No tag” (secondary style), elapsed time via TimelineView(.periodic(from: .now, by: 1)) + DurationFormatter, and a stop button (stop.circle.fill) calling appState.stopTimer(record).
  • Empty state: “No running timers” placeholder text.
  • Play button: always visible, pinned at the bottom, visually consistent with floatingPlayButton in ContentView; calls appState.startTimer(tagId: nil, comment: nil).
  • Overflow: rows in a ScrollView; the window height stays fixed.
  • Loading state: on relaunch-with-restoration the mini scene can connect before AppState finishes its initial load - render the empty/placeholder state until data arrives rather than assuming runningTimers is ready.
  • Accessibility identifiers (for UI tests): miniTimerWindow on the root, miniPlayButton, miniStopButton per row, miniTimerRow per row. Identifiers go on leaf controls, not containers - the propagation trap is documented in RunningTimersSection.

6. Toolbar button

New NSToolbarItem.Identifier("pipButton"), accessibility identifier pipButton, inserted immediately before pinIdentifier in toolbarDefaultItemIdentifiers. SF Symbol pip.enter when closed, pip.exit when open (icon swap mirrors the existing pin/pin.fill handling). Action: MiniWindowManager.shared.toggle(). MiniWindowManager notifies the delegate (same NotificationCenter pattern as .updateToolbarStateChanged) so the icon also updates when the user closes the window via its close button.

7. Tray icon (AppKit plugin bundle)

New XcodeGen target in Minuta/project.yml: MinutaTray, a plain macOS bundle (bundle product type, MACOSX_DEPLOYMENT_TARGET: "15.0", not Catalyst), embedded into the app at Contents/PlugIns/MinutaTray.bundle, signed with the same team. One Swift source file plus the shared bridge protocol file.

Bridge protocol - a single Swift file compiled into both targets (the only code they share), so no framework linkage is needed:

@objc public protocol MinutaTrayPlugin {
    init()
    func install(delegate: MinutaTrayDelegate)   // creates the NSStatusItem
    func uninstall()                             // removes it (Settings toggle off)
    func setRunning(_ running: Bool)             // play <-> play.fill
}

@objc public protocol MinutaTrayDelegate {
    func trayToggleMiniWindow(statusItemFrame: CGRect) // left click; frame in screen coords
    func trayStartTimer()                              // right-click menu
    func trayStopAllTimers()
    func trayOpenMainWindow()
}

Plugin side (MinutaTray/TrayController.swift, principal class): NSStatusBar.system.statusItem(withLength: .squareLength); button image NSImage(systemSymbolName:) - play idle, play.fill running - with isTemplate = true so it adapts to menu bar appearance and tinting. Left click calls trayToggleMiniWindow passing button.window.frame; right click builds a plain NSMenu (Start timer / Stop all timers / Open Minuta) whose items call the delegate. No timer data, no ticking, no UI beyond the icon and menu.

App side (new TrayIconManager, @MainActor singleton next to MiniWindowManager):

  • loads the bundle from Bundle.main.builtInPlugInsURL, instantiates principalClass via the protocol, calls install(delegate:);
  • implements the delegate: toggle routes to MiniWindowManager.toggle() (passing the status-item frame so the window can be placed under the icon on first open); start/stop route to the existing AppState calls; “Open Minuta” activates the main scene;
  • observes appState.runningTimers (it is @Observable) and pushes setRunning(!runningTimers.isEmpty);
  • honors the showTrayIcon UserDefault (Settings toggle in SettingsSheet, Catalyst-only section, default on) - install/uninstall on change.

Not gated by MANUAL_DISTRIBUTION: the tray icon is a product feature, not a distribution concern. (If an App Store build ever happens, review of a loadable AppKit bundle inside a Catalyst app is a known gray area - noted in Risks, irrelevant while distribution is Homebrew-only.)

Risks and mitigations

  1. handlesExternalEvents routing on Catalyst. The SwiftUI-side scene matching for UIKit-requested activations is the least-documented link in the chain. Mitigation: this is step 1 of the sequencing - a throwaway spike proving manifest -> second scene -> role detection -> NSWindow config end-to-end before any real UI is built. Fallback route documented in Design 2.
  2. “New Window” menu item / Cmd+N. With supportsMultipleScenes on, UIKit/SwiftUI may surface a New Window command; Cmd+N is already bound to Start Timer (TimerCommands). Verify during the spike. If it appears: remove it via UIMenuBuilder (builder.remove(menu: .newScene)) in the app delegate’s buildMenu(with:), or accept it - extra main windows share AppState and are data-safe, merely redundant. Decision falls out of the spike.
  3. Frame autosave collision. The main window persists as NSWindow Frame MainSceneWindow; a second Catalyst window may share or fight over autosave names. Mitigation: the mini window never relies on AppKit autosave - fixed size via sizeRestrictions, origin persisted manually (Design 3). Verify during the spike that the main window’s autosave is unaffected.
  4. Single-scene assumptions. Audited: only the two call sites in Design 4 exist (grep connectedScenes\|windows.first\|keyWindow). The refactor lands first.
  5. XCUITest with two windows. app.buttons["..."] queries span all windows on Catalyst, which can produce ambiguous matches. Mitigation: all mini-window queries scope through the miniTimerWindow identifier (app.otherElements["miniTimerWindow"].buttons[...]); main-window assertions keep using existing unique identifiers. If window-scoping proves flaky, per-window queries via app.windows.element(boundBy:) are the fallback - research first per the testing-loop rule, don’t iterate blind.
  6. State restoration. macOS will try to restore the mini scene on relaunch. This is desired (the panel comes back where you left it), but it must not crash pre-load (Design 5’s loading state) and must re-apply the NSWindow config (level/buttons) in willConnectTo, which runs on restoration too. Explicit manual-test item.
  7. AppKit bundle loading. Loading an AppKit plugin into a Catalyst process is a known technique but has sharp edges: the bundle must be signed with the same team so library validation admits it, both targets must build with the same toolchain (same repo, same Xcode - given), and NSStatusBar must be exercised only from the plugin side. Mitigation: the plugin-load spike is folded into sequencing step 5 before any UI depends on it; the bridge is @objc protocols with Foundation-only types, so no Swift-module or SwiftUI coupling crosses the boundary. If it ever goes to the App Store, a loadable bundle is a review gray area - acceptable while distribution is Homebrew-only.
  8. Tray icon is untestable from XCUITest. The status item lives in the system menu bar; clicking it from XCUITest requires system-wide queries that are flaky and permission-gated. Mitigation: the plugin is deliberately logic-free; everything behind the delegate (toggle, start, stop) is covered by the toolbar-button UI tests and unit tests, and the icon itself goes on the manual checklist.

Test plan

Unit tests (MinutaTests, app target)

  • MiniWindowManager: toggle state transitions (closed -> open -> closed), session bookkeeping with a stubbed session, origin persistence round-trip through UserDefaults (including the clamp-to-visible-frame math with an off-screen saved origin).
  • Scene-role helper: activity present -> mini; stamped userInfo -> mini; neither -> main.
  • TrayIconManager (Catalyst-only tests): plugin bundle loads from builtInPlugInsURL and its principal class conforms to MinutaTrayPlugin (the test host is the Catalyst app, so this exercises the real load path); setRunning pushed on runningTimers transitions empty -> non-empty -> empty, against a mock plugin; showTrayIcon default-on install and toggle-off uninstall, against a mock plugin.

UI tests (new MiniTimerTests class, Catalyst-only)

Wrapped in #if targetEnvironment(macCatalyst) like DocumentPickerStabilityTests; runs under ./scripts/run-uitests.sh --mac, no-op on iPhone.

  1. testOpenAndCloseMiniWindow - launch (empty fixture), click pipButton, assert miniTimerWindow + miniPlayButton + empty state appear; click pipButton again, assert the window is gone and the toolbar icon reverted.
  2. testStartAndStopFromMiniWindow - open mini window, click miniPlayButton; assert a miniTimerRow with miniStopButton appears and the main window’s Running section shows the same timer (cross-scene state sharing). Click miniStopButton; assert the row disappears from both and the record lands in Today.
  3. testMainWindowTimerAppearsInMini - start a timer via the main floatingPlayButton, then open the mini window; assert the row is present with the expected tag name.
  4. testCloseViaWindowButtonUpdatesToolbar - open mini window, close it via its close button, assert the pipButton icon state reset (guards the disconnect-notification path).
  5. testTrayIconSettingsToggle - open Settings, assert the “Show menu bar icon” toggle exists and flips its persisted state (the status item itself is asserted manually, per Risk 8).
  6. Screenshots: mini window with two running timers (standard fixture), saved to UITests/__Snapshots__/MiniTimerTests/ for the website’s Mac screenshot set.

Existing suites must pass unchanged on both platforms: the mini window never opens unless requested, so iPhone tests are untouched and Mac tests only gain the new toolbar item (no test asserts the exact toolbar item count today). Per the standing rule, “run all tests” = Mac Catalyst first, then iPhone, plus cd Shared && swift test.

Manual checklist (not automatable)

  • Mini window floats above other apps’ windows and full-screen Spaces; follows across Spaces (canJoinAllSpaces).
  • Pin interplay: main pinned + mini open, main unpinned + mini open - mini always floats, main honors its own pin state, pin button targets the main window (the Design 4 refactor).
  • Minimize/zoom traffic lights hidden on the mini window; close works; window is not resizable.
  • Quit and relaunch with the mini window open: panel restores at its last origin, shows the loading-safe state, then live timers.
  • Saved origin on a disconnected external display clamps back on-screen.
  • Dark mode.
  • Cmd+N still starts a timer; no stray New Window item (or the accepted behavior from Risk 2 is documented).
  • Tray icon: appears on launch (default on); play outline when idle, play.fill while a timer runs, template rendering correct in light/dark menu bars; left click summons the mini window under the icon and a second click hides it; right-click menu items (Start timer, Stop all timers, Open Minuta) all work; Settings toggle removes and restores the icon live; icon state stays in sync when timers are started/stopped from the main window, the mini window, and the CLI (external change polling picks up the latter within its 5s/60s cadence).

Docs to update

  • Root CLAUDE.md - “Mac Catalyst Specifics” (new toolbar button + mini window + tray icon and its plugin bundle), “UI Structure”, “Key UI Components” (MiniTimerView), “Project Structure” (the MinutaTray target), and the project.yml regeneration note if target layout changes.
  • minuta.tools/src/routes/docs/ - Mac/Catalyst UI page for the feature; this plan’s backlog entry ticked and moved to 911-completed on completion.
  • Website screenshots: regenerate the Mac set (the new toolbar button changes existing toolbar screenshots) per the “Regenerating website screenshots” workflow.

Sequencing

  1. Spike (throwaway view): scene manifest + requestSceneSessionActivation routing + role detection + NSWindow config (float, fixed size, hidden buttons). Resolves Risks 1-3 before committing to the design. If routing fails, switch to the documented fallback and update this plan.
  2. connectedScenes.first refactor (Design 4) + unit coverage - independently shippable, commit on its own.
  3. MiniWindowManager + toolbar button + MiniTimerView - the feature proper.
  4. Persistence + polish: origin save/restore + clamping, restoration loading state, icon state via disconnect notification.
  5. Tray icon: MinutaTray bundle target + xcodegen generate, plugin-load smoke test first (Risk 7), then the bridge, TrayIconManager, icon state sync, right-click menu, Settings toggle, window placement under the icon.
  6. Tests + screenshots: MiniTimerTests, TrayIconManager unit tests, screenshot regen.
  7. Docs + backlog.

Commit after each step; run tests before committing.

Implementation outcomes (2026-08-06)

Shipped as planned; feature doc at 400-ui/415-mini-timer-window. How the risks resolved:

  1. Routing (Risk 1): requestSceneSessionActivation + handlesExternalEvents worked exactly as designed on the first try; the fallback was never needed.
  2. New Window / Cmd+N (Risk 2) materialized harder than predicted: not a stray menu item but a launch crash — SwiftUI’s contributed “New Window” Cmd+N plus Start Timer’s Cmd+N is a duplicate key command, and menu building throws NSInternalInconsistencyException before the first frame. Fix: CommandGroup(replacing: .newItem) instead of after: — New Window is gone, Cmd+N stays Start Timer.
  3. Frame autosave (Risk 3): no collision observed; manual origin persistence works. Origin save/restore is disabled in UI-test runs (shared UserDefaults), and a restored mini scene is destroyed on connect in test mode for determinism.
  4. Single-scene assumptions (Risk 4): refactor landed first as its own commit, as planned.
  5. XCUITest two windows (Risk 5): unscoped app.buttons[...] queries turned out to work fine across both windows — no window scoping needed.
  6. Plugin bundle (Risk 7): embeds under Contents/PlugIns/, loads, and the cross-image @objc protocol cast conforms — verified by a unit test running in the real app host.
  7. Unplanned find: the Settings toggle must bind via @AppStorage, not a manual Binding into the non-observable manager — the control otherwise never re-renders. Caught by the toggle UI test.
  8. Unplanned find (test-infra, not this feature): a full-suite Mac run failed across nine suites with “Unable to find hit point” errors. Root cause was environmental — during the pre-fix launch-crash loop (outcome 2), the Catalyst-prewarmed “Fonts” panel got flipped visible once; AppKit persisted NSFontPanelAttributes = "1, 8" in the app’s defaults, and from then on every launch of any build re-opened the panel and every exit re-persisted the flag. A visible extra window breaks every test helper anchored on app.windows.firstMatch (snapshot, dragScroll, swipeDown). The fix: quit all app instances first, then defaults delete tools.minuta.app NSFontPanelAttributes — deleting while an instance is alive gets undone on its exit. Diagnosis note: failure screenshots that show non-Minuta content (another app, the desktop) mean windows.firstMatch resolved to a phantom window or nothing — check CGWindowListCopyWindowInfo for extra onscreen windows owned by the app.

Deferred (not this task)

  • iOS/iPadOS PiP (option C, AVPictureInPictureController) - separate backlog entry if ever wanted; nothing in this design blocks it.
  • Row tap opens the editor (activate main window, open RecordEditor for the record) - natural v2.
  • Tag picker on the mini play button (long-press menu of recent tags) - v2.
  • Richer tray menu - per-timer rows with ticking elapsed and individual stop actions in the right-click menu, and/or elapsed time as status-item text next to the icon. Both require streaming timer snapshots across the bridge; the v1 bridge stays a boolean on purpose. The backlog “Menu Bar app” entry is otherwise covered by this plan (status icon + summon/hide + quick start/stop).