Fullscreen cover (cover) & the system-gesture shield

A cover presents a Day subtree over the whole window (edge-to-edge, above every other surface): the SwiftUI fullScreenCover(item:) shape. Like nav and nav_stack (docs/navigation.md), it is a projection of an app-owned signal, not an imperative controller:

let open = Signal::new(None::<Section>);          // Section: any Route type
zstack((
    home_page(open),
    cover(open, |section| game_page(*section))
        .background(|section| section.surface_color()),
))
// present:  open.set(Some(Section::Breakout));
// dismiss:  open.set(None);                       // e.g. from an in-content close button
  • Some(r) builds build(&r) under the cover and presents it (slide-up where the platform animates modals). None dismisses it. Switching directly from Some(a) to Some(b) swaps the content and re-presents.
  • .background(f) paints the surface color edge-to-edge (under the status bar and home indicator) while the content itself is laid out inside the safe area. Without it the platform’s default surface color shows in the unsafe regions.
  • The builder runs inside the presented content’s scope: state it restores, signals it creates, and cleanups it registers (e.g. a save-on-exit) live exactly as long as the presentation.
  • The content is disposed only after the backend reports the hide transition finished (Event::CoverHidden on the cover node), so the surface never blanks mid-animation. Scope cleanups (the natural save-on-exit hook) run at that moment.

The CoverHidden delivery contract

App teardown hangs off this one event, so its delivery is a hard guarantee, not a best-effort animation callback:

  • Backends must deliver it after every dismissal, even when the platform loses the animation completion (UIKit drops transition completions under scripted bursts; Android cancels withEndAction when an animator is superseded). Both backends pair the normal completion with a delayed backstop that reports once the surface has verifiably left the screen.
  • The piece treats it as idempotent and orderable: duplicates are no-ops, and a belated report from a previous dismissal cannot dispose content presented since (the closing gate). Backends may therefore over-report freely rather than risk under-reporting.
  • The mock-toolkit e2e (cover_cycle_keeps_siblings_alive_and_represents) pins present → dismiss → re-present across double and late reports.

The shield modifiers

Two Decorate modifiers protect a fullscreen interaction (a game, a drawing canvas) from accidental exits. Both are registration-scoped: they apply while their subtree is mounted and lift when it unmounts.

game_page()
    .defers_system_gestures(Edges::ALL)   // SwiftUI defersSystemGestures(on:)
    .interactive_dismiss_disabled()       // SwiftUI interactiveDismissDisabled()
  • defers_system_gestures(edges) asks the OS to require a second swipe for its edge gestures on the given Edges (TOP/BOTTOM/LEADING/TRAILING/ALL). iOS defers the chosen screen edges (preferredScreenEdgesDeferringSystemGestures on the root and cover view controllers); Android enters swipe-to-reveal immersive mode while any request is live; desktop backends no-op. day-core keeps the union of all mounted requests and re-sends it through the Toolkit::defer_system_gestures duty on every change.
  • interactive_dismiss_disabled() blocks the user-initiated dismissal of the enclosing cover: Android’s system back stops closing it (iOS sets isModalInPresentation, inert under the fullscreen style). Programmatic writes (open.set(None), dayscript nav_back) still close it; ship an explicit close control. The state is queryable via day_core::shield::dismiss_disabled() (reactive: reads track a change counter).

A mounted cover registers a string-route adapter (docs/navigation.md): navigate("<key>") presents the parsed route, nav_back() dismisses, and the presented key is the cover’s contribution to current_route(). Day-Games’ walkthrough drives games with plain - navigate: { route: breakout } / - nav_back: steps.

.unrouted(): a cover that belongs to a control

cover(open, build).unrouted() skips that registration: navigate("<key>") does not reach it, it contributes nothing to current_route(), and nav_back() walks past it. Interactive dismissal is unaffected; Android’s system back arrives as Event::NavBack on the cover’s own node, which never went through the adapter.

Reach for it whenever the cover belongs to a control rather than to the app: a color picker’s chooser (docs/colorpicker.md), a scrubber’s fullscreen mode. Two things go wrong otherwise.

The sharper problem is that a routed cover claims route segments through R::from_key, and the untyped route is String, whose from_key accepts anything. So a routed cover(Signal<Option<String>>, …) claims every segment: mount one, and the app’s next navigate("settings") presents that cover keyed "settings" instead of going to settings. A piece that mounts a cover would be silently rewriting its host app’s navigation, which is what happened to the showcase’s walkthrough the first time the color picker’s panel went in. A typed Route enum does not have this problem, because its from_key rejects what is not its own; .unrouted() is the fix when the key is a string, and the right answer regardless when the surface is not a destination.

The quieter problem is that current_route() naming a transient chooser makes a restored session reopen it, and a “link me to this screen” URL point at a modal.

How it works

  • kinds::COVER is realized detached from the visible hierarchy (its set_frame is a toolkit no-op: the frame is native-owned). CoverPatch::Present { background, dismiss_disabled } shows it; CoverPatch::Dismiss hides it; CoverPatch:: DismissDisabled tracks the shield while presented.
  • Layout follows the nav-page contract: the backend reports the content container’s safe-area size via Event::FrameChanged, and CoverLayout (day-core) lays the children out at that size. In the tree the cover node measures 0×0, so it never disturbs the layout it sits in.
  • A native dismissal request (Android back) arrives as Event::NavBack on the cover node; the piece writes None into the signal unless dismissal is disabled, the same origin-tagged write-back discipline as every control.
  • A presented cover is a touch boundary, including blank content and safe-area margins. Android’s DayCover.onTouchEvent consumes events that descendants do not handle, after normal child dispatch. Background opacity alone does not stop taps reaching the page below; apps do not need dummy gesture handlers on headers or spacers. Hiding the cover restores interaction with the underlying page.

Per-toolkit realization

ToolkitPresentDismiss requestHidden report
uikitDayCoverVC (fullscreen modal) over a DayNavPageView, through the dialog FIFOnone (fullscreen has no sheet gesture)dismiss completion block
androidDayCover shell re-homed onto the activity content root, slide-up ValueAnimatorOnBackPressedCallbackNavBackslide-out completion, shell becomes GONE
arkuiStack re-homed onto the window root at full bounds (no transition)noneposted immediately on dismiss
mockpatch recorded (flag = presented)tests emit ittests emit it
appkit / gtk / qt / xaml / domtopmost full-window child of the window content, opaque theme background by default (Cap::Cover = Emulated, no transition)noneposted immediately on dismiss