Move developer tools into a global floating overlay#70
Conversation
Extract the DEBUG-only Developer section out of Settings into a standalone DeveloperToolsView and surface it through a new global, floating DeveloperOverlay attached at RootView. The overlay is a small draggable, corner-snapping button (DeveloperOverlayButton, its own view type for easy restyling) that expands into a Picture-in-Picture panel and can grow to full screen and back, keeping its navigation state across the resize. It floats above every launch phase and tab, so the tools are reachable from anywhere including logged out; the SwiftData inspector row hides until a live session exists. Renames the settings.debug.* strings to developer.* and adds overlay chrome strings. Removes the developer section and now-unused imports from SettingsView. Adds unit tests for the presentation/corner logic and hosting tests for the new views.
Update the module READMEs, Where/AGENTS.md, and the RegionMapView doc comments so they describe the DEBUG developer tools living in the global floating overlay rather than a Settings section. Regenerated CLAUDE.md via ./sync-agents (gitignored).
| private let edgeInset: CGFloat = 16 | ||
| /// Extra bottom clearance so the resting button clears the floating tab | ||
| /// bar when the app is logged in. | ||
| private let bottomBarClearance: CGFloat = 64 |
There was a problem hiding this comment.
This of all the numbers is a smell, can we derive this somehow from the actual UI?
There was a problem hiding this comment.
Posted by an AI agent on Kyle Van Essen's behalf.
Agreed — removed the magic 64 in ced1dd1 and derived it from the live UI instead. Because the overlay is a RootView sibling above the TabView, the window's safe area doesn't include the floating tab bar, so MainTabs now measures it: each tab's content bottom inset minus the window's own bottom inset (the home indicator) = the actual bar height, reported up via a DeveloperTabBarInsetKey preference and threaded to the overlay by RootView. The bottom corners offset by that plus the real safe-area insets on every edge; logged out (no tab bar) it just falls back to the safe area.
I wasn't sure a .background reader inside tab content would actually see the bar, so DeveloperTabBarInsetTests.reporterMeasuresFloatingTabBarHeight() hosts a real TabView and asserts the measured height is > 0 — guarding the assumption if a future SDK changes it.
| case .collapsed: | ||
| collapsedButton(in: proxy) | ||
| case .floating, .fullScreen: | ||
| panel(in: proxy) |
There was a problem hiding this comment.
Full screen need to be done in a way that ensure accessibility works; the view needs to become accessibility modal.
There was a problem hiding this comment.
Posted by an AI agent on Kyle Van Essen's behalf.
Done in ba0c3cb. In full screen the panel now carries the .isModal accessibility trait (as a contained element), so VoiceOver ignores everything behind it and stays trapped in the tools. The floating PiP panel stays non-modal — the app behind is still reachable — matching its visible, tap-through behavior, per our discussion.
| /// ``DeveloperOverlay``. It's a solid disc with a white glyph, ring, and | ||
| /// shadow so it stays legible over light, dark, and busy photo backgrounds. | ||
| struct DeveloperOverlayButton: View { | ||
| var diameter: CGFloat = 52 |
There was a problem hiding this comment.
Duplicate 52 from above, put into a constant or remove the need for it entirely (derive from an intrinsic layout)
There was a problem hiding this comment.
Posted by an AI agent on Kyle Van Essen's behalf.
Fixed in 28190d1. The button now owns a single @ScaledMetric(relativeTo: .title2) private var diameter = 52 (so it also scales with Dynamic Type), and the overlay no longer hardcodes the size at all — it measures the rendered button via onGeometryChange and uses that for its anchor/drag math. So the number lives in exactly one place.
| switch presentation { | ||
| case .collapsed: break | ||
| case .floating: presentation = .fullScreen | ||
| case .fullScreen: presentation = .floating |
There was a problem hiding this comment.
Once we address the accessibility changes above we'll need this to ensure we re poll a11y
There was a problem hiding this comment.
Posted by an AI agent on Kyle Van Essen's behalf.
Handled in 00f9efb. Rather than driving it from the model, it's posted view-side from .onChange(of: model.presentation) (a presentation concern) — you'd ok'd onChange for this. Crossing the full-screen boundary (where the modal flips) posts .screenChanged to move VoiceOver focus into/out of the modal; the non-modal floating open/close posts the lighter .layoutChanged.
…verlay Addresses PR review: the collapsed button's diameter was hardcoded both in DeveloperOverlayButton and (as buttonDiameter) in DeveloperOverlay. The button now owns a single @ScaledMetric diameter (scales with Dynamic Type) and the overlay measures the rendered size via onGeometryChange for its anchor/drag math instead of duplicating the constant.
Addresses PR review: the resting button's bottom clearance was a hardcoded 64pt guess at the floating tab bar's height. MainTabs now reports each tab's content bottom inset minus the window inset (the actual bar height) via a preference; RootView threads it to the sibling overlay, which offsets the bottom corners by it (and by the real safe-area insets on every edge). Logged out there's no tab bar, so it falls back to the safe area. A hosting test guards that the reporter measures the bar, not just the home indicator.
Addresses PR review: in full screen the tools cover the whole app, so the panel now carries the .isModal accessibility trait (as a contained element) and VoiceOver ignores everything behind it. The floating PiP panel stays non-modal, matching its visible, tap-through behavior — the app behind remains reachable.
Addresses PR review: when the presentation changes the overlay now posts a UIAccessibility notification so VoiceOver re-scans. Crossing the full-screen boundary (where the modal flips) posts .screenChanged to move focus into/out of the modal; the non-modal floating open/close posts the lighter .layoutChanged. Driven view-side from .onChange(of: model.presentation), per discussion.
The root GeometryReader respects the safe area, so proxy.size is already the safe-area region and its bottom edge is the top of the home indicator. anchorPoint was additionally subtracting proxy.safeAreaInsets, double-counting the home-indicator inset (and the top/leading/trailing insets) — so the button rested about a tab bar's height too high. Position purely within proxy.size, offsetting the bottom corners only by the measured tabBarInset.
…e only) Drops the filled indigo disc for an outlined ring around a plain wrench glyph with no background, so it stays out of the way over the app. A faint shadow keeps it legible on varied backgrounds and contentShape keeps the whole disc tappable despite the empty center.
| nonisolated static func nearestCorner(to point: CGPoint, in size: CGSize) -> Corner { | ||
| let isLeading = point.x < size.width / 2 | ||
| let isTop = point.y < size.height / 2 | ||
| switch (isTop, isLeading) { |
There was a problem hiding this comment.
Can we refactor this to be a real enum of positions instead?
Summary
Moves the DEBUG-only Developer tools out of the Settings tab into their own global, floating developer surface so they're reachable from anywhere in the app — including logged out.
DeveloperToolsViewthat owns its ownNavigationStack.DeveloperOverlayattached once atRootView, above every launch phase and tab:DeveloperOverlayButton) so the look can be swapped later without touching the drag/presentation logic. It's understated: an outlined ring around a wrench glyph, no filled background, with a faint shadow so it stays legible over light, dark, and busy backgrounds.developerSection(and now-unusedLogViewerUI/RegionKit/SwiftDataInspectorimports) fromSettingsView.WhereSessionexists (so it's absent before login / in non-SwiftData fakes).Everything new is
#if DEBUGand compiled out of release entirely, matching the previous behavior.Implementation notes
DeveloperOverlayModel.Presentationenum (collapsed/floating/fullScreen) rather than parallelBools, so illegal combinations can't be spelled. Corner snapping is a pure, unit-tested static function.settings.debug.*strings todeveloper.*and adds overlay chrome strings (button label, close, expand, collapse).Review follow-ups
Addresses the four inline review comments, one commit each:
DeveloperOverlayButtonand (asbuttonDiameter) inDeveloperOverlay. The button now owns a single@ScaledMetricdiameter (scales with Dynamic Type) and the overlay measures the rendered size viaonGeometryChangefor its anchor/drag math instead of duplicating the constant.64ptguess.MainTabsnow reports each tab's content bottom inset minus the window inset (the real bar height) via a preference;RootViewthreads it to the sibling overlay, which offsets the bottom corners by it. Logged out there's no tab bar, so it falls back to the safe area..isModaltrait (as a contained element) so VoiceOver ignores everything behind it. The floating PiP panel stays non-modal, matching its visible, tap-through behavior.onChange(of: model.presentation)posts aUIAccessibilitynotification:.screenChangedwhen crossing the full-screen (modal) boundary to move focus into/out of the modal, and the lighter.layoutChangedfor the non-modal floating open/close.Plus two tweaks from testing on-device:
proxy.size(which is already the safe-area region), so the resting button sat ~a tab bar's height too high; it now positions withinproxy.sizeand offsets only by the measured tab-bar height.Tests
DeveloperOverlayModelTestscover the presentation flow and corner-snapping quadrants.DeveloperTabBarInsetTestscover the preference reduce and host a realTabViewto prove the reporter measures the floating tab bar (content inset − window inset > 0), not just the home indicator.ScreenHostingTestsnow hostsDeveloperToolsViewandDeveloperOverlay.StringsTestsupdated for the renamed/added strings.#Preview.Verification
./swiftformat --lint— clean.tuist test WhereUITestson iPhone 17 / iOS 26.2 — all suites pass.