Skip to content

Move developer tools into a global floating overlay#70

Merged
kyleve merged 8 commits into
mainfrom
cursor/developer-overlay-6990
Jul 8, 2026
Merged

Move developer tools into a global floating overlay#70
kyleve merged 8 commits into
mainfrom
cursor/developer-overlay-6990

Conversation

@kyleve

@kyleve kyleve commented Jul 8, 2026

Copy link
Copy Markdown
Owner

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.

  • Extracts the three tools (Logs, SwiftData Inspector, Region map) into a standalone DeveloperToolsView that owns its own NavigationStack.
  • Adds a new DeveloperOverlay attached once at RootView, above every launch phase and tab:
    • Collapsed → a small draggable button that snaps to the nearest corner.
    • Tap → expands into a Picture-in-Picture floating panel (the app stays interactive behind it).
    • Expand → grows to full screen and shrinks back, keeping the tools' navigation state across the resize (same view identity, only the frame changes).
  • The collapsed button is its own view type (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.
  • Removes the developerSection (and now-unused LogViewerUI / RegionKit / SwiftDataInspector imports) from SettingsView.
  • The SwiftData inspector row hides until a live WhereSession exists (so it's absent before login / in non-SwiftData fakes).

Everything new is #if DEBUG and compiled out of release entirely, matching the previous behavior.

Implementation notes

  • State is modeled as a single DeveloperOverlayModel.Presentation enum (collapsed / floating / fullScreen) rather than parallel Bools, so illegal combinations can't be spelled. Corner snapping is a pure, unit-tested static function.
  • Renames the settings.debug.* strings to developer.* and adds overlay chrome strings (button label, close, expand, collapse).

Review follow-ups

Addresses the four inline review comments, one commit each:

  • Dedupe the button size — 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.
  • Derive the tab-bar clearance from the live UI — the resting button's bottom clearance was a hardcoded 64pt guess. MainTabs now reports each tab's content bottom inset minus the window inset (the real bar height) via a preference; RootView threads 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.
  • Full screen is an accessibility modal — in full screen the panel carries the .isModal trait (as a contained element) so VoiceOver ignores everything behind it. The floating PiP panel stays non-modal, matching its visible, tap-through behavior.
  • Re-poll VoiceOver on presentation changeonChange(of: model.presentation) posts a UIAccessibility notification: .screenChanged when crossing the full-screen (modal) boundary to move focus into/out of the modal, and the lighter .layoutChanged for the non-modal floating open/close.

Plus two tweaks from testing on-device:

  • Outline-only button — dropped the conspicuous filled indigo disc for the understated outline described above.
  • Fixed a double bottom inset — the anchor math was subtracting the safe-area insets on top of proxy.size (which is already the safe-area region), so the resting button sat ~a tab bar's height too high; it now positions within proxy.size and offsets only by the measured tab-bar height.

Tests

  • New DeveloperOverlayModelTests cover the presentation flow and corner-snapping quadrants.
  • New DeveloperTabBarInsetTests cover the preference reduce and host a real TabView to prove the reporter measures the floating tab bar (content inset − window inset > 0), not just the home indicator.
  • ScreenHostingTests now hosts DeveloperToolsView and DeveloperOverlay.
  • StringsTests updated for the renamed/added strings.
  • Each new SwiftUI view ships a #Preview.

Verification

  • ./swiftformat --lint — clean.
  • tuist test WhereUITests on iPhone 17 / iOS 26.2 — all suites pass.
Open in Web Open in Cursor 

kyleve added 2 commits July 8, 2026 15:28
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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This of all the numbers is a smell, can we derive this somehow from the actual UI?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full screen need to be done in a way that ensure accessibility works; the view needs to become accessibility modal.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate 52 from above, put into a constant or remove the need for it entirely (derive from an intrinsic layout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we address the accessibility changes above we'll need this to ensure we re poll a11y

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

kyleve added 4 commits July 8, 2026 16:22
…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.
@kyleve kyleve marked this pull request as ready for review July 8, 2026 20:51
kyleve added 2 commits July 8, 2026 16:59
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) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we refactor this to be a real enum of positions instead?

@kyleve kyleve merged commit 348bdd6 into main Jul 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant