diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 4d7629cd9a..1ccfa79b77 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -13,7 +13,7 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; - if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || + if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || @@ -25,7 +25,7 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' + const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT @@ -109,7 +109,10 @@ export default class EntryAbility extends UIAbility { /** * The immersive shell is not verifiable from a screenshot alone: whether the * window covers the status bar and how tall the bottom strip is are device - * facts, so state them once per launch. + * facts, so state them once per launch. The camera cutout is stated in full — + * visibility and all four rectangles — because it is the one avoid area that + * moves to a different edge when the device rotates, and a device without a + * cutout reports zeroes here rather than staying silent about it. */ private logWindowInsets(mainWindow: window.Window): void { try { @@ -119,11 +122,37 @@ export default class EntryAbility extends UIAbility { 'Window insets statusBar=%{public}s navigation=%{public}s windowRect=%{public}s', String(system.topRect.height), String(navigation.bottomRect.height), JSON.stringify(mainWindow.getWindowProperties().windowRect)); + const cutout = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_CUTOUT); + hilog.info(DOMAIN, TAG, + 'Window cutout visible=%{public}s top=%{public}s bottom=%{public}s left=%{public}s right=%{public}s', + String(cutout.visible), String(cutout.topRect.height), String(cutout.bottomRect.height), + String(cutout.leftRect.width), String(cutout.rightRect.width)); } catch (err) { hilog.error(DOMAIN, TAG, 'Failed to read window insets. Cause: %{public}s', JSON.stringify(err)); } } + /** + * The content colour of both system bars, for the whole window and the whole + * app. + * + * This is one value for one window, and it is derived from one thing only: the + * colour mode. There is no per-page override here, so any page whose top band + * is dark — a media viewer, an immersive title, a hero the design decides to + * paint dark — puts the light-mode ink on a dark band and the clock and the + * status icons stop being readable against it. Every top band the app ships + * today is a theme-following page colour (`PAGE_BG`, `SIDEBAR_BG`, + * `PAGE_BG_OVERLAY`, `WELCOME_PAGE_BG`, `CARD`), which is why the single value + * is correct as it stands; `immersive-bottom.test.cjs` guards that list as a + * tripwire. + * + * So: a new page that paints its own dark top band is not finished here. It has + * to come back to this method — either by declaring its band against the + * colour mode the way the others do, or by giving the app a per-page override + * (the platform's `HdsNavigation.systemBarStyle` is the page-level route, and + * the platform also warns against mixing it with the window-level calls used + * here). Either way the band and the content colour are one decision, not two. + */ private updateSystemBars(colorMode?: ConfigurationConstant.ColorMode): void { if (!this.mainWindow) { return; @@ -132,16 +161,14 @@ export default class EntryAbility extends UIAbility { const content = dark ? MobileDesignColors.ink.dark : MobileDesignColors.ink.light; try { this.mainWindow.setWindowSystemBarProperties({ - // Both bars are transparent, because the window is laid out full-screen - // and the strips they occupy are the page's own edges: the drawer's fill - // and the chat page's blurred header band are what should show there. - // An opaque status bar painted the page colour over both, which clipped - // the sidebar's title, and it also sat over the pull-down notification - // centre's frosted background as a white strip instead of blending into - // it. `statusBarContentColor` is what keeps the clock and the icons - // legible against whatever the page puts behind them. - statusBarColor: MobileDesignColors.transparent.light, - navigationBarColor: MobileDesignColors.transparent.light, + // The window is laid out full-screen above (`setWindowLayoutFullScreen(true)`), + // so it already runs in the immersive layout, and the status bar and the + // navigation area are transparent by platform default: both strips carry + // the page's own background through. The page therefore owns what shows + // there — the drawer's fill and the chat page's blurred header band — and + // any background colour set here would repaint the page's edge instead. + // The content colours are the only thing left to state: they keep the + // clock and the icons legible against whatever the page puts behind them. statusBarContentColor: content, navigationBarContentColor: content }); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets index c0436728b0..40e16ac111 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -58,7 +58,17 @@ export struct AppShell { .width('100%') .height('100%') .backgroundColor(SIDEBAR_BG) - .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) + // The cutout is an avoid area of its own, so the floor claims it too: a + // notch the framework would otherwise leave bare has to end up under the + // same fill the rest of the window is. The floor is the window itself — + // full width and full height — so it names all four edges. + // `expandSafeArea` is per edge and takes effect only where this + // component's boundary meets that edge's safe-area boundary, so claiming + // the top strip with `TOP` says nothing about the side strip a rotated + // cutout lands on: without `START`/`END` the fill stops at the inset and + // the page colour shows beside it. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], + [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]) .hitTestBehavior(HitTestMode.None) .opacity((this.allowSidebar && this.shellState.showSidebar) ? 1 : 0) // Matched to the content card rather than to the sidebar panel: the @@ -148,7 +158,13 @@ export struct AppShell { private CompactLoginCover() { Stack({ alignContent: Alignment.Bottom }) { Column().width('100%').height('100%').backgroundColor(SCRIM) - .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) + // The scrim is the cover's own background over the whole shell, so a bare + // notch would read as a hole in the dimming: it claims the cutout too. + // Like the floor it is the whole shell, so it names the same four edges: + // a rotated cutout sits on a side edge, and the scrim has to reach it or + // the dimming has a hole in it. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], + [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]) .onClick(() => { this.shellState.showSettings = false; }) Scroll() { Column() { this.settings() } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index 911d90b97c..cf87d8971d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -163,7 +163,12 @@ export struct AppSidebar { // row is what has to keep clear of the status bar: the fill still reaches // the top of the window, and the title, the search field and the session // rows start below the strip. - .padding({ left: 20, right: 20, top: this.insets.top }) + // + // Its side edges are the screen's too, and a rotated device moves the camera + // cutout onto one of them: the wider of the design's gutter and the cutout + // is what the panel reserves, so the 20 the design asks for survives on a + // device that claims no side strip at all. + .padding({ left: this.insets.leftPadding(20), right: this.insets.rightPadding(20), top: this.insets.top }) .backgroundColor(SIDEBAR_BG) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 7df81a5de3..08b6d7917c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -184,10 +184,26 @@ export struct ConversationView { .width('100%') // Carried by the band that owns the background, not by the wrapper: the // band is what has to reach the status bar, and its measured height is - // what the transcript borrows as its content start offset. - .padding({ top: this.topSafeInset }) + // what the transcript borrows as its content start offset. Its sides are + // the window's wherever this page *is* the window — the compact layout, + // and the wide compositions that put it flush against an edge — so it + // reserves the side strips as well; the header's own gutter lives inside + // it and is not restated here. + // + // In the usual wide layout this page is the detail pane instead, and then + // only its right edge is the window's: the left one is the master pane's. + // A layout dump of that posture reads the left strip as a 21.14vp indent + // on a band that never touches that edge (see the note on `FlowPlaceholder` + // in RemoteSurfaceHost — the padding model cannot drop one side per edge, + // and dropping both would put the compact header back under the cutout). + .padding({ left: this.insets.leftPadding(0), right: this.insets.rightPadding(0), top: this.topSafeInset }) .backgroundColor(PAGE_BG_OVERLAY) .backgroundBlurStyle(BlurStyle.Thin) + // Named so a layout dump can be read against the pixels, the way the + // bottom fade is: this band's own box is what says where the page's top + // edge sits, and the side padding it carries is only visible in a dump as + // an offset between the box and its first child. + .id('conversation-top-band') this.TopEdgeFade() } .width('100%') @@ -206,8 +222,19 @@ export struct ConversationView { // The composer keeps the distance from the screen edge it had while the page // area ended above the navigation bar, and the layer's own box grows by that // strip: the gradient below is the layer's background, so it fills the strip - // and the transcript behind it reaches the bottom of the screen. - .padding({ bottom: this.bottomSafeInset }) + // and the transcript behind it reaches the bottom of the screen. Wherever the + // layer's sides are the window's — the compact layout, and the wide + // compositions that put the page flush against an edge — they are the + // layer's own business as well: the composer's gutter is inside it, and on a + // rotated device the camera cutout would otherwise sit over the first control + // of the row. In the usual wide layout the layer is the detail pane's width + // instead, so only its right edge is the window's and the left strip is an + // indent nothing needs; see the note on `TopOverlay` above. + .padding({ + left: this.insets.leftPadding(0), + right: this.insets.rightPadding(0), + bottom: this.bottomSafeInset + }) // The fade covers the whole layer, not just a strip above it: the transcript // runs behind the composer, so anything short of that leaves a line of text // sitting crisp and legible beside the pill after it has already faded out @@ -221,8 +248,19 @@ export struct ConversationView { // into it. Where the immersive layout already puts the page area there the // two overlap and nothing extra is drawn; on a window that still ends above // the navigation bar this is what keeps the strip from being bare page - // colour under the fade. - .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) + // colour under the fade. The cutout is a separate avoid area, so the fade + // claims it as well: a device that rotated its camera onto an edge the fade + // reaches would otherwise show the page colour beside the gradient. Both of + // its sides are named because `expandSafeArea` is per edge and only takes + // effect where this box's own boundary meets that edge's safe-area + // boundary: a side the compact layer owns expands, and the same claim on the + // detail pane's left side — where the master pane is in between — draws + // nothing. `TOP` is deliberately absent: this layer is only as tall as the + // status line and the composer and sits at the bottom of the pane, so its + // top edge is nowhere near the window's top avoid area and naming it would + // claim a strip the fade never touches. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], + [SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]) // Named so a layout dump can be read against the pixels: the fade's own box // is what says where the chat page's bottom edge sits and how far the // transcript can scroll, and neither is visible in the composer's bounds. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets index d225c3d762..88eb84c805 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets @@ -180,8 +180,15 @@ export struct FilePreviewSurface { // The header band is the surface's top edge in both hosts — the compact // full-page overlay and the wide preview pane — so it carries the status bar // strip itself: the band's fill still reaches the top of the window and the - // back control and title sit below the clock and the indicators. - .padding({ left: 8, right: 8, top: 8 + this.insets.top, bottom: 8 }) + // back control and title sit below the clock and the indicators. It spans + // the window horizontally, so it carries the side strips the same way: on a + // rotated device the back control is what the camera cutout would cover. + .padding({ + left: this.insets.leftPadding(8), + right: this.insets.rightPadding(8), + top: 8 + this.insets.top, + bottom: 8 + }) .alignItems(VerticalAlign.Center) .border({ width: { bottom: 1 }, color: LINE }) .backgroundColor(PAGE_BG) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets index 945a403141..65dc7d9f44 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets @@ -30,7 +30,9 @@ export struct MiniAppSurface { // The gallery is a full-screen surface, so its header band owns the top // edge of the window: the box grows by the status bar strip and the // controls keep the 56 the design gives them below it, instead of sitting - // under the clock and the indicators. + // under the clock and the indicators. The band spans the window + // horizontally, so it reserves the side strips too — a rotated device puts + // the camera cutout on the back control's edge. Row({ space: 8 }) { NavigationBackButton({ onBack: (): void => { if (this.state.appId) this.actions.back(); @@ -41,7 +43,8 @@ export struct MiniAppSurface { .fontColor(INK).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center).layoutWeight(1) Blank().width(48) - }.width('100%').height(56 + this.insets.top).padding({ left: 12, right: 12, top: this.insets.top }) + }.width('100%').height(56 + this.insets.top) + .padding({ left: this.insets.leftPadding(12), right: this.insets.rightPadding(12), top: this.insets.top }) if (this.state.document) { MiniAppWebView({ document: this.state.document, revision: this.state.revision, request: this.actions.request }) .layoutWeight(1) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets index 61fa36977f..7013483ff3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets @@ -1,7 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MobileDesignTypography, MobileDesignGeometry as G } from '../../generated/MobileDesignTokens'; import { MotionPreference } from '../../services/MotionPreference'; -import { WindowInsetsBinding, WindowSystemBarService } from '../../services/WindowSystemBarService'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { WelcomeBrandFlow } from './WelcomeBrandFlow'; import { INK, PAGE_BG, WELCOME_PAGE_BG, MUTED, TRANSPARENT } from './Theme'; @@ -22,17 +22,20 @@ export struct WelcomeHome { @Local elapsed: number = 0; @Local pageHeight: number = 700; @Local pageWidth: number = 390; - @Local topInset: number = 0; // The dock paints into the strip (expandSafeArea carries its background to // the screen edge), so its buttons read the strip: the design's own clearance // where it is already taller than the indicator, the indicator's height // where it is not. + // + // The page's top strip comes from the same binding rather than a read of its + // own. It used to ask the service for the status bar height once, at mount, + // which left the header reserving the strip a landscape rotation had already + // moved the camera cutout onto, and left the value stale after the rotation. @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); private timer: number = -1; private started: number = 0; aboutToAppear(): void { - this.readTopInset(); const context = this.getUIContext().getHostContext(); if (context) { this.insets.bind(this.getUIContext(), context); @@ -44,16 +47,6 @@ export struct WelcomeHome { this.insets.unbind(); } - private readTopInset(): void { - const context = this.getUIContext().getHostContext(); - if (!context) { - return; - } - void WindowSystemBarService.topSystemInsetPx(context).then((topInsetPx: number): void => { - this.topInset = this.getUIContext().px2vp(topInsetPx); - }); - } - private stop(): void { if (this.timer >= 0) clearInterval(this.timer); this.timer = -1; } private play(): void { this.stop(); @@ -124,11 +117,11 @@ export struct WelcomeHome { .padding({ bottom: 24 }) this.Actions() }.width('100%').height(Math.max(0, - this.pageHeight - this.topInset - G.welcomeHeaderHeight)) + this.pageHeight - this.insets.top - G.welcomeHeaderHeight)) } }.width('100%') }.width('100%').height('100%').scrollBar(BarState.Off).backgroundColor(WELCOME_PAGE_BG) - .padding({ top: this.topInset }) + .padding({ top: this.insets.top }) .onSizeChange((_old: SizeOptions, size: SizeOptions) => { this.pageHeight = Number(size.height); this.pageWidth = Number(size.width); @@ -167,7 +160,23 @@ export struct WelcomeHome { .padding({ left: G.welcomeGutter, right: G.welcomeGutter, top: this.wide() ? 0 : G.welcomeGutter, bottom: this.wide() ? 0 : this.insets.bottomPadding(G.welcomeDockBottom) }) .backgroundColor(this.wide() ? TRANSPARENT : $r('app.color.welcome_dock')) - .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) + // The dock's fill reaches the bottom edge of the screen. The cutout is a + // separate avoid area from the system bars, so it is claimed here too. + // + // The sides are named as well, and the claim is only ever load-bearing in + // one combination. The dock only has a fill of its own in the compact + // composition — `wide()` swaps it for a centred column capped at 400vp + // whose background is TRANSPARENT — but `wide()` is a logical-width test + // (`pageWidth >= 600`) and a side cutout is not: a landscape window below + // 600vp still puts the camera on a side edge, and split screen, a free + // window and a hover posture all produce one. In that combination this + // column does sit on the window's left and right edges, so leaving the + // sides unnamed is the same page-colour sliver beside the fill that the + // shell's other three full-bleed backgrounds would show. Naming them in + // the wide composition costs nothing, because there the dock paints + // TRANSPARENT and an edge claim against a transparent fill draws nothing. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], + [SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]) .borderRadius({ topLeft: G.welcomeDockRadius, topRight: G.welcomeDockRadius }) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index e334010441..9f248a3ce8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -136,9 +136,11 @@ export struct WideConversationHost { if (this.wideMasterPaneCollapsed) { // The detail pane's top edge is the window's, so the floating control - // keeps the status bar strip above it instead of sitting under it. + // keeps the status bar strip above it instead of sitting under it — and + // its left edge is the window's too, which is the edge a rotated device + // moves the camera cutout onto. SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane }) - .position({ x: 12, y: 12 + this.insets.top }).zIndex(2) + .position({ x: this.insets.leftPadding(12), y: 12 + this.insets.top }).zIndex(2) .hitTestBehavior(HitTestMode.Default) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index dff71d963e..6ab4000728 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -237,7 +237,9 @@ export struct RemoteSurfaceHost { Column() { // The compact home is a page, not a sheet: its header band is the top edge // of the window, so the band reserves the status bar and the drawer - // control and title start below the clock and the indicators. + // control and title start below the clock and the indicators. The band is + // the window's full width, so it reserves the side strips as well — the + // drawer control is the control a landscape cutout would land on. ConversationHeader({ title: RemoteI18n.t('app.title'), subtitle: this.compactHeaderContext(), @@ -245,7 +247,11 @@ export struct RemoteSurfaceHost { showSidebarButton: true, onOpenSidebar: this.onOpenSidebar }) - .padding({ top: this.insets.top }) + .padding({ + left: this.insets.leftPadding(0), + right: this.insets.rightPadding(0), + top: this.insets.top + }) if (RemoteCompactHomePolicy.shouldShowConnectHome(this.remotePageState.connectionState)) { this.ConnectionEntry() } else { @@ -293,8 +299,32 @@ export struct RemoteSurfaceHost { // wider. The band's box grows by the status bar strip for the same reason // the compact header's does: the wide detail pane's top edge is the // window's top edge too. + // + // The sides are not stated the same way, because the wide surface has two + // parents and only one of them is the window. Mounted by `WideRouteContent` + // the band is the whole wide page and both of its side edges are the + // window's; mounted by `WideConversationHost.RemoteHomeDetail` it is the + // detail pane, and then only its right edge is the window's — its left one + // is the master pane's, so the left strip only indents the restore control + // instead of clearing a cutout. A layout dump of the master/detail posture + // reads exactly that: the band's own box sits at the pane's left edge + // (x=980 on a 789vp landscape window) while its content starts 72px further + // in, and the same band collapsed flush against the window's left edge does + // need that strip. Padding cannot be claimed per edge the way + // `expandSafeArea` can, so the strip is reserved on both sides and the + // over-reservation is a bounded indent rather than a missing clearance; + // reverting both sides would put the compact header and the flush-left wide + // composition back under the cutout this strip exists for. .width('100%').height(76 + this.insets.top) - .padding({ left: 16, right: 16, top: 14 + this.insets.top, bottom: 12 }) + .padding({ + left: this.insets.leftPadding(16), + right: this.insets.rightPadding(16), + top: 14 + this.insets.top, + bottom: 12 + }) + // Named so a layout dump can be read against the pixels: this band is the + // one whose box-vs-content offset is the whole question above. + .id('remote-wide-home-band') this.CompactEmptyState() diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets index ce67b41656..0066fb2a97 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets @@ -275,7 +275,7 @@ export struct ImmersiveBottomPreview { aboutToAppear(): void { const scenarioId = AppStorage.get('scenarioId') ?? ''; this.floating = scenarioId.endsWith('-wide') && !scenarioId.endsWith('-wide-shell'); - this.drawer = scenarioId.endsWith('-drawer') || scenarioId.startsWith('immersive-bottom-tools'); + this.drawer = scenarioId.includes('-drawer') || scenarioId.startsWith('immersive-bottom-tools'); this.settings = scenarioId.endsWith('-settings') || scenarioId.endsWith('-settings-dark'); this.cover = scenarioId.endsWith('-cover'); this.galleryVisible = scenarioId.endsWith('-miniapps'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets index ac9c0c684c..ab21f4d706 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -2,98 +2,275 @@ import { Context } from '@kit.AbilityKit'; import { UIContext, window } from '@kit.ArkUI'; import { RemoteLogger } from './RemoteLogger'; +/** + * The strip one avoid area claims along each edge of the window, in px. + */ +export interface AvoidEdges { + top: number; + bottom: number; + left: number; + right: number; +} + /** * How much of the window the app has to keep clear of its own content, in px. * - * Both numbers are read rather than assumed. The shell runs in immersive layout, - * so the page draws from the top of the window and the status bar is the app's - * own inset to reserve; the bottom strip is the navigation indicator, until the - * soft keyboard takes its place. + * Every number is read rather than assumed. The shell runs in immersive layout, + * so the page draws from the edges of the window: the status bar is the app's + * own top inset to reserve, the bottom strip is the navigation indicator until + * the soft keyboard takes its place, and the sides are the camera cutout once + * the device has rotated it onto one of them. */ export interface WindowInsets { top: number; bottom: number; + left: number; + right: number; +} + +/** The four edge widths one avoid area reports, in px. */ +export function avoidEdgesOf(area: window.AvoidArea): AvoidEdges { + return { + top: area.topRect.height, + bottom: area.bottomRect.height, + left: area.leftRect.width, + right: area.rightRect.width + }; +} + +/** + * Merges the avoid areas that share an edge into the strip that edge needs. + * + * More than one area can claim the same edge — the status bar and a landscape + * camera cutout both touch the top, the navigation indicator and a reverse + * cutout both touch the bottom — and they are overlapping regions of one window + * rather than strips stacked on top of each other, so an edge is the widest + * claim among them and never their sum. The official `handleCutoutAvoidArea` + * sample merges the same way, one `Math.max` per edge. + * + * Pure, four numbers in and four numbers out, so the merge is assertable + * without a window; `insetsOf` keeps only the reading and the keyboard rule. + */ +export function mergeAvoidEdges(areas: AvoidEdges[]): AvoidEdges { + let top = 0; + let bottom = 0; + let left = 0; + let right = 0; + for (const area of areas) { + top = Math.max(top, area.top); + bottom = Math.max(bottom, area.bottom); + left = Math.max(left, area.left); + right = Math.max(right, area.right); + } + return { top: top, bottom: bottom, left: left, right: right }; } /** One read of the insets, plus the listener that keeps them current. */ export type WindowInsetListener = (insets: WindowInsets) => void; export type WindowInsetSubscription = () => void; -/** Platform window adapter for surfaces that need to draw behind system bars. */ +/** + * Platform window adapter for surfaces that need to draw behind system bars. + * + * The shell's immersive layout itself is the ability's decision — `EntryAbility` + * lays the window out full-screen — and it is what makes the insets below the + * app's own to reserve rather than the framework's. + */ export class WindowSystemBarService { - /** - * Immersive layout for the whole shell, not one page: the transcript has to be - * able to reach the bottom edge of the screen, which only exists in the page - * area once the window stops reserving the navigation-bar strip. - * - * Immersive layout also moves the page's own origin to the top of the window, - * so every page that draws chrome against an edge reads the insets below - * instead of assuming the framework avoided the bars for it. - */ - static async setLayoutFullScreen(context: Context, enabled: boolean): Promise { - try { - const appWindow = await window.getLastWindow(context); - await appWindow.setWindowLayoutFullScreen(enabled); - } catch (_err) { - // Window layout is best effort and must not block page rendering. - } - } - - static async topSystemInsetPx(context: Context): Promise { - try { - const appWindow = await window.getLastWindow(context); - return appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height; - } catch (_err) { - return 0; - } - } - /** * Reads the insets once and re-reads them on every avoid-area change — folding, * rotation and the soft keyboard all move the avoid areas without recreating * the page, and a stale number either clips the composer or leaves the * transcript reserving space nothing is drawn in. * - * The returned function unregisters the listener. + * The returned function unregisters the listener. This is the app's one window + * read: every surface reaches it through `WindowInsetsBinding`, which shares a + * single subscription (`InsetsBroadcast`) instead of opening one per component. */ static async observeInsets(context: Context, onChange: WindowInsetListener): Promise { + // Swapped for the real release as soon as the window is in hand. Until then + // there is nothing on the window to take back, so the placeholder is what a + // failure before `on` registered ends up calling. + let detach: WindowInsetSubscription = (): void => {}; try { const appWindow = await window.getLastWindow(context); const read: () => void = (): void => { onChange(WindowSystemBarService.insetsOf(appWindow)); }; const onAvoidAreaChange: (data: window.AvoidAreaOptions) => void = (_data: window.AvoidAreaOptions): void => { read(); }; - const subscription: WindowInsetSubscription = (): void => { + // `listening` records whether `on` actually took. The release takes back + // exactly the listener this call put on and never one it did not: the + // failure path below runs after `on` may or may not have registered, and + // releasing blind would detach a callback that is not ours. + let listening = false; + detach = (): void => { + if (!listening) { + return; + } + listening = false; appWindow.off('avoidAreaChange', onAvoidAreaChange); }; appWindow.on('avoidAreaChange', onAvoidAreaChange); + listening = true; read(); - return subscription; - } catch (_err) { - onChange({ top: 0, bottom: 0 }); + return detach; + } catch (err) { + // A window that cannot hand over its avoid areas must not stop the page + // rendering, so this still falls back to zero insets — but it is not the + // silent no-op it used to be. The listener registered above is released + // first: leaving it on would keep the window alive past every unmount, and + // leaving the shared broadcast at zero would let every later avoid-area + // change throw inside the system's own callback with nobody watching, so + // the whole app would lay itself out against zero insets for the rest of + // the process with nothing in the log to say why. + detach(); + RemoteLogger.error( + `window insets read failed, falling back to zero insets: ${err instanceof Error ? err.message : String(err)}` + ); + onChange({ top: 0, bottom: 0, left: 0, right: 0 }); return (): void => {}; } } + private static avoidEdges(appWindow: window.Window, type: window.AvoidAreaType): AvoidEdges { + return avoidEdgesOf(appWindow.getWindowAvoidArea(type)); + } + private static insetsOf(appWindow: window.Window): WindowInsets { - const top = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height; + // The camera cutout is an avoid area of its own, and only because the module + // declares `avoid_cutout` (see module.json5). Rotating the device moves it + // from the top edge to a side one, which is why it is merged here with the + // system and navigation areas instead of being left to the top strip. + // + // `TYPE_SYSTEM_GESTURE` is deliberately not read here, and that is a decision + // rather than an oversight. It is the region a system return gesture can be + // started from; the official immersive-layout sample reads it beside the + // areas above. This app registers the omission as an open follow-up instead + // of merging it now: the bottom of that region is already covered by the + // navigation indicator here, and reserving its side part unconditionally + // would move every full-width band inward on a gesture-navigation device + // with no measured overlap to justify it. Merge it in the day a swiped edge + // is observed to swallow a control. + const merged = mergeAvoidEdges([ + WindowSystemBarService.avoidEdges(appWindow, window.AvoidAreaType.TYPE_SYSTEM), + WindowSystemBarService.avoidEdges(appWindow, window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR), + WindowSystemBarService.avoidEdges(appWindow, window.AvoidAreaType.TYPE_CUTOUT) + ]); // The keyboard covers the navigation indicator while it is up, and // `KeyboardAvoidMode.RESIZE` has already lifted the page above it, so the // bottom strip belongs to the keyboard for as long as it is visible. - const keyboard = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_KEYBOARD).bottomRect.height; - if (keyboard > 0) { - return { top, bottom: 0 }; + const keyboardVisible = + appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_KEYBOARD).bottomRect.height > 0; + return { + top: merged.top, + bottom: keyboardVisible ? 0 : merged.bottom, + left: merged.left, + right: merged.right + }; + } +} + +/** + * The window's one avoid-area subscription, shared by every binding. + * + * The four numbers are a fact about one window, not about one component. While + * each of the 14+ bindings opened its own `getLastWindow` and its own + * `avoidAreaChange` listener, the app held one subscription per mounted surface, + * and those surfaces disagreed with each other for as long as each one's + * asynchronous window call took to resolve: a page that mounted just after a + * rotation could lay itself out from the previous numbers while its neighbour + * already had the new ones. + * + * This is reference-counted instead. The first subscriber opens the one window + * listener, the last release closes it, and every binding in between is a + * callback on this object. A new subscriber is handed the current value as it + * registers, so a surface that mounts after the last change still starts from + * real numbers rather than from zeroes. + * + * The values it broadcasts are px, the unit the window reports. Each binding + * converts them with its own `UIContext`, because two windows on one device need + * not share a scale. + */ +class InsetsBroadcast { + private listeners: WindowInsetListener[] = []; + private current: WindowInsets = { top: 0, bottom: 0, left: 0, right: 0 }; + private subscription?: WindowInsetSubscription; + private opened: boolean = false; + /** + * Bumped by every open and every close, so a window read that resolves late + * can tell whether it still belongs to the current subscription. + */ + private generation: number = 0; + + /** + * Registers one listener and returns its release. + * + * Registration is synchronous — the shared window call happens inside `open` + * and is not awaited here — so a component that unmounts immediately after + * binding has nothing in flight to leak: it releases a listener that is + * already in the list, and the shared subscription is torn down by that same + * release if it was the last one. + */ + subscribe(context: Context, listener: WindowInsetListener): WindowInsetSubscription { + this.listeners.push(listener); + if (this.listeners.length === 1) { + this.open(context); } - const navigation = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR).bottomRect.height; - if (navigation > 0) { - return { top, bottom: navigation }; + listener(this.current); + return (): void => { + this.release(listener); + }; + } + + private release(listener: WindowInsetListener): void { + const index = this.listeners.indexOf(listener); + if (index >= 0) { + this.listeners.splice(index, 1); + } + if (this.listeners.length === 0) { + this.close(); + } + } + + private open(context: Context): void { + if (this.opened) { + return; + } + this.opened = true; + this.generation += 1; + const generation = this.generation; + RemoteLogger.info('window insets subscription opened'); + void WindowSystemBarService.observeInsets(context, (insets: WindowInsets): void => { + this.current = insets; + for (const listener of this.listeners) { + listener(insets); + } + }).then((subscription: WindowInsetSubscription): void => { + // The last subscriber can be gone, or a later open can already own the + // window, before this read resolves. Either way this subscription has no + // one left to serve, so it is handed straight back instead of being kept. + if (generation !== this.generation || this.listeners.length === 0) { + subscription(); + return; + } + this.subscription = subscription; + }); + } + + private close(): void { + this.opened = false; + this.generation += 1; + if (this.subscription) { + this.subscription(); + this.subscription = undefined; + RemoteLogger.info('window insets subscription closed'); } - // Some builds fold the navigation bar into the system avoid area instead of - // reporting an indicator of its own. - return { top, bottom: appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).bottomRect.height }; } } +/** The process-wide instance every `WindowInsetsBinding` registers with. */ +const sharedInsetsBroadcast: InsetsBroadcast = new InsetsBroadcast(); + /** * One component's live view of the window insets, in vp. * @@ -101,35 +278,46 @@ export class WindowSystemBarService { * edge reads these numbers instead of assuming the framework reserved the strip * for it: the chat page's fade fills the bottom edge, and the interactive rows * of every other page — drawers, sheets, covers, galleries — keep clear of it. - * They mount and unmount independently, so the binding owns the subscription - * rather than leaving each page to juggle one, and it holds vp because padding, - * margins and radii in this app are vp. + * The sides are part of the same model: they are the camera cutout, which a + * rotated device moves onto one of them, so a full-width band reserves them + * before its own design spacing rather than assuming the page starts clear. + * They mount and unmount independently, so the binding holds a registration on + * the app's one shared subscription rather than leaving each page to juggle a + * window listener of its own, and it holds vp because padding, margins and radii + * in this app are vp. */ @ObservedV2 export class WindowInsetsBinding { @Trace top: number = 0; @Trace bottom: number = 0; + @Trace left: number = 0; + @Trace right: number = 0; private subscription?: WindowInsetSubscription; private disposed: boolean = false; bind(uiContext: UIContext, context: Context): void { this.disposed = false; - void WindowSystemBarService.observeInsets(context, (insets: WindowInsets): void => { + // Registering is synchronous and immediately delivers the current value, so + // this surface is laid out from real numbers as soon as it mounts and is + // releasable the moment it goes. The callback still checks `disposed` + // because the broadcast can deliver a change across an unmount. + const callback: WindowInsetListener = (insets: WindowInsets): void => { if (this.disposed) { return; } this.top = uiContext.px2vp(insets.top); this.bottom = uiContext.px2vp(insets.bottom); - RemoteLogger.info(`window insets top=${this.top} bottom=${this.bottom}`); - }).then((subscription: WindowInsetSubscription): void => { - // The page can disappear before the window call resolves; releasing here - // keeps a listener from outliving the component that asked for it. - if (this.disposed) { - subscription(); - return; - } - this.subscription = subscription; - }); + this.left = uiContext.px2vp(insets.left); + this.right = uiContext.px2vp(insets.right); + RemoteLogger.info(`window insets top=${this.top} bottom=${this.bottom} left=${this.left} right=${this.right}`); + }; + // A binding that is bound twice replaces its own registration instead of + // leaving the previous one behind on the shared subscription. + const previous = this.subscription; + this.subscription = sharedInsetsBroadcast.subscribe(context, callback); + if (previous) { + previous(); + } } unbind(): void { @@ -149,6 +337,20 @@ export class WindowInsetsBinding { return Math.max(designSpacing, this.bottom); } + /** + * The padding a side-anchored content layer needs. Split by side rather than + * exposed as one pair because a cutout only ever lands on one of them: the + * left strip stays 0 while the right one carries the camera, and padding both + * by the wider value would push the content of a full-width band off-centre. + */ + leftPadding(designSpacing: number): number { + return Math.max(designSpacing, this.left); + } + + rightPadding(designSpacing: number): number { + return Math.max(designSpacing, this.right); + } + /** * The tail spacer a scrolling surface appends after its last row: the strip * itself plus the design's own breathing. The viewport then runs to the diff --git a/src/apps/mobile/harmonyos/entry/src/main/module.json5 b/src/apps/mobile/harmonyos/entry/src/main/module.json5 index b52c821f61..6e5460c280 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/module.json5 +++ b/src/apps/mobile/harmonyos/entry/src/main/module.json5 @@ -10,6 +10,19 @@ ], "deliveryWithInstall": true, "installationFree": false, + // The camera cutout is not an avoid area by default, so a page does not stay + // clear of it — and in landscape it sits on a side edge of a full-width + // band. From API version 12 on, a module makes the cutout a default avoid + // area with this metadata, which is what lets the window report it through + // `getWindowAvoidArea(TYPE_CUTOUT)` and lets a component expand into it + // (`ts-universal-attributes-expand-safe-area`; module-level `metadata` is + // legal per `module-configuration-file`). + "metadata": [ + { + "name": "avoid_cutout", + "value": "true" + } + ], "requestPermissions": [ { "name": "ohos.permission.INTERNET" diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 41c1fa4010..c5b9dbf430 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -18,6 +18,8 @@ function source(relativePath) { const entryAbility = source('entry/src/main/ets/entryability/EntryAbility.ets'); const conversationView = source('entry/src/main/ets/pages/components/ConversationView.ets'); const windowService = source('entry/src/main/ets/services/WindowSystemBarService.ets'); +const moduleConfig = source('entry/src/main/module.json5'); +const appShell = source('entry/src/main/ets/pages/components/AppShell.ets'); const appSidebar = source('entry/src/main/ets/pages/components/AppSidebar.ets'); const miniAppSurface = source('entry/src/main/ets/pages/components/MiniAppSurface.ets'); const settingsSheet = source('entry/src/main/ets/pages/components/SettingsSheet.ets'); @@ -33,6 +35,16 @@ const welcomeHome = source('entry/src/main/ets/pages/components/WelcomeHome.ets' const remoteSurfaceHost = source('entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets'); const wideConversationHost = source('entry/src/main/ets/pages/components/WideConversationHost.ets'); +// Reads one component's build() chain, so an assertion can name the root box a +// page paints without reaching into the file's other builders. +function buildChain(sourceText) { + const start = sourceText.indexOf('build() {'); + assert.notEqual(start, -1, 'the component must keep its build()'); + const end = sourceText.indexOf('\n @Builder', start); + assert.notEqual(end, -1, 'build() must be followed by another builder'); + return sourceText.slice(start, end); +} + // Reads one @Builder out of a component, so an assertion can name the layer it // is about instead of counting matches in the whole file. function builderBody(sourceText, name) { @@ -68,6 +80,43 @@ function call(sourceText, name) { throw new Error(`unbalanced .${name}() call`); } +// Counts a literal, so an assertion can be about how many times a call exists +// rather than only about whether it exists at all. +function occurrences(sourceText, needle) { + return sourceText.split(needle).length - 1; +} + +// Every `.ets` source in the entry module's `ets` tree. Some of these contracts +// are about the whole app and not about one file — "one window listener" is the +// clearest of them — and a component that breaks one lives anywhere in the tree. +// `generated/` is left out: it is emitted from the design tokens and carries no +// window code, so a match there would be a generator bug rather than this +// contract moving. +function etsSources(root) { + const files = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name === 'generated') { + continue; + } + files.push(...etsSources(path.join(root, entry.name))); + } else if (entry.name.endsWith('.ets')) { + files.push(path.join(root, entry.name)); + } + } + return files; +} + +// The whole tree as `[relativePath, text]` pairs, with `/` separators so an +// expectation reads the same on every host. +function etsTree(relativeRoot) { + const root = path.join(__dirname, '../..', relativeRoot); + return etsSources(root).map((file) => [ + path.relative(root, file).split(path.sep).join('/'), + fs.readFileSync(file, 'utf8') + ]); +} + test('the shell runs the window full-screen for every page', () => { // Not the welcome page's private trick any more: the transcript can only reach // the bottom edge of the screen if the window stops reserving the strip above @@ -80,19 +129,24 @@ test('the shell runs the window full-screen for every page', () => { 'the welcome page must not switch the window layout any more'); }); -test('both system bars are transparent so the page owns their strips', () => { +test('neither system bar sets a background so the page owns their strips', () => { // The window runs full-screen, so the status bar's strip is the page's own // top edge: the drawer's fill and the chat page's blurred header band are what - // should show there. An opaque bar painted the page colour over both — it + // should show there. The strips are transparent by platform default and carry + // the page's own background through, so the ability must not set a background + // colour on them at all. An opaque bar painted the page colour over both — it // clipped the sidebar's title — and it also sat over the pull-down // notification centre's frosted background as a white strip. The content // colour is what keeps the clock and the icons legible either way. - assert.match(entryAbility, /navigationBarColor: MobileDesignColors\.transparent\.light/, - 'the navigation bar must be transparent so the page bottom shows through'); - assert.match(entryAbility, /statusBarColor: MobileDesignColors\.transparent\.light/, - 'the status bar must be transparent so the page top shows through'); - assert.match(entryAbility, /statusBarContentColor: content/, + const bars = call(normalize(entryAbility), 'setWindowSystemBarProperties'); + assert.equal(bars.includes('statusBarColor:'), false, + 'the status bar must keep the platform default background instead of setting one'); + assert.equal(bars.includes('navigationBarColor:'), false, + 'the navigation bar must keep the platform default background instead of setting one'); + assert.match(bars, /statusBarContentColor: content/, 'the status bar icons must keep following the colour mode'); + assert.match(bars, /navigationBarContentColor: content/, + 'the navigation bar icons must keep following the colour mode'); }); test('the window inset service is the only place the strip is measured', () => { @@ -104,6 +158,180 @@ test('the window inset service is the only place the strip is measured', () => { assert.match(windowService, /appWindow\.off\('avoidAreaChange'/, 'the binding must release its listener'); }); +test('a window read that fails releases its listener and says so', () => { + // The failure path used to hand back an empty release and zero every inset. + // Two things followed, and neither was visible from anywhere else: the + // listener registered one line above stayed on the window for the life of the + // process — `InsetsBroadcast.close()` had nothing to call, so an app with no + // mounted surface still held a window listener — and the shared broadcast was + // pinned to zeroes, so every later avoid-area change re-threw inside the + // system's own callback with nobody watching and the whole app laid itself out + // against zero insets. That is the silent whole-app regression this test + // exists to keep out: the failure has to be released and it has to be logged. + const observe = windowService.slice( + windowService.indexOf('static async observeInsets'), + windowService.indexOf('private static avoidEdges')); + assert.notEqual(observe.indexOf('} catch'), -1, + 'observeInsets must keep a failure path: a window that cannot answer is not a reason to stop the page rendering'); + const failure = normalize(observe.slice(observe.indexOf('} catch'))); + assert.match(failure, /(?:appWindow\.off\('avoidAreaChange'|detach\(\);)/, + 'the failure path must take the listener it registered back off the window, directly or through the shared ' + + 'release: leaving it registered outlives every unmount, and zeroing the broadcast pins the whole app to zero ' + + 'insets for the rest of the process'); + assert.match(failure, /RemoteLogger\.(?:error|warn)\(/, + 'the failure path must log: silently zeroing every inset is the whole-app regression this path used to cause'); + // The release the failure path calls has to be the one that really detaches, + // and it has to be guarded by whether `on` registered at all: releasing blind + // would detach a callback that was never ours. + assert.match(normalize(windowService), + /detach = \(\): void => \{ if \(!listening\) \{ return; \} listening = false; appWindow\.off\('avoidAreaChange', onAvoidAreaChange\); \};/, + 'the shared release must be the one place the window listener is taken off, and it must not fire when nothing was registered'); +}); + +test('the whole app subscribes to the window once, not once per surface', () => { + // One window, one avoid-area fact. Fourteen surfaces each opening their own + // `getLastWindow` and their own listener meant fourteen subscriptions to the + // same event and a window during which two surfaces disagreed about the strip + // between them. There is one read and one listener left, and this is the + // assertion that keeps a component from quietly growing its own again: the + // per-component subscription is a regression, however convenient it looks. + // + // Counted over the whole `ets` tree, not over the service alone. Counting one + // file only proved the service did not grow a second listener; a component + // living in any other file could open one and nothing here would notice, which + // is exactly the shape this contract exists to forbid. + const tree = etsTree('entry/src/main/ets'); + const subscribingFiles = tree + .filter(([, text]) => text.includes("on('avoidAreaChange'")) + .map(([name]) => name); + assert.deepEqual(subscribingFiles, ['services/WindowSystemBarService.ets'], + 'the window must be subscribed to from the shared service and nowhere else: any other file registering ' + + '`avoidAreaChange` is a component holding its own window listener, which the app-wide release cannot reach ' + + 'and which disagrees with the shared numbers between reads'); + assert.equal(tree.reduce((count, [, text]) => count + occurrences(text, "on('avoidAreaChange'"), 0), 1, + 'the window must be subscribed to exactly once, by the shared broadcast: a second listener means a surface opened its own'); + assert.equal(tree.reduce((count, [, text]) => count + occurrences(text, "off('avoidAreaChange'"), 0), 1, + 'the window listener must be released from exactly one place, in the service that opened it: a release ' + + 'anywhere else means a component closed a listener it did not open'); + // `KeyboardReleaseService` is the tree's other window read and a deliberate + // one: it dismisses the soft keyboard, it is not an inset source, and it opens + // no avoid-area listener. The one counted here is the shared inset read above, + // which is the app's only inset source. + assert.equal(occurrences(windowService, 'window.getLastWindow('), 1, + 'only the shared inset read may ask the window service for insets'); + + // Reference counted: the first registration opens the shared subscription and + // the last release closes it, so the app holds the window for exactly as long + // as one surface is actually mounted on it. + const broadcast = normalize(windowService.slice( + windowService.indexOf('class InsetsBroadcast'), + windowService.indexOf('const sharedInsetsBroadcast'))); + assert.match(broadcast, /subscribe\(context: Context, listener: WindowInsetListener\): WindowInsetSubscription/, + 'a binding must register its callback with the broadcast rather than with the window'); + assert.match(broadcast, /if \(this\.listeners\.length === 1\) \{ this\.open\(context\); \} listener\(this\.current\);/, + 'the first registration must open the shared window subscription and hand the new subscriber the current value'); + assert.match(broadcast, /if \(this\.listeners\.length === 0\) \{ this\.close\(\); \}/, + 'the last release must close the shared window subscription'); + assert.match(broadcast, /if \(generation !== this\.generation \|\| this\.listeners\.length === 0\) \{ subscription\(\); return; \}/, + 'a window read that resolves after the last release, or after a later open, must be handed straight back'); + assert.match(normalize(windowService), /this\.subscription = sharedInsetsBroadcast\.subscribe\(context, callback\);/, + 'the binding must take its registration from the shared broadcast'); + + // The binding's public shape is what the 14+ surfaces are written against, so + // it stays: bind/unbind keep their signature, and every padding primitive the + // pages consume is still here. A page that needs the top strip mounts a + // binding — the one-shot read it used to have could not follow a rotation. + assert.match(windowService, /bind\(uiContext: UIContext, context: Context\): void \{/, + 'the binding must keep the bind signature every surface already calls'); + assert.match(windowService, /unbind\(\): void \{/, 'the binding must keep its unbind'); + for (const primitive of ['leftPadding', 'rightPadding', 'bottomPadding', 'tailSpacing']) { + assert.match(windowService, new RegExp(`${primitive}\\(designSpacing: number\\): number \\{`), + `the binding must keep ${primitive} as its own primitive`); + } + assert.equal(windowService.includes('topSystemInsetPx'), false, + 'the one-shot status-bar read must stay gone: it could not follow a rotation, and the binding it was folded into can'); + assert.match(normalize(welcomeHome), /\.padding\(\{ top: this\.insets\.top \}\)/, + 'the welcome page must reserve the top strip from the shared binding'); +}); + +test('the insets merge every avoid area that claims an edge, cutout included', () => { + // The camera cutout is an avoid area of its own: the framework's SYSTEM area + // is the status bar and the navigation bar only, so a model that read those + // two could not express a cutout at all — and the cutout is the one area that + // moves, onto a side edge, as soon as the device rotates. More than one area + // can claim the same edge, and they overlap rather than stack, so an edge is + // the widest claim among them: the merge is one `Math.max` per edge, the same + // merge the official `handleCutoutAvoidArea` sample performs. + assert.match(windowService, /export function mergeAvoidEdges\(areas: AvoidEdges\[\]\): AvoidEdges/, + 'the per-edge merge must stay a function of its own, not be buried in the reader'); + const merge = normalize(windowService.slice( + windowService.indexOf('export function mergeAvoidEdges'), + windowService.indexOf('export class WindowSystemBarService'))); + for (const edge of ['top', 'bottom', 'left', 'right']) { + assert.match(merge, new RegExp(`${edge} = Math\\.max\\(${edge}, area\\.${edge}\\)`), + `the merge must take the widest claim on the ${edge} edge`); + } + const insetsOf = normalize(windowService.slice(windowService.indexOf('private static insetsOf'))); + for (const type of ['TYPE_SYSTEM', 'TYPE_NAVIGATION_INDICATOR', 'TYPE_CUTOUT']) { + assert.match(insetsOf, new RegExp(type), + `${type} must be one of the areas the insets are read from`); + } + // The keyboard keeps owning the bottom strip while it is up: `RESIZE` has + // already lifted the page above it (see ConversationView), so reserving the + // navigation indicator as well would count the same strip twice. + assert.match(insetsOf, /bottom: keyboardVisible \? 0 : merged\.bottom/, + 'the keyboard must keep taking the bottom strip while it is visible'); +}); + +test('the binding exposes the side strips and the paddings that consume them', () => { + // Left and right are 0 on a portrait phone, so `max(design, strip)` is exactly + // the design value there and the change is invisible until a device rotates + // its camera onto that side. The two primitives are split by side because a + // cutout only ever lands on one of them: a band reserves the side the device + // claims and keeps its own gutter on the other. + for (const edge of ['top', 'bottom', 'left', 'right']) { + assert.match(windowService, new RegExp(`@Trace ${edge}: number = 0;`), + `the binding must publish the ${edge} strip`); + assert.match(windowService, new RegExp(`this\\.${edge} = uiContext\\.px2vp\\(insets\\.${edge}\\)`), + `the binding must convert the ${edge} strip to vp like the others`); + } + assert.match(normalize(windowService), + /leftPadding\(designSpacing: number\): number \{ return Math\.max\(designSpacing, this\.left\); \}/, + 'a side strip must only ever raise its own side above the design spacing'); + assert.match(normalize(windowService), + /rightPadding\(designSpacing: number\): number \{ return Math\.max\(designSpacing, this\.right\); \}/, + 'a side strip must only ever raise its own side above the design spacing'); +}); + +test('the module declares the cutout as an avoid area', () => { + // Without this metadata the page does not avoid the camera cutout at all: the + // framework only treats it as an avoid area once the module asks, so neither + // the side strips below nor a background expanded into it would exist. It is a + // module-level entry, not a per-ability one: the declaration has to sit before + // the abilities array starts. + const moduleBlock = moduleConfig.slice(moduleConfig.indexOf('"module"'), moduleConfig.lastIndexOf('"abilities"')); + assert.match(moduleBlock, /"name": "avoid_cutout",[\s\S]*?"value": "true"/, + 'the module must declare avoid_cutout before its abilities'); +}); + +test('the bands a layout dump has to read are named', () => { + // The side strips are invisible in a screenshot. A layout dump is where they + // can be measured, and a band with side padding shows up there only as the + // offset between the band's own box and its first child — the box itself does + // not move. So the bands that carry one are named for the dump, the way the + // chat page's bottom fade already is: the chat page's top band, and the wide + // home header band whose box is the window in one parent and the wide detail + // pane in the other, which is the question its side padding turns on. + const top = builderBody(conversationView, 'TopOverlay'); + assert.equal(call(top, 'id'), ".id('conversation-top-band')", + 'the chat page top band must stay named so a dump can read its box against its first child'); + assert.match(normalize(remoteSurfaceHost), /\.id\('remote-wide-home-band'\)/, + 'the wide home header band must stay named: whether its box is the window or the detail pane is ' + + 'exactly what decides whether its side padding clears a cutout or only indents its content'); + assert.equal(call(builderBody(conversationView, 'BottomOverlay'), 'id'), ".id('conversation-bottom-fade')", + 'the chat page bottom fade must stay named'); +}); + test('the binding owns both strip numbers a surface needs', () => { // bottomPadding is for a fixed control: the design's spacing, or the strip if // the strip is larger. tailSpacing is for the end of a scrolling surface: the @@ -116,12 +344,16 @@ test('the binding owns both strip numbers a surface needs', () => { test('the chat page bottom layer reaches the screen edge and its composer does not move', () => { const bottom = builderBody(conversationView, 'BottomOverlay'); - // The layer's box grows by the strip, so its own gradient fills it... - assert.equal(call(bottom, 'padding'), '.padding({ bottom: this.bottomSafeInset })'); + // The layer's box grows by the strip, so its own gradient fills it — and it + // carries the side strips too, because the layer is the window's full width + // and the composer's own gutter lives inside it... + assert.equal(call(bottom, 'padding'), + '.padding({ left: this.insets.leftPadding(0), right: this.insets.rightPadding(0), bottom: this.bottomSafeInset })'); // ...it may paint into the strip even where the page area still stops above // it, and it is named so a layout dump can be read against the pixels. assert.equal(call(bottom, 'expandSafeArea'), - '.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])'); + '.expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], ' + + '[SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM])'); assert.equal(call(bottom, 'id'), ".id('conversation-bottom-fade')"); // The transcript borrows the layer's measured height, which is what lets the // last message scroll above the fade instead of under the composer. @@ -132,9 +364,13 @@ test('the chat page bottom layer reaches the screen edge and its composer does n test('the chat page header band reserves the status bar itself', () => { // The shell is immersive, so the page draws from the top of the window and the // band is what has to keep the header out of the status bar. Its own box is - // the transcript's content start offset, so the two stay in step. + // the transcript's content start offset, so the two stay in step. The band is + // the window's full width as well, which is what makes its sides the sides the + // camera cutout rotates onto; the header's own gutter is inside it and is not + // restated here. const top = builderBody(conversationView, 'TopOverlay'); - assert.equal(call(top, 'padding'), '.padding({ top: this.topSafeInset })'); + assert.equal(call(top, 'padding'), + '.padding({ left: this.insets.leftPadding(0), right: this.insets.rightPadding(0), top: this.topSafeInset })'); assert.match(top, /\.backgroundColor\(PAGE_BG_OVERLAY\)/, 'the band that carries the inset must stay the one that paints the header'); }); @@ -148,25 +384,26 @@ test('every surface whose top edge is the screen edge reserves the status bar', // back control, the preview's header and the home headers' drawer control are // all one strip lower than they were while the page area ended below it. assert.match(normalize(builderBody(appSidebar, 'SidebarContent')), - /\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/, + /\.padding\(\{ left: this\.insets\.leftPadding\(20\), right: this\.insets\.rightPadding\(20\), top: this\.insets\.top \}\)/, 'the drawer panel must reserve the strip above its title'); assert.match(normalize(miniAppSurface), - /\.height\(56 \+ this\.insets\.top\)\.padding\(\{ left: 12, right: 12, top: this\.insets\.top \}\)/, + /\.height\(56 \+ this\.insets\.top\)\s*\.padding\(\{ left: this\.insets\.leftPadding\(12\), right: this\.insets\.rightPadding\(12\), top: this\.insets\.top \}\)/, 'the mini-app gallery header must reserve the strip'); assert.match(normalize(filePreviewSurface), - /\.height\(68 \+ this\.insets\.top\)[\s\S]*?\.padding\(\{ left: 8, right: 8, top: 8 \+ this\.insets\.top, bottom: 8 \}\)/, + /\.height\(68 \+ this\.insets\.top\)[\s\S]*?\.padding\(\{ left: this\.insets\.leftPadding\(8\), right: this\.insets\.rightPadding\(8\), top: 8 \+ this\.insets\.top, bottom: 8 \}\)/, 'the file preview header must reserve the strip'); assert.match(normalize(remoteSurfaceHost), - /\.padding\(\{ top: this\.insets\.top \}\)/, + /\.padding\(\{ left: this\.insets\.leftPadding\(0\), right: this\.insets\.rightPadding\(0\), top: this\.insets\.top \}\)/, 'the compact home header must reserve the strip'); assert.match(normalize(remoteSurfaceHost), - /\.height\(76 \+ this\.insets\.top\) \.padding\(\{ left: 16, right: 16, top: 14 \+ this\.insets\.top, bottom: 12 \}\)/, + /\.height\(76 \+ this\.insets\.top\)\s*\.padding\(\{ left: this\.insets\.leftPadding\(16\), right: this\.insets\.rightPadding\(16\), top: 14 \+ this\.insets\.top, bottom: 12 \}\)/, 'the wide home header must reserve the strip'); // A floating control on a pane whose top edge is the window's keeps the strip // clear itself, the way the surfaces' fixed bottom controls keep the - // navigation bar clear. + // navigation bar clear. Its left edge is the window's edge in the collapsed + // wide layout, so the same offset carries the side strip. assert.match(normalize(wideConversationHost), - /\.position\(\{ x: 12, y: 12 \+ this\.insets\.top \}\)/, + /\.position\(\{ x: this\.insets\.leftPadding\(12\), y: 12 \+ this\.insets\.top \}\)/, 'the floating master-restore control must reserve the strip'); for (const [name, text] of [['RemoteSurfaceHost', remoteSurfaceHost], ['WideConversationHost', wideConversationHost]]) { @@ -218,7 +455,8 @@ test('the sidebar list scrolls under the bar and its floating footer keeps the s // the footer keeps its distance from that edge itself, and the list ends in // a tail spacer so its last row rests above the navigation bar. const content = normalize(builderBody(appSidebar, 'SidebarContent')); - assert.match(content, /\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/, + assert.match(content, + /\.padding\(\{ left: this\.insets\.leftPadding\(20\), right: this\.insets\.rightPadding\(20\), top: this\.insets\.top \}\)/, 'the panel root must reserve the status bar without shrinking its scrolling viewport'); assert.match(content, /\.padding\(\{ bottom: this\.scrollTailPadding\(\) \}\)/, 'the session list must end in a tail spacer'); @@ -334,9 +572,102 @@ test('the welcome dock reads the strip through the shared binding', () => { assert.match(normalize(welcomeHome), /bottom: this\.wide\(\) \? 0 : this\.insets\.bottomPadding\(G\.welcomeDockBottom\)/, 'the dock must lift its content by the strip wherever the indicator is taller'); - assert.match(welcomeHome, - /\.expandSafeArea\(\[SafeAreaType\.SYSTEM\], \[SafeAreaEdge\.BOTTOM\]\)/, - 'the dock fill must still reach the screen edge'); + assert.equal(call(normalize(welcomeHome), 'expandSafeArea'), + '.expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], ' + + '[SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM])', + 'the dock fill must reach the screen edge on the sides as well as the bottom: `wide()` is a logical-width ' + + 'test and a side cutout is not, so a landscape window below 600vp, a split screen, a free window and a ' + + 'hover posture all leave this column sitting on the window\'s side edges with no side claim and a page-colour sliver beside its fill'); +}); + +test('every background that owns a screen edge claims the cutout as well', () => { + // The CUTOUT area is described separately from SYSTEM — which the framework + // defines as the status bar and the navigation bar — so a fill that claims the + // system area alone stops short of a notch and shows the page colour beside + // it. These are the four full-bleed backgrounds of the shell: the drawer's + // floor, the account cover's scrim, the chat page's bottom fade and the home + // dock. + for (const [name, text, expected] of [['AppShell', appShell, 2], + ['ConversationView', conversationView, 1], ['WelcomeHome', welcomeHome, 1]]) { + const claims = (text.match(/\.expandSafeArea\(\[SafeAreaType\.SYSTEM, SafeAreaType\.CUTOUT\]/g) ?? []).length; + assert.equal(claims, expected, `${name} must claim the cutout wherever it claims the system area`); + const systemOnly = (text.match(/\.expandSafeArea\(\[SafeAreaType\.SYSTEM\]/g) ?? []).length; + assert.equal(systemOnly, 0, `${name} must not leave a fill on the system area alone`); + } +}); + +test('each fill names the edges its own box actually touches', () => { + // Naming the cutout type is only half of the claim: `expandSafeArea` takes + // effect per edge and only where the component's own boundary meets that + // edge's safe-area boundary. A cutout in landscape sits on the window's left + // or right edge, so a fill that named only TOP/BOTTOM claimed nothing at all + // on the one edge the rotated cutout lands on, however clearly it named the + // type. Each of the four fills now names exactly the edges its box owns: + // + // drawer floor / cover scrim the window itself, all four edges; + // chat page's bottom fade the window's full width and its bottom edge, + // while its top edge sits mid-pane — naming TOP + // would claim a strip it never touches; + // home dock the bottom edge and both sides. The sides are + // only load-bearing in one combination, but it + // is a reachable one: the dock has a fill of its + // own only in the compact composition, while a + // side cutout exists in any landscape window — + // including a sub-600vp one, a split screen, a + // free window or a hover posture, where the dock + // does sit on the window's side edges. In the + // `wide()` composition the dock paints + // TRANSPARENT, so the extra claim draws nothing. + const shellBuild = appShell.slice(appShell.indexOf('build() {'), + appShell.indexOf('@Builder', appShell.indexOf('build() {'))); + for (const [name, slice, expected] of [ + ['the drawer floor', shellBuild, + '[SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]'], + ['the account cover scrim', builderBody(appShell, 'CompactLoginCover'), + '[SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]'], + ['the chat page bottom fade', builderBody(conversationView, 'BottomOverlay'), + '[SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]'], + ['the home dock', builderBody(welcomeHome, 'Actions'), + '[SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]'] + ]) { + assert.equal(call(slice, 'expandSafeArea'), + `.expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], ${expected})`, + `${name} must claim exactly the edges its box owns`); + } +}); + + +test('every surface whose side edge is the screen edge reserves the side strips', () => { + // The side strips are the camera cutout, which a rotated device moves onto the + // window's left or right edge. A full-width band consumes them per side — + // `max(design, strip)` — so the design's own gutter survives on a device that + // claims no side strip at all, which is every portrait phone. + // + // The sheets and covers are deliberately absent: `bindSheet` and + // `bindContentCover` inset their own page horizontally, so their rows never + // reach the window's side edges and a side padding there would only move + // content that was already clear. + for (const [name, text, pattern] of [ + ['the drawer panel', appSidebar, + /\.padding\(\{ left: this\.insets\.leftPadding\(20\), right: this\.insets\.rightPadding\(20\), top: this\.insets\.top \}\)/], + ['the compact home header', remoteSurfaceHost, + /\.padding\(\{ left: this\.insets\.leftPadding\(0\), right: this\.insets\.rightPadding\(0\), top: this\.insets\.top \}\)/], + ['the wide home header', remoteSurfaceHost, + /\.padding\(\{ left: this\.insets\.leftPadding\(16\), right: this\.insets\.rightPadding\(16\), top: 14 \+ this\.insets\.top, bottom: 12 \}\)/], + ['the gallery header', miniAppSurface, + /\.padding\(\{ left: this\.insets\.leftPadding\(12\), right: this\.insets\.rightPadding\(12\), top: this\.insets\.top \}\)/], + ['the preview header', filePreviewSurface, + /\.padding\(\{ left: this\.insets\.leftPadding\(8\), right: this\.insets\.rightPadding\(8\), top: 8 \+ this\.insets\.top, bottom: 8 \}\)/], + ['the chat header band', conversationView, + /\.padding\(\{ left: this\.insets\.leftPadding\(0\), right: this\.insets\.rightPadding\(0\), top: this\.topSafeInset \}\)/], + ['the composer layer', conversationView, + /\.padding\(\{ left: this\.insets\.leftPadding\(0\), right: this\.insets\.rightPadding\(0\), bottom: this\.bottomSafeInset \}\)/] + ]) { + assert.match(normalize(text), pattern, `${name} must consume the side strips through the binding`); + } + assert.match(normalize(wideConversationHost), + /\.position\(\{ x: this\.insets\.leftPadding\(12\), y: 12 \+ this\.insets\.top \}\)/, + 'the floating control must take its x offset through the binding as well'); }); test('every surface that owns a bottom edge reads the shared binding, not a constant', () => { @@ -353,3 +684,122 @@ test('every surface that owns a bottom edge reads the shared binding, not a cons assert.match(text, /this\.insets\.unbind\(\)/, `${name} must release the insets when it goes`); } }); + +test('every top band the status bar shows through is a theme-following page colour', () => { + // The window's status-bar content colour is one value for the whole window and + // follows the colour mode alone (see `updateSystemBars` in EntryAbility): the + // light ink on a light band, the dark ink on a dark one. That single value is + // correct for every page shipped today for one reason only — every top band the + // window shows through the transparent status bar is a theme-following page + // colour — and this test is what turns that from a coincidence into a contract. + // + // Each entry names the band that owns the window's top edge and the semantic + // colour it paints with. A band that becomes media chrome (`MEDIA_BACKGROUND` / + // `MEDIA_SCRIM`, dark in both appearances) or a hard-coded colour puts the + // light-mode ink on a dark band, and the clock and the status icons stop being + // readable against it. + const bands = [ + ['the drawer panel', builderBody(appSidebar, 'SidebarContent'), 'SIDEBAR_BG'], + ['the chat header band', builderBody(conversationView, 'TopOverlay'), 'PAGE_BG_OVERLAY'], + ['the gallery root', buildChain(miniAppSurface), 'PAGE_BG'], + ['the file preview root', buildChain(filePreviewSurface), 'PAGE_BG'], + ['the compact home root', builderBody(remoteSurfaceHost, 'CompactHomeContent'), 'PAGE_BG'], + ['the wide home root', builderBody(remoteSurfaceHost, 'FlowPlaceholder'), 'PAGE_BG'], + ['the wide home pane', builderBody(wideConversationHost, 'RemoteHomeContent'), 'PAGE_BG'], + ['the wide home detail', builderBody(wideConversationHost, 'RemoteHomeDetail'), 'PAGE_BG'], + ['the wide chat pane', builderBody(wideConversationHost, 'RemoteChatContent'), 'PAGE_BG'], + ['the connect sheet root', buildChain(connectView), 'CARD'], + ['the welcome page', buildChain(welcomeHome), 'WELCOME_PAGE_BG'] + ]; + const unreadable = 'the status bar icons are drawn in the colour-mode ink, so a top band that is dark in ' + + 'that same appearance makes them unreadable: handle the band and the system bar content colour together ' + + '(see `updateSystemBars` in EntryAbility) instead of repainting the band on its own'; + for (const [name, slice, token] of bands) { + assert.match(normalize(slice), new RegExp(`\\.backgroundColor\\(${token}\\)`), + `${name} must paint its top band with the theme-following ${token}`); + for (const forbidden of ['MEDIA_BACKGROUND', 'MEDIA_SCRIM']) { + assert.equal(slice.includes(forbidden), false, + `${name} must not paint its top band with ${forbidden} (dark in both appearances): ${unreadable}`); + } + assert.equal(slice.includes("backgroundColor('#"), false, + `${name} must not paint its top band with a hard-coded colour: ${unreadable}`); + } + + // The same rule for the fills that reach the screen edge rather than sitting in + // a band: the status bar strip carries whatever the page paints there, so the + // surfaces that own it are the ones that have to keep their own ink legible on + // it. Each of the three already reads a theme-following token above. + for (const [name, text] of [['AppShell', appShell], ['ConversationView', conversationView], + ['WelcomeHome', welcomeHome]]) { + assert.equal(text.includes('MEDIA_BACKGROUND'), false, + `${name} must not paint a screen-edge fill with media chrome: ${unreadable}`); + } +}); + +test('the chat page owns the keyboard avoidance the bottom rule assumes', () => { + // `insetsOf` hands the bottom strip to the keyboard while it is visible, + // because `KeyboardAvoidMode.RESIZE` has already lifted the page above it: + // reserving the navigation indicator as well would count the same strip twice. + // The mode is set by the chat page itself and only while that page is mounted, + // so the rule leans on a page it cannot see. Without RESIZE the keyboard is + // still up and the bottom strip is still handed to it, so the bottom is + // reserved by the keyboard and the navigation bar both - the composer and + // everything docked below the transcript sit under the keyboard with a + // bar-sized hole beneath them. + const appear = conversationView.slice(conversationView.indexOf('aboutToAppear(): void {'), + conversationView.indexOf('aboutToDisappear(): void {')); + const disappear = conversationView.slice(conversationView.indexOf('aboutToDisappear(): void {'), + conversationView.indexOf('build() {')); + assert.match(conversationView, /private previousKeyboardAvoidMode: KeyboardAvoidMode = /, + 'the chat page must keep the mode it found, so it can put it back'); + assert.match(appear, /this\.previousKeyboardAvoidMode = this\.getUIContext\(\)\.getKeyboardAvoidMode\(\);/, + 'the chat page must read the current keyboard avoid mode before it changes it'); + assert.match(appear, /this\.getUIContext\(\)\.setKeyboardAvoidMode\(KeyboardAvoidMode\.RESIZE\);/, + 'the chat page must set RESIZE while it is mounted: the bottom inset rule in WindowSystemBarService ' + + 'assumes the keyboard has already lifted the page, so without RESIZE the bottom is reserved by the ' + + 'keyboard and the navigation bar at once'); + assert.match(disappear, + /this\.getUIContext\(\)\.setKeyboardAvoidMode\(this\.previousKeyboardAvoidMode\);/, + 'the chat page must restore the mode it found when it goes, so the rest of the shell keeps the platform default'); +}); + +test('the side gesture area is deliberately not tracked, and says so', () => { + // `TYPE_SYSTEM_GESTURE` is the region a system return gesture can start from, + // and the official immersive sample reads it beside the areas this app merges. + // The omission here is a recorded decision with a follow-up rather than an + // oversight, so the note and the behaviour are asserted together: the day one + // of them moves, the other has to move with it. + assert.match(windowService, /TYPE_SYSTEM_GESTURE/, + 'the decision not to track the gesture area must stay recorded next to the merge it belongs to'); + const insetsOf = normalize(windowService.slice(windowService.indexOf('private static insetsOf'))); + assert.equal(insetsOf.includes('TYPE_SYSTEM_GESTURE'), false, + 'reading the gesture area is a new decision: it moves where every full-width band starts, so it must not ' + + 'be merged in as a drive-by, and the note above the merge has to be rewritten with it'); +}); + +test('the binding and the broadcast keep the lifecycle branches their state depends on', () => { + // Four single lines carry the whole "a surface only reads insets while it is + // mounted, and the app only holds the window while a surface is" rule, and not + // one of them is load-bearing for any other assertion in this file. Each + // deletion breaks a different real path, so they are pinned by name. + // The broadcast is declared above the binding in the file, so each is read + // from its own declaration to the next one that follows it. + const broadcast = normalize(windowService.slice( + windowService.indexOf('class InsetsBroadcast'), + windowService.indexOf('export class WindowInsetsBinding'))); + const binding = normalize(windowService.slice(windowService.indexOf('export class WindowInsetsBinding'))); + assert.match(binding, /unbind\(\): void \{ this\.disposed = true;/, + 'unbind must mark the binding disposed: without it the shared broadcast keeps writing later changes into a ' + + 'surface that is already gone and the dead surface relayouts on every avoid-area change'); + assert.match(binding, + /const callback: WindowInsetListener = \(insets: WindowInsets\): void => \{ if \(this\.disposed\) \{ return; \}/, + 'the callback must keep its disposed guard: the broadcast delivers a change that can land across an unmount, ' + + 'and without the guard a surface that is no longer mounted is still written to'); + assert.match(binding, + /const previous = this\.subscription; this\.subscription = sharedInsetsBroadcast\.subscribe\(context, callback\); if \(previous\) \{ previous\(\); \}/, + 'bind must release its previous registration: binding twice would leave the first callback on the shared ' + + 'subscription, so the surface is updated twice and the broadcast counts a subscriber that no longer exists'); + assert.match(broadcast, /private close\(\): void \{ this\.opened = false;/, + 'close must clear opened: left true, the broadcast believes it is still open, so after the last surface ' + + 'closes and a new one opens the window listener is never rebuilt and the insets never update again'); +});