Purpose
This issue exists because the same two regressions have been introduced and "fixed" repeatedly in a cycle. Every fix for one symptom broke the other. This issue documents the full history, the root cause, and the exact constraints that must never be violated.
Before touching AppDelegate.swift, PopoverView.swift, or any frame/sizingOptions/contentSize code — read this entire issue first.
The Two Symptoms
Symptom A — Left Jump
The popover flies to the far left of the screen when opened, instead of appearing below the status bar icon.
Cause: NSPopover.contentSize.width changed between opens. NSPopover re-calculates its anchor position from scratch every time contentSize changes — including width. Even a 1pt width difference causes a full re-anchor and the popover lands in the wrong position.
Symptom B — Empty Black Space
The popover shows a large empty black area below the content. Content only fills ~300pt but the popover is 480pt tall.
Cause: contentSize.height was hardcoded to 480pt regardless of actual content height.
The Regression Cycle (commit history)
| Attempt |
What was done |
Symptom A |
Symptom B |
| Early |
contentSize = (340, 480) fixed forever |
✅ No jump |
❌ Empty space |
| Fix B |
sizingOptions = .preferredContentSize + .frame(width: 340) |
❌ Jump returns |
✅ Height fits |
| Fix A |
Back to contentSize = (340, 480) + ScrollView |
✅ No jump |
❌ Empty space |
| Fix B |
sizingOptions + KVO to update contentSize |
❌ Jump returns |
✅ Height fits |
| Fix A |
Remove KVO, fixed contentSize again |
✅ No jump |
❌ Empty space |
| v1.3 |
sizingOptions + .frame(idealWidth: 340) |
✅ No jump |
✅ Height fits |
The cycle always happened because: fixing Symptom B required dynamic height (which changed contentSize) and fixing Symptom A required stable contentSize.width. These appear contradictory — but they are NOT, if you use idealWidth correctly.
Why Each "Fix" Failed
❌ popover.contentSize = NSSize(width: 340, height: 480)
- Fixes: left jump (width never changes)
- Breaks: height fitting (480pt always, empty space when content is shorter)
❌ sizingOptions = .preferredContentSize + .frame(width: 340)
.frame(width: 340) is a layout constraint — it constrains the view's layout width but does NOT guarantee that preferredContentSize.width is reported as exactly 340 on every nav state
- When navigating to
JobStepsView (which has .frame(width:340, height:480)), SwiftUI may report a different ideal width for the root Group
- Result:
preferredContentSize.width changes => contentSize.width changes => left jump
❌ KVO on preferredContentSize to manually update contentSize
- Any write to
contentSize — even height-only — triggers NSPopover to fully re-anchor
- There is no AppKit API to update height without also re-anchoring horizontally
- Result: left jump on every height change
❌ Wrapping jobListView in ScrollView for dynamic height
ScrollView reports infinite preferred height to SwiftUI's layout system
- When combined with
fixedSize(vertical: true), this makes the popover grow to fill the screen
- Without
fixedSize, height defaults to whatever the parent offers (480pt) => empty space
The Only Correct Solution
These two lines must always exist together and never be changed:
AppDelegate.swift
hc.sizingOptions = .preferredContentSize
// DO NOT set popover.contentSize anywhere — not here, not anywhere else
PopoverView.swift — root Group modifier
.frame(idealWidth: 340)
// THIS IS THE KEY. idealWidth, NOT width, NOT width+height.
PopoverView.swift — jobListView usage in body
jobListView
.fixedSize(horizontal: false, vertical: true) // measure natural height
.frame(maxHeight: 480, alignment: .top) // cap at 480, pin to top
// DO NOT wrap jobListView in ScrollView
JobStepsView.swift — fixed drill-down frame
.frame(width: 340, height: 480)
// Width MUST be 340 to match idealWidth. Height fixed is fine (ScrollView handles it).
Why idealWidth Solves Both
idealWidth tells SwiftUI: "my preferred width for layout negotiation is 340pt".
NSHostingController with sizingOptions = .preferredContentSize reads the SwiftUI view's ideal size (not its layout size) and publishes it as preferredContentSize.
Because idealWidth = 340 is set on the root Group — which wraps all nav states — preferredContentSize.width is always exactly 340, regardless of which nav state is active. Height varies freely with content.
Result:
contentSize.width is always 340 → NSPopover anchor never moves → no left jump
contentSize.height varies with content → popover fits content → no empty space
Complete List of Things That Will Cause Regression
❌ Setting popover.contentSize anywhere (manually)
→ Overrides preferredContentSize → breaks dynamic height OR causes re-anchor
❌ Changing hc.sizingOptions to anything other than .preferredContentSize
→ NSPopover stops tracking SwiftUI height → fixed size → empty space
❌ Removing sizingOptions line entirely
→ Same as above
❌ Changing .frame(idealWidth: 340) to .frame(width: 340)
→ Layout constraint ≠ ideal size → preferredContentSize.width may not be 340
→ Width changes on nav state change → left jump
❌ Changing .frame(idealWidth: 340) to .frame(width: 340, height: 480)
→ Fixed height → preferredContentSize.height = 480 always → empty space
❌ Removing .frame(idealWidth: 340) entirely
→ SwiftUI reports unconstrained ideal width → unpredictable → left jump
❌ Adding KVO / observers on preferredContentSize to set contentSize
→ Any contentSize write triggers full NSPopover re-anchor → left jump
❌ Wrapping jobListView in ScrollView
→ ScrollView preferred height = infinite → fixedSize makes popover enormous
→ Without fixedSize → height = 480 always → empty space
❌ Changing JobStepsView .frame width away from 340
→ When navigating to JobStepsView, preferredContentSize.width changes
→ NSPopover sees width change → re-anchors → left jump
Checklist Before Merging Any Sizing Change
Purpose
This issue exists because the same two regressions have been introduced and "fixed" repeatedly in a cycle. Every fix for one symptom broke the other. This issue documents the full history, the root cause, and the exact constraints that must never be violated.
Before touching
AppDelegate.swift,PopoverView.swift, or anyframe/sizingOptions/contentSizecode — read this entire issue first.The Two Symptoms
Symptom A — Left Jump
The popover flies to the far left of the screen when opened, instead of appearing below the status bar icon.
Cause:
NSPopover.contentSize.widthchanged between opens. NSPopover re-calculates its anchor position from scratch every timecontentSizechanges — including width. Even a 1pt width difference causes a full re-anchor and the popover lands in the wrong position.Symptom B — Empty Black Space
The popover shows a large empty black area below the content. Content only fills ~300pt but the popover is 480pt tall.
Cause:
contentSize.heightwas hardcoded to 480pt regardless of actual content height.The Regression Cycle (commit history)
contentSize = (340, 480)fixed foreversizingOptions = .preferredContentSize+.frame(width: 340)contentSize = (340, 480)+ScrollViewsizingOptions+ KVO to updatecontentSizecontentSizeagainsizingOptions+.frame(idealWidth: 340)The cycle always happened because: fixing Symptom B required dynamic height (which changed
contentSize) and fixing Symptom A required stablecontentSize.width. These appear contradictory — but they are NOT, if you useidealWidthcorrectly.Why Each "Fix" Failed
❌
popover.contentSize = NSSize(width: 340, height: 480)❌
sizingOptions = .preferredContentSize+.frame(width: 340).frame(width: 340)is a layout constraint — it constrains the view's layout width but does NOT guarantee thatpreferredContentSize.widthis reported as exactly 340 on every nav stateJobStepsView(which has.frame(width:340, height:480)), SwiftUI may report a different ideal width for the root GrouppreferredContentSize.widthchanges =>contentSize.widthchanges => left jump❌ KVO on
preferredContentSizeto manually updatecontentSizecontentSize— even height-only — triggers NSPopover to fully re-anchor❌ Wrapping
jobListViewinScrollViewfor dynamic heightScrollViewreports infinite preferred height to SwiftUI's layout systemfixedSize(vertical: true), this makes the popover grow to fill the screenfixedSize, height defaults to whatever the parent offers (480pt) => empty spaceThe Only Correct Solution
These two lines must always exist together and never be changed:
AppDelegate.swiftPopoverView.swift— root Group modifierPopoverView.swift— jobListView usage in bodyjobListView .fixedSize(horizontal: false, vertical: true) // measure natural height .frame(maxHeight: 480, alignment: .top) // cap at 480, pin to top // DO NOT wrap jobListView in ScrollViewJobStepsView.swift— fixed drill-down frameWhy
idealWidthSolves BothidealWidthtells SwiftUI: "my preferred width for layout negotiation is 340pt".NSHostingControllerwithsizingOptions = .preferredContentSizereads the SwiftUI view's ideal size (not its layout size) and publishes it aspreferredContentSize.Because
idealWidth = 340is set on the root Group — which wraps all nav states —preferredContentSize.widthis always exactly 340, regardless of which nav state is active. Height varies freely with content.Result:
contentSize.widthis always 340 → NSPopover anchor never moves → no left jumpcontentSize.heightvaries with content → popover fits content → no empty spaceComplete List of Things That Will Cause Regression
Checklist Before Merging Any Sizing Change
AppDelegatestill havehc.sizingOptions = .preferredContentSize?popover.contentSizeset anywhere? (It must NOT be)GroupinPopoverViewstill have.frame(idealWidth: 340)and nothing else?jobListViewfree ofScrollView?jobListViewuse.fixedSize(horizontal: false, vertical: true)+.frame(maxHeight: 480, alignment: .top)?JobStepsViewframe width still exactly 340?