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.fillwhen 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 realNSWindowvia the private_hostWindowForUIWindow:delegate call, unwrapsUINSWindowProxy.attachedWindow, and mutateslevel/collectionBehaviorby KVC. There is also a working IMP-cast pattern for selectors KVC can’t express (setFrame:display:inmoveToTestOrigin). 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 configureswindowScene.titlebar(toolbar,titleVisibility) andwindowScene.sizeRestrictions(used to pin the UI-test window size). The mini scene reuses these hooks. - Shared state across scenes.
AppStatelives inAppContainer, held as App-level@StateinMinutaApp. A second SwiftUI window scene receives the same instance, so running timers,startTimer,stopTimer, and the 60s auto-refresh /ExternalChangeMonitorticks all work in the mini window for free. - Toolbar precedent. The pin / settings / update-pill items in
SceneDelegateshow exactly how to add and toggle the new button. - Ticking rows. Running rows tick via
TimelineView(.periodic(from:by: 1))(RecordEditorViews.swift) withDurationFormatter- 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:
| Shape | Verdict | |
|---|---|---|
| A | Companion window - second UIWindowScene, small + floating via the existing NSWindow bridge | Chosen |
| B | Compact mode - main window morphs into the mini layout and back (the original backlog sketch) | Fallback |
| C | System PiP - AVPictureInPictureController + AVSampleBufferDisplayLayer rendering the timer as video frames | Rejected 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:
NSPopoverwith 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,MinutaSharedtypes, orAppStateare 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:
existingMiniSessionis nil; SwiftUI matches the activity to theminiTimersWindowGroupviahandlesExternalEvents, and the activity arrives inconnectionOptions.userActivitiesinwillConnectTo- the deterministic role signal. - Role stamping: on first connect,
SceneDelegatewritessession.userInfo = ["role": "mini"]. Restored scenes have emptyuserActivities, so the stamp is what survives relaunch; thewillConnectTobranch checks activity-or-stamp. - Dedupe / toggle:
MiniWindowManager(new@MainActorsingleton, sibling ofWindowPinManager) tracks the miniUISceneSession. Toggle =requestSceneSessionActivationwith the existing session if backgrounded,requestSceneSessionDestructionif 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 viastandardWindowButton:(IMP-cast call, same pattern assetFrame:display:; keep the close button so the window is dismissible); - restore the window origin from UserDefaults (
miniWindowOrigin) with the existingsetFrame:display:IMP pattern, clamped to the screen’svisibleFrameusing the same math asmoveToTestOrigin; save the origin insceneDidDisconnect.
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) callingappState.stopTimer(record). - Empty state: “No running timers” placeholder text.
- Play button: always visible, pinned at the bottom, visually consistent with
floatingPlayButtonin ContentView; callsappState.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
AppStatefinishes its initial load - render the empty/placeholder state until data arrives rather than assumingrunningTimersis ready. - Accessibility identifiers (for UI tests):
miniTimerWindowon the root,miniPlayButton,miniStopButtonper row,miniTimerRowper row. Identifiers go on leaf controls, not containers - the propagation trap is documented inRunningTimersSection.
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, instantiatesprincipalClassvia the protocol, callsinstall(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 existingAppStatecalls; “Open Minuta” activates the main scene; - observes
appState.runningTimers(it is@Observable) and pushessetRunning(!runningTimers.isEmpty); - honors the
showTrayIconUserDefault (Settings toggle inSettingsSheet, Catalyst-only section, default on) -install/uninstallon 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
handlesExternalEventsrouting 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.- “New Window” menu item / Cmd+N. With
supportsMultipleSceneson, 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 viaUIMenuBuilder(builder.remove(menu: .newScene)) in the app delegate’sbuildMenu(with:), or accept it - extra main windows shareAppStateand are data-safe, merely redundant. Decision falls out of the spike. - 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 viasizeRestrictions, origin persisted manually (Design 3). Verify during the spike that the main window’s autosave is unaffected. - Single-scene assumptions. Audited: only the two call sites in Design 4 exist (
grep connectedScenes\|windows.first\|keyWindow). The refactor lands first. - XCUITest with two windows.
app.buttons["..."]queries span all windows on Catalyst, which can produce ambiguous matches. Mitigation: all mini-window queries scope through theminiTimerWindowidentifier (app.otherElements["miniTimerWindow"].buttons[...]); main-window assertions keep using existing unique identifiers. If window-scoping proves flaky, per-window queries viaapp.windows.element(boundBy:)are the fallback - research first per the testing-loop rule, don’t iterate blind. - 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. - 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
NSStatusBarmust 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@objcprotocols 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. - 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 frombuiltInPlugInsURLand its principal class conforms toMinutaTrayPlugin(the test host is the Catalyst app, so this exercises the real load path);setRunningpushed onrunningTimerstransitions empty -> non-empty -> empty, against a mock plugin;showTrayIcondefault-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.
testOpenAndCloseMiniWindow- launch (empty fixture), clickpipButton, assertminiTimerWindow+miniPlayButton+ empty state appear; clickpipButtonagain, assert the window is gone and the toolbar icon reverted.testStartAndStopFromMiniWindow- open mini window, clickminiPlayButton; assert aminiTimerRowwithminiStopButtonappears and the main window’s Running section shows the same timer (cross-scene state sharing). ClickminiStopButton; assert the row disappears from both and the record lands in Today.testMainWindowTimerAppearsInMini- start a timer via the mainfloatingPlayButton, then open the mini window; assert the row is present with the expected tag name.testCloseViaWindowButtonUpdatesToolbar- open mini window, close it via its close button, assert thepipButtonicon state reset (guards the disconnect-notification path).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).- 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);
playoutline when idle,play.fillwhile 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” (theMinutaTraytarget), and theproject.ymlregeneration 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
- Spike (throwaway view): scene manifest +
requestSceneSessionActivationrouting + 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. connectedScenes.firstrefactor (Design 4) + unit coverage - independently shippable, commit on its own.MiniWindowManager+ toolbar button +MiniTimerView- the feature proper.- Persistence + polish: origin save/restore + clamping, restoration loading state, icon state via disconnect notification.
- Tray icon:
MinutaTraybundle 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. - Tests + screenshots:
MiniTimerTests,TrayIconManagerunit tests, screenshot regen. - 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:
- Routing (Risk 1):
requestSceneSessionActivation+handlesExternalEventsworked exactly as designed on the first try; the fallback was never needed. - 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
NSInternalInconsistencyExceptionbefore the first frame. Fix:CommandGroup(replacing: .newItem)instead ofafter:— New Window is gone, Cmd+N stays Start Timer. - 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.
- Single-scene assumptions (Risk 4): refactor landed first as its own commit, as planned.
- XCUITest two windows (Risk 5): unscoped
app.buttons[...]queries turned out to work fine across both windows — no window scoping needed. - Plugin bundle (Risk 7): embeds under
Contents/PlugIns/, loads, and the cross-image@objcprotocol cast conforms — verified by a unit test running in the real app host. - Unplanned find: the Settings toggle must bind via
@AppStorage, not a manualBindinginto the non-observable manager — the control otherwise never re-renders. Caught by the toggle UI test. - 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 onapp.windows.firstMatch(snapshot, dragScroll, swipeDown). The fix: quit all app instances first, thendefaults 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) meanwindows.firstMatchresolved to a phantom window or nothing — checkCGWindowListCopyWindowInfofor 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
RecordEditorfor 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).