@@ -239,13 +239,10 @@ function CanvasLoadingBrand({ appName }) {
239239const AppCanvas = forwardRef ( function AppCanvas ( {
240240 appId, version = 0 , appName, appSlug, offlineCapable = false ,
241241 capabilityContract = null ,
242- immersive = false ,
243- // The lighter "look like a standalone app" collapse (mode:'bar'): the shell
244- // hides its toolbar but keeps the status-bar strip, so unlike `immersive`
245- // (full-bleed) the app is NOT painted under the notch and needs no safe-area
246- // insets. It still counts as "chrome hidden" for the app's immersive helper
247- // echo, so the app's toggle()/`.hidden` stay correct.
248- barCollapsed = false ,
242+ // The shell's applied presentation for this app: full-bleed immersive,
243+ // status-bar-preserving chrome collapse, or null. One value keeps safe-area
244+ // forwarding and the runtime echo from observing contradictory booleans.
245+ immersiveMode = null ,
249246 // Whether this app is the currently-visible canvas (canvas view + active
250247 // app). One prop, two consumers:
251248 // - `moebius:frame-visibility` — the shell keeps recently-used apps
@@ -422,8 +419,8 @@ const AppCanvas = forwardRef(function AppCanvas({
422419 // listener is live and it can receive frame-init/theme/insets. Per frame,
423420 // because the two buffered frames finish loading independently.
424421 const loadedDocsRef = useRef ( new Set ( ) )
425- // version -> last immersive intent (bool ) that frame declared. Recorded for
426- // EVERY frame, including a hidden incoming one whose real-time immersive post
422+ // version -> last immersive request ({ value, mode } ) that frame declared.
423+ // Recorded for every frame, including a hidden incoming one whose real-time post
427424 // is withheld (only the visible frame drives chrome live). On a swap we replay
428425 // the promoted frame's recorded intent so an immersive game stays immersive
429426 // across a rebuild without a chrome flash.
@@ -735,7 +732,7 @@ const AppCanvas = forwardRef(function AppCanvas({
735732 // from the verified source, never the payload — but the mode is a
736733 // harmless presentation hint the frame may choose.
737734 const mode = msg . mode === 'bar' ? 'bar' : 'full'
738- frameImmersiveRef . current . set ( srcVersion , value )
735+ frameImmersiveRef . current . set ( srcVersion , { value, mode } )
739736 if ( srcVersion === liveVersionRef . current && activeRef . current ) {
740737 onImmersive ?. ( appId , value , mode )
741738 }
@@ -986,13 +983,14 @@ const AppCanvas = forwardRef(function AppCanvas({
986983 useEffect ( ( ) => {
987984 if ( ! appId ) return
988985 const current = { appId, liveVersion : swap . liveVersion , active }
986+ const intent = frameImmersiveRef . current . get ( swap . liveVersion )
989987 const value = immersiveLifecycleValue (
990988 immersiveLifecycleRef . current ,
991989 current ,
992- frameImmersiveRef . current . get ( swap . liveVersion ) ,
990+ intent ?. value ,
993991 )
994992 immersiveLifecycleRef . current = current
995- if ( value !== null ) onImmersive ?. ( appId , value )
993+ if ( value !== null ) onImmersive ?. ( appId , value , intent ?. mode )
996994 } , [ appId , swap . liveVersion , active , onImmersive ] )
997995
998996 // Unmount/eviction is a hard release even when no active-state render landed.
@@ -1066,9 +1064,9 @@ const AppCanvas = forwardRef(function AppCanvas({
10661064 // pad away from the notch/home-indicator. env(safe-area-inset-*) reads 0
10671065 // inside the sandboxed iframe, so the shell reads the real values off a
10681066 // probe element and posts them; the frame applies them to :root as
1069- // --mobius-safe-*. Only non-zero while THIS app is immersive — a windowed
1070- // app's chrome already owns the inset padding, so it must receive zeros and
1071- // not double-pad. Sent on iframe load (sendInsets in onLoad), whenever the
1067+ // --mobius-safe-*. Only non-zero in full-bleed mode. Windowed and
1068+ // bar-collapsed apps keep shell-owned inset padding and receive zeros, so they
1069+ // cannot double-pad. Sent on iframe load (sendInsets in onLoad), whenever the
10721070 // immersive verdict flips, and on resize/orientationchange while immersive
10731071 // (a rotation moves the cutout, so the cached insets would otherwise go
10741072 // stale — see the geometry-change effect below).
@@ -1077,22 +1075,23 @@ const AppCanvas = forwardRef(function AppCanvas({
10771075 // windowed frame whose chrome already owns the inset padding also gets zeros
10781076 // so it can't double-pad.
10791077 function sendInsets ( v ) {
1080- const insets = ( v === liveVersionRef . current && immersive ) ? readDeviceInsets ( ) : zeroInsets ( )
1078+ const insets = ( v === liveVersionRef . current && immersiveMode === 'full' )
1079+ ? readDeviceInsets ( )
1080+ : zeroInsets ( )
10811081 postToFrame ( v , { type : 'moebius:frame-insets' , insets } )
10821082 }
10831083
10841084 // Echo the shell's APPLIED immersive verdict for this app so the runtime's
10851085 // window.mobius.immersive helper stays authoritative — toggle()/`.hidden`
10861086 // must reflect a release the shell made on its own (the floating exit button,
10871087 // or an app switch that dropped the lease), not just the app's last request.
1088- // The `immersive` prop is exactly that applied verdict (active canvas AND this
1089- // app holds the lease). Only the live frame is the immersive holder, so a
1088+ // `immersiveMode` is exactly that applied verdict (active canvas AND this app
1089+ // holds the lease). Only the live frame is the immersive holder, so a
10901090 // hidden/incoming buffered frame always learns `false`.
10911091 function sendImmersiveState ( v ) {
10921092 postToFrame ( v , {
10931093 type : 'moebius:immersive-state' ,
1094- // Either flavor of hidden bar counts as "hidden" for the app helper.
1095- value : v === liveVersionRef . current ? ( immersive || barCollapsed ) : false ,
1094+ value : v === liveVersionRef . current && immersiveMode != null ,
10961095 } )
10971096 }
10981097
@@ -1151,7 +1150,7 @@ const AppCanvas = forwardRef(function AppCanvas({
11511150 if ( loadedDocsRef . current . has ( v ) ) sendInsets ( v )
11521151 }
11531152 // eslint-disable-next-line react-hooks/exhaustive-deps
1154- } , [ immersive , swap . liveVersion ] )
1153+ } , [ immersiveMode , swap . liveVersion ] )
11551154
11561155 // Keep the app's window.mobius.immersive helper in sync with the shell's
11571156 // applied verdict (see sendImmersiveState). Same triggers as the insets echo.
@@ -1160,7 +1159,7 @@ const AppCanvas = forwardRef(function AppCanvas({
11601159 if ( loadedDocsRef . current . has ( v ) ) sendImmersiveState ( v )
11611160 }
11621161 // eslint-disable-next-line react-hooks/exhaustive-deps
1163- } , [ immersive , barCollapsed , swap . liveVersion ] )
1162+ } , [ immersiveMode , swap . liveVersion ] )
11641163
11651164 // Re-forward insets on viewport geometry change WHILE immersive. Rotating the
11661165 // device or a window resize moves the notch/home-indicator (landscape puts
@@ -1171,7 +1170,7 @@ const AppCanvas = forwardRef(function AppCanvas({
11711170 // listener is only attached while immersive (and torn down on exit). The
11721171 // probe element resolves the fresh env() values after layout settles.
11731172 useEffect ( ( ) => {
1174- if ( ! immersive ) return
1173+ if ( immersiveMode !== 'full' ) return
11751174 function onGeometryChange ( ) { sendInsets ( liveVersionRef . current ) }
11761175 window . addEventListener ( 'resize' , onGeometryChange )
11771176 window . addEventListener ( 'orientationchange' , onGeometryChange )
@@ -1180,7 +1179,7 @@ const AppCanvas = forwardRef(function AppCanvas({
11801179 window . removeEventListener ( 'orientationchange' , onGeometryChange )
11811180 }
11821181 // eslint-disable-next-line react-hooks/exhaustive-deps
1183- } , [ immersive ] )
1182+ } , [ immersiveMode ] )
11841183
11851184 if ( ! appId ) {
11861185 return (
0 commit comments