Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs/status-bar-overlay-validation-2026-11-01.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Status bar overlay and bottom strip: verification, 1 November 2026

Branch `fix/ios-overlay-1035`, cut from `origin/main` at e2bf684 (1.35.0,
build 54). Committed locally, not pushed. Changes: `ios/App/App/ZNViewController.swift`,
`src/bridge/keyboard-backdrop.ts`, `src/main.tsx`, `src/ui-mobile/mobile.css`
and the layout contracts in `src/ui-mobile/mobile-layout.test.ts`. No
app-core change and no Capacitor config change.

Report: ZenNotes/zennotes#1035 (@Christian-SC26), iPhone 1.35.0 on iOS 27.2
in dark mode. A whitish, translucent band covers the status bar; it goes away
once the keyboard opens. A blank strip sits at the bottom of the screen.

## Cause

**The band is the iOS 27 scroll edge effect on the web view.** Apps built
with the iOS 27 SDK get the current design whatever
`UIDesignRequiresCompatibility` says (the key added for #631 only works on
iOS 26), so the WKWebView's scroll view carries the system's top edge effect
(`style=automatic backgroundCapture=sampled`, read with lldb from the running
1.35.0 build). The effect is a progressive blur over the top safe area. Near
the screen edge it samples past the page, which is transparent
(`ios.backgroundColor` `#00000000`), into the UIWindow behind it.

**The window's color is the keyboard backdrop's**, and that was only set at
boot and on `keyboardWillShow`. At boot `index.html`'s pre-boot script sets
`data-theme` from the stored `themeId` as is, even in Auto mode, and Auto
keeps the id of the variant the user picked (`apple-light`, say). The boot
sync read that light theme's `--z-bg` (white) into the window; core then
resolved Auto to `apple-dark`, and nothing repainted the window. The edge
effect blurred white into the status bar. Opening the keyboard ran the sync
again, so the window turned dark and the band went away: exactly the report.
The same thing happens without a cold launch when Auto follows the system
from light to dark while the app runs, or when a theme is picked in Settings.

Measured column, top 60 pt, dark Apple theme: the reporter's screenshot runs
126, 125, 124, 121, 117, 111, 102, 87, 69, 51, 38, 30, 28 (every 5 pt); the
simulator before the fix runs 127, 127, 125, 122, 119, 114, 105, 94, 77, 59,
45, 33, 29.

**The bottom strip is the shell's own padding.** `.zn-app-shell` padded
`0.75rem + env(safe-area-inset-bottom)` under every view (46 pt on an iPhone
16 Pro), so the note stopped at a hard line well above the home indicator.

## Fix

- **Native:** `ZNViewController` hides the web view's four scroll edge effects
(iOS 26 and later). The page draws its own chrome edge to edge and never
scrolls the document, so the effects have nothing to mark. This alone
removes the band, whatever color the window has.
- **Backdrop follows the theme:** `watchThemeBackdrop()` replaces the one-off
boot sync. It re-reads `--z-bg` whenever core changes `data-theme` or
`data-theme-mode` on `<html>`, or the managed `<style>` elements in `<head>`
(custom themes, tweaks), coalesced to one frame and deduplicated, so the
window around the rounded keyboard and behind the web view always matches
the theme. The `keyboardWillShow` sync stays as a cheap second check.
- **Bottom edge:** the shell no longer pads the bottom. Views run under the
home indicator to the screen edge, and every view's main scroller (a
`flex-1` overflow element: Home, Read mode, Tasks, Tags, the collections, a
database table) ends with a `::after` spacer of the old clearance
(`--zn-shell-bottom`), so the last line still scrolls clear. The editor's
scroller already ends with core's fifth-of-a-screen padding. Kept as before:
the Kanban board pads itself so its columns stop above the indicator; side
panels and the separate note-list column pad themselves and add no spacer;
with the keyboard up the clearance is 0, as the shell's padding was; Cloud's
attention row, the last thing in the shell, carries the clearance under its
44 px so its action and the circle sit where they did.

## Verification

Scratch simulator `zn-1035-scratch` (iPhone 16 Pro, iOS 27.2 24B8117, Xcode
27.2), Debug builds of `origin/main` and of this branch. The page was driven
through an eval channel injected into the scratch builds' `dist` only, touch
through a scratch XCUITest driver (an existing UI test file replaced locally
and restored afterwards), native state read with lldb. Both were removed and
`dist` rebuilt clean.

| Case | Before (1.35.0) | After |
| --- | --- | --- |
| Cold launch, Auto + Apple theme (stored `apple-light`), iPhone in dark mode | whitish band, top 127 fading to the background by 60 pt | top exactly the background (28 28 30) |
| Window forced white through the backdrop plugin | band 127 to 29 (the report's profile) | no change at the top |
| Stripes painted under the status bar | blurred and dimmed over the top 50 pt | crisp |
| Edge effects (lldb), after a keyboard cycle and appearance flips | top/bottom/left/right shown | all four `isHidden == YES` |
| Window color (lldb) after the system flips light, then dark | stays the boot color | white, then 28 28 30 |
| Note in Edit mode, at rest | text stops at 828, blank to 874 | text runs to 874 |
| End of scroll: Read mode, Home, Tasks, Tags, Archive, Trash, database table | | scroller reaches 874 and ends with a 46 px spacer; Read mode's last line at 462 |
| Kanban board | columns end at 816 | columns end at 816 (board padded 58 px), card lists add no spacer |
| Outline panel | | panel pads 46 px, its scroller adds no spacer |
| Cloud attention row (phase forced to `attention`) | row 784 to 828, circle bottom 784 | row content 784 to 828, padded to 874, circle bottom 784 |
| Keyboard open and closed in a note | | toolbar and keyboard as before, top stays clean |
| iPhone in light mode | blank strip at the bottom | text runs to the edge; no band in either version |

Automated: `npm test` 416 passed (the new layout contract fails on
1.35.0's `mobile.css` and passes here); `npm run typecheck` clean;
`npm run build`, `cap copy ios` and the simulator `xcodebuild` succeed.

Screenshots (before/after in dark and light, and the reporter's top strip
next to both) are in the side-session notes folder `iphone-status-bar-1035/`,
not in the repo.

## Android

No matching change. The band needs UIKit's scroll edge effects behind a
transparent WKWebView and the iOS-only backdrop plugin; Android has neither.
At the bottom, Android shows core's status row with the Cloud status at all
times (#48: its word-count hide no longer matches core's `px-3 sm:px-6`), so
the inset under it is that row's navigation-bar clearance, not dead space.
Porting the shell rule there would only add a spacer above the row. If
Android ever hides that row the way the iPhone does, port these rules then.

## Not verified

A real iOS 27.2 device; landscape (no rotation without the simulator panel;
the spacer uses the same inset, 20 pt in landscape); iPad (the CSS is
phone-gated, the native change applies there too); a real Cloud account in
an attention phase (forced through the DOM instead); custom themes and theme
tweaks (covered by the `<head>` observer, not exercised); VoiceOver.

## How to test locally

1. In this worktree: `npm ci && npm run build`, then
`LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 npx cap sync ios`.
2. Run the App scheme on an iOS 27 iPhone simulator. Fresh install: "Get
started", then "On this iPhone".
3. With the simulator in light mode: ••• More, Settings, Appearance, mode
Auto, then pick the Apple theme (Auto stores the light variant's id).
Switch the simulator to dark (Features, Toggle Appearance) and relaunch
the app. Before: a whitish band over the status bar until a keyboard
opens. After: the status bar sits on the plain dark background, and stays
clean when the appearance is toggled while the app runs.
4. In any note: the text runs to the bottom edge (before: a blank strip about
46 pt tall); scroll to the end and the last line rests above the home
indicator. Home, Tasks and a database scroll the same way.
5. Tasks, Kanban: the columns still stop above the home indicator.
19 changes: 19 additions & 0 deletions ios/App/App/ZNViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ class ZNViewController: CAPBridgeViewController {
bridge?.registerPluginInstance(WidgetBridgePlugin())
bridge?.registerPluginInstance(CloudFilesPlugin())
bridge?.registerPluginInstance(BackgroundSyncPlugin())
hideScrollEdgeEffects()
}

/// The page draws its own chrome edge to edge and never scrolls the
/// document (every view scrolls an inner element), so the system's scroll
/// edge effects on the web view have nothing to mark. Apps built with the
/// iOS 27 SDK get them whatever UIDesignRequiresCompatibility says, and
/// the top one blurs what lies behind the transparent web view into the
/// status bar: the window, whose color is the keyboard backdrop's, so a
/// window still holding a light theme's color drew a whitish band over a
/// dark theme (ZenNotes/zennotes#1035).
private func hideScrollEdgeEffects() {
guard #available(iOS 26.0, *), let scrollView = webView?.scrollView else { return }
for effect in [
scrollView.topEdgeEffect, scrollView.leftEdgeEffect,
scrollView.bottomEdgeEffect, scrollView.rightEdgeEffect
] {
effect.isHidden = true
}
}

override open func viewDidAppear(_ animated: Bool) {
Expand Down
35 changes: 34 additions & 1 deletion src/bridge/keyboard-backdrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,43 @@ interface KeyboardBackdropPlugin {

const NativeKeyboardBackdrop = registerPlugin<KeyboardBackdropPlugin>('ZenKeyboardBackdrop')

let sentColor = ''

/** Keep the native window exposed around iOS's rounded keyboard on-theme. */
export function syncKeyboardBackdrop(): void {
const rawColor = getComputedStyle(document.documentElement).getPropertyValue('--z-bg')
const color = parseThemeBackdropColor(rawColor)
if (!color) return
void NativeKeyboardBackdrop.setColor(color).catch(() => {})
const key = `${color.red} ${color.green} ${color.blue}`
if (key === sentColor) return
sentColor = key
void NativeKeyboardBackdrop.setColor(color).catch(() => {
sentColor = ''
})
}

/**
* The window sits behind the transparent WebView, so its color shows wherever
* the page does not paint, and it has to follow the theme as it changes, not
* only when a keyboard opens: index.html's pre-boot theme can be the light
* variant of an Auto theme that core then resolves dark, and Auto follows the
* system while the app runs. A light window under a dark theme was the whitish
* band over the status bar in ZenNotes/zennotes#1035. Core sets the theme on
* <html> and keeps custom themes and tweaks in <style> elements in <head>.
*/
export function watchThemeBackdrop(): void {
let frame = 0
const schedule = (): void => {
if (!frame) frame = requestAnimationFrame(() => {
frame = 0
syncKeyboardBackdrop()
})
}
const observer = new MutationObserver(schedule)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-theme-mode']
})
observer.observe(document.head, { childList: true, subtree: true, characterData: true })
syncKeyboardBackdrop()
}
9 changes: 5 additions & 4 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
mobileBackgroundSyncHost
} from './bridge/mobile-bridge'
import { installBackgroundSync } from './bridge/background-sync'
import { syncKeyboardBackdrop } from './bridge/keyboard-backdrop'
import { syncKeyboardBackdrop, watchThemeBackdrop } from './bridge/keyboard-backdrop'
import { configureMobileCloudAuth } from './bridge/mobile-cloud-auth'
import { maybeRunFirstRunOnboarding } from './ui-mobile/Onboarding'
import { installWidgetPublisher } from './bridge/widgets'
Expand All @@ -35,9 +35,10 @@ import './ui-mobile/mobile.css'

function wireKeyboard(): void {
// The rounded iOS 26+ keyboard exposes the native UIWindow after Capacitor
// shortens the WKWebView. Match it to the active ZenNotes theme before the
// first keyboard and refresh it on every show in case the theme changed.
syncKeyboardBackdrop()
// shortens the WKWebView. Match it to the active ZenNotes theme now and on
// every theme change; each show checks again, which costs nothing when the
// color is the one already sent.
watchThemeBackdrop()
// Tablets: hardware keyboards / the floating mini-keyboard still report a
// "keyboard frame", and Native resize would shrink the WebView leaving a
// black band where no keyboard is. Don't resize there — the toolbar lifts
Expand Down
33 changes: 32 additions & 1 deletion src/ui-mobile/mobile-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ test('dialog sheets keep their content out of the home indicator and the sensor

test('full-screen phone surfaces clear every inset edge, landscape sides included', () => {
for (const selector of [
'.zn-mobile.zn-phone .zn-app-shell',
'.zn-mobile.zn-phone .z-palette > [role=\'dialog\']',
'.zn-mobile.zn-phone [data-zn-settings]'
]) {
Expand All @@ -68,11 +67,43 @@ test('full-screen phone surfaces clear every inset edge, landscape sides include
assert.ok(padding.includes(`env(safe-area-inset-${edge})`), `${selector} ${edge}`)
}
}
const shell = declaration('.zn-mobile.zn-phone .zn-app-shell', 'padding') ?? ''
for (const edge of ['top', 'right', 'left']) {
assert.ok(shell.includes(`env(safe-area-inset-${edge})`), `app shell ${edge}`)
}
for (const selector of ['.zn-mobile-sheet', '.zn-phone .zn-mobile-drawer', '.zn-onboard']) {
assert.ok(declaration(selector, 'padding')?.includes('env(safe-area-inset-left)'), selector)
}
})

// Padding the shell's bottom left a blank strip under every view, above the
// home indicator (ZenNotes/zennotes#1035). Views run to the screen edge and
// their scrollers end with the clearance, so the last line still scrolls
// clear; sheets that pad the indicator themselves add none.
test('views run under the home indicator and their scrollers end clear of it', () => {
const shell = '.zn-mobile.zn-phone .zn-app-shell'
assert.ok(!(declaration(shell, 'padding') ?? '').includes('safe-area-inset-bottom'))
assert.equal(declaration(shell, '--zn-shell-bottom'), 'calc(0.75rem + env(safe-area-inset-bottom))')
for (const scroller of [
`${shell} [class~='flex-1'][class*='overflow-y-auto']::after`,
`${shell} [class~='flex-1'][class~='overflow-auto']::after`,
`${shell} [data-preview-scroll]::after`
]) {
assert.equal(declaration(scroller, 'height'), 'var(--zn-shell-bottom)', scroller)
}
assert.equal(declaration('.zn-mobile.zn-phone section.glass-column', '--zn-shell-bottom'), '0px')
assert.equal(declaration('.zn-mobile.zn-phone [data-connections-panel]', '--zn-shell-bottom'), '0px')
assert.equal(declaration('.zn-mobile.zn-phone.zn-kb-open .zn-app-shell', '--zn-shell-bottom'), '0px')
// Cloud's attention row is the last thing in the shell: it carries the
// clearance under its 44px, so its action stays where the circle expects it.
let row = false
css.walkRules((rule) => {
if (!rule.selector.includes('div.h-8.shrink-0.justify-between.text-xs:has(')) return
rule.walkDecls('padding-bottom', (decl) => { row = decl.value === 'var(--zn-shell-bottom, 0px)' })
})
assert.ok(row, 'the Cloud row pads the home indicator itself')
})

test('a select row stacked in a column keeps its height; one on a toolbar line still fills it', () => {
assert.equal(declaration('.zn-mobile.zn-phone .zn-app-shell div:has(> select)', 'flex'), '1')
assert.equal(declaration('.zn-mobile.zn-phone .zn-app-shell .flex-col > div:has(> select)', 'flex'), 'none')
Expand Down
45 changes: 39 additions & 6 deletions src/ui-mobile/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -659,10 +659,36 @@
/* The side insets are 0 in portrait. In landscape they hold the sensor
housing (notch or Dynamic Island) on one side and its mirror on the other;
the page draws edge to edge (viewport-fit=cover, contentInset never), so
without them the editor's lines ran under the island. */
without them the editor's lines ran under the island.
The bottom is not padded: views run under the home indicator to the screen
edge, the way iOS lists and documents do, and each view's scroller ends
with the clearance instead, so its last line still scrolls clear of the
indicator. Padding the shell left a blank strip under every view
(ZenNotes/zennotes#1035). */
.zn-mobile.zn-phone .zn-app-shell {
padding: env(safe-area-inset-top) env(safe-area-inset-right)
calc(0.75rem + env(safe-area-inset-bottom)) env(safe-area-inset-left);
--zn-shell-bottom: calc(0.75rem + env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
}

/* A view's main scroller is a flex-1 overflow element (Home, a note in Read
mode, Tasks, Tags, the collections, a database's table). The editor's
scroller is not, and core already ends it with a fifth of the screen. */
.zn-mobile.zn-phone .zn-app-shell [class~='flex-1'][class*='overflow-y-auto']::after,
.zn-mobile.zn-phone .zn-app-shell [class~='flex-1'][class~='overflow-auto']::after,
.zn-mobile.zn-phone .zn-app-shell [data-preview-scroll]::after {
content: '';
display: block;
flex: none;
grid-column: 1 / -1;
height: var(--zn-shell-bottom);
}

/* The Kanban board scrolls sideways; its columns are panels, so they stop
short of the indicator as before, and the card lists inside them need no
clearance of their own. */
.zn-mobile.zn-phone .zn-app-shell [class*='overflow-x-auto']:has(> [data-kanban-column-id]) {
--zn-shell-bottom: 0px;
padding-bottom: calc(1.5rem + env(safe-area-inset-bottom));
}

.zn-phone .zn-mobile-fab,
Expand Down Expand Up @@ -724,7 +750,9 @@
the core's data-cloud-sync-* hooks, never on its Tailwind tone classes.
Same geometry as the Android shell's Cloud row: 44px row, 44px action, FAB
lifted by --zn-cloud-footer-height (see .zn-mobile-fab). While the
keyboard is up the format toolbar owns that strip, so the row stays out. */
keyboard is up the format toolbar owns that strip, so the row stays out.
The row is the last thing in the shell, which no longer pads the home
indicator, so it carries that clearance under its 44px itself. */
.zn-mobile.zn-phone:has(
[data-cloud-sync-phase='attention'],
[data-cloud-sync-phase='error'],
Expand All @@ -740,7 +768,8 @@
[data-cloud-sync-review]
) {
display: flex;
height: var(--zn-cloud-footer-height);
height: calc(var(--zn-cloud-footer-height) + var(--zn-shell-bottom, 0px));
padding-bottom: var(--zn-shell-bottom, 0px);
}

.zn-mobile.zn-phone [data-cloud-sync-action] {
Expand Down Expand Up @@ -1239,6 +1268,8 @@
padding-top: env(safe-area-inset-top);
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
padding-left: env(safe-area-inset-left);
/* The column pads the indicator itself; its list adds none. */
--zn-shell-bottom: 0px;
background: rgb(var(--z-bg));
box-shadow: 12px 0 40px -18px rgb(var(--z-shadow) / 0.5);
}
Expand All @@ -1265,6 +1296,8 @@
z-index: 47;
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
/* The sheet pads the indicator itself; its scrollers add none. */
--zn-shell-bottom: 0px;
background: rgb(var(--z-bg));
animation: zn-slide-in-right 0.24s cubic-bezier(0.32, 0.72, 0.22, 1);
}
Expand Down Expand Up @@ -2629,7 +2662,7 @@
}

.zn-mobile.zn-phone.zn-kb-open .zn-app-shell {
padding-bottom: 0;
--zn-shell-bottom: 0px;
}

/* ---- First-open onboarding ------------------------------------------------- */
Expand Down
Loading