diff --git a/README.md b/README.md index 0649dbb..e51fdce 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. No source checkout is used. The vendored set is the core release -[core-2.75.0-core.h4b1bd3ddd69c2aab](https://github.com/ZenNotes/zennotes/releases/tag/core-2.75.0-core.h4b1bd3ddd69c2aab) -(desktop commit `7e6506db`, clean tree). Run `npm run +[core-2.76.0-core.h076cc71aa1ce0bbe](https://github.com/ZenNotes/zennotes/releases/tag/core-2.76.0-core.h076cc71aa1ce0bbe) +(desktop commit `61628218`, clean tree). Run `npm run boundaries:check` to verify archives, installed versions, singleton editor/React peers, and imports; it refuses an archive built from a dirty upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out. diff --git a/android/app/build.gradle b/android/app/build.gradle index c52e519..6ed5e8f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 48 - versionName "1.1.45" + versionCode 49 + versionName "1.1.46" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/docs/system-bar-insets-validation-2026-10-23.md b/docs/system-bar-insets-validation-2026-10-23.md new file mode 100644 index 0000000..57a0376 --- /dev/null +++ b/docs/system-bar-insets-validation-2026-10-23.md @@ -0,0 +1,132 @@ +# System bar insets: verification, 23 October 2026 + +Branch `fix/system-bar-insets`, cut from `origin/main` at 65fda06 (1.1.45). +Committed locally, not pushed. Changes: `src/ui-mobile/mobile.css` and two +new contract tests in `src/ui-mobile/mobile-layout.test.ts`. No native code, +no Capacitor config and no app-core change. + +## Report + +Dave (Discord, 2026-10-23, Android 1.1.45 from Play, 3-button navigation): +the navigation buttons make app buttons ("Go back" and others) hard to tap. +His screenshots of Cloud's **Review sync changes** sheet show its last line, +"Loading the versions kept for you…", drawn under back/home/recents. + +## Cause + +Capacitor 8's SystemBars plugin picks one of two inset models by WebView +version (`SystemBars.java`, `insetsHandling: css`, the default): + +- **WebView < 140:** it pads the WebView natively, so the page sits between + the bars and every `env(safe-area-inset-*)` reads 0. +- **WebView 140 and later** (with `viewport-fit=cover`, which `index.html` + has): it drops the native padding and hands the insets to the page. The + page now draws under the status bar and the navigation bar, and + `env(safe-area-inset-*)` carries the real values. + +The emulators used so far ship WebView 133, so the shell's CSS was only ever +seen in the first model. Phones that update WebView from Play are in the +second, where three things were wrong: + +1. **Dialog sheets overflow into the bottom inset.** `mobile.css` turns core + dialogs into bottom sheets capped at `86dvh`, with the bar's height as + `padding-bottom` *inside* that cap (border-box). Core sizes some dialog + bodies in `vh` for the desktop: Review sync changes has a `72vh` + scroller under its header. Once the body reaches its cap, header + body + + inset exceed `86dvh`, and since `overflow: hidden` clips at the padding + edge, the body's bottom paints straight into the strip under the bar. On a + 360x800 phone the body ran 56 px past the sheet's content box: exactly + Dave's screenshot, and the reason buttons that scroll through that strip + ("Go back", "Keep this version") are hard to hit. +2. **Full-screen surfaces ignore the bottom inset.** The search/command + palettes' results run to the screen bottom (the last rows can never + scroll clear of the bar), and Settings' footer note sits under it. +3. **Nothing honors the side insets.** In landscape, 3-button navigation + moves to the right edge (left at 270 degrees) and the camera cutout takes + the other side. The app shell, the More/Create sheets, dialog sheets, + palettes, Settings, side panels, the drawer and toasts all ran under them + ("Finish later" sat under Back). + +`env()` itself is reliable on WebView 145: measured top 51 / bottom 48 with +3-button navigation, bottom 24 with gestures, and bottom 0 while the +keyboard is up, matching Capacitor's injected `--safe-area-inset-*`. + +## Fix (shell CSS only) + +- Dialog sheets: block panels become a flex column, so a vh-capped scroller + (the dialog body) shrinks to fit the cap and the header and footer stay in + view; panels core already lays out as `flex` or `grid` are left alone. + `overflow-y: auto` replaces the clip, so anything that still does not fit + scrolls with the inset at the end of the scroll instead of painting into + it. Left/right insets are padded. +- Palettes and Settings pad all four insets; their absolute corner buttons + (Cancel, Done, back) add the side inset. +- App shell, shell sheets and onboarding pad the side insets; the drawer + widens by the left inset; the note-list column pads left; side panels are + inset (not padded, since core gives them their own side padding) and their + Done button moves in; the toast host clears both sides. + +Deliberately not done: no switch back to native padding on new WebViews (it +would also paint the status and navigation bar strips in the fixed dark +window color for every theme), no change to Immersive Mode, and no app-core +edit. App-core does not need one for this; optionally, `CloudConflictDialog` +could lay its panel out as `flex flex-col` with a `min-h-0 flex-1` body in +place of the fixed `max-h-[72vh]` so other hosts get the same fit. + +## Verification + +Throwaway AVD `zn-navbar-insets-37` (Pixel 8, Google APIs **API 37, WebView +145.0.7632.218**), debug APK, driven over the WebView's CDP socket. Density +480 (360x800 CSS px, a common phone size) unless noted. A stand-in for +Review sync changes was mounted with the exact core 2.75.0 Modal, Header and +Body class strings (six queued files, resolver still loading), since the AVD +has no Cloud account to raise a real conflict; the shell CSS lays it out as +it does the real dialog. + +| Case | Before (1.1.45) | After | +| --- | --- | --- | +| Review sync changes, portrait, 3-button | last line at 757 to 841 px, bar from 752: under the buttons (Dave's screenshot) | body shrinks to end at 752; scrolled to the end the last line ends at 736 | +| Same, portrait, gesture | last line under the handle and off-screen | ends at 760, handle from 776 | +| Same, landscape 90 / 270, 3-button | "Finish later" under Back; body's last 21 px off-screen | every button and the last line inside the safe area, both rotations | +| Same, landscape, gesture | body's last line clipped by the screen edge | inside the safe area | +| Home and More sheet, landscape, 3-button | editor text, Read button and list rows under the side bar | probe finds nothing under any bar | +| Search palette, portrait | results scroller ends at 800, bar from 752 | ends at 752; with the keyboard up it still meets the keyboard (inset 0) | +| Settings, portrait | "Settings save automatically on this device." under the bar | footer ends at 751 | +| Drawer, side panel (Outline), landscape 270 | under the left bar | drawer rows start at 60 (inset 48); panel spans 48 to 756 | +| Trash confirm, Rename prompt | | unchanged layout; Rename sits directly on the keyboard | +| WebView 133 (API 36 AVD), portrait and landscape | | insets all 0 as before, app shell padding unchanged, conflict stand-in fully reachable | + +The probe flags any button, link, status, input or text block whose +clipped box reaches into a non-zero inset. Before/after screenshots and the +two composites (`before-after-review-sync-portrait.png`, +`-landscape.png`) are in the side-session notes folder +`android-system-bar-insets/`, not in the repo. + +Automated: `npm test` 308 passed (the two new layout contracts fail on the +`origin/main` CSS and pass here); `npm run typecheck` clean; +`npm run build` and `./gradlew assembleDebug` succeed. + +Not verified: a real Cloud conflict on a real device (no account on the +AVD), WebView 140 to 143 (the AVD images ship 133 or 145), API 29 with a +modern WebView (`neutralizeDoubleKeyboardInset` path, untouched here), and +TalkBack. + +## How to test locally + +1. `npm ci && npm run build && npx cap sync android && (cd android && ./gradlew assembleDebug)`. +2. Boot an emulator whose WebView is 140 or newer (the API 37 Google APIs + image ships 145), then: + `adb shell cmd overlay enable-exclusive --category com.android.internal.systemui.navbar.threebutton` + and, for a phone-sized viewport, `adb shell wm density 480`. +3. `adb install -r android/app/build/outputs/apk/debug/app-debug.apk`, launch, + "Get started", "Start writing". +4. Portrait: open the ensō, Search, dismiss the keyboard: the results end at + the top of the bar (before: under it). More, Settings: the footer note sits + above the bar. +5. Landscape (`adb shell settings put system user_rotation 1`): the editor + text, the More sheet and the Cloud footer stay clear of the right-hand + buttons; `user_rotation 3` moves the bar left, and the drawer (Browse) + clears it. +6. With a Cloud account and two devices, edit one note on both and sync: + Review sync changes keeps its last line and buttons above the bar, + portrait and landscape. diff --git a/package-lock.json b/package-lock.json index 7d0a616..1b845e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.45", + "version": "1.1.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.45", + "version": "1.1.46", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.1", "@capacitor/android": "^8.5.3", @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.75.0-core.h4b1bd3ddd69c2aab.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.75.0-boundaries.h755475e033d56c59.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.75.0-boundaries.h755475e033d56c59.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -3668,9 +3668,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.75.0-core.h4b1bd3ddd69c2aab", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.75.0-core.h4b1bd3ddd69c2aab.tgz", - "integrity": "sha512-wSOQe5S0owR1C0W6suWqwjuFLLRZhxvPh16Nj3xz6UD/nI6blzcWhOpOV+shN2u+Yocsi5Qle3UdWAzzsKmMFw==", + "version": "2.76.0-core.h076cc71aa1ce0bbe", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz", + "integrity": "sha512-z1j0s1yszCUjjnaw0zvVAF3tYUjj4WqDlbVbdswRABF8OmaSI15YjOpIwjnopi2YgsqQ+z7aq3DLg31MOYMMbQ==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3700,8 +3700,8 @@ "@replit/codemirror-vim": "^6.3.0", "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.75.0-boundaries.h755475e033d56c59", - "@zennotes/shared-domain": "2.75.0-boundaries.h755475e033d56c59", + "@zennotes/bridge-contract": "2.76.0-boundaries.he867787f4c1f2ebc", + "@zennotes/shared-domain": "2.76.0-boundaries.he867787f4c1f2ebc", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3745,19 +3745,19 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.75.0-boundaries.h755475e033d56c59", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.75.0-boundaries.h755475e033d56c59.tgz", - "integrity": "sha512-f3dXzCwu6X0JgdVCcAhQMa/tY/b+rPG0d2DfCC/2GVc0NtjgLFgwKx5RuqF8V6hyy+MGBDSezrTm1korvUHTNg==", + "version": "2.76.0-boundaries.he867787f4c1f2ebc", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz", + "integrity": "sha512-mPHMyDU5lKdbv1dZzcmSN6sqlxgiO+xcULirLg+vqjk4sd1LevLv6fy7mFuXNEP3PeE17wA9DAAElzOR2D05Lw==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.75.0-boundaries.h755475e033d56c59", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.75.0-boundaries.h755475e033d56c59.tgz", - "integrity": "sha512-X9G9j7oei/3X7LfTLcOP2o1AZYkHKlwSlAiizTEOd+VHVRESmJE67oFrwD/dNm/qCtGy9tum4w8deUwRRMm8Nw==", + "version": "2.76.0-boundaries.he867787f4c1f2ebc", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz", + "integrity": "sha512-ebanh7nYqtwwf+WFy8fzE8+IE+RI9biRW7ph7KJ8e57m/lz+yT7BrN1esI+Ei18INs/XB2zrZKJXXkLFtO2dHA==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.75.0-boundaries.h755475e033d56c59", + "@zennotes/bridge-contract": "2.76.0-boundaries.he867787f4c1f2ebc", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } diff --git a/package.json b/package.json index c6af427..07276dc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.45", + "version": "1.1.46", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", @@ -43,9 +43,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.75.0-core.h4b1bd3ddd69c2aab.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.75.0-boundaries.h755475e033d56c59.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.75.0-boundaries.h755475e033d56c59.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/bridge/cloud-sync-staging.test.ts b/src/bridge/cloud-sync-staging.test.ts index 3370203..48e4926 100644 --- a/src/bridge/cloud-sync-staging.test.ts +++ b/src/bridge/cloud-sync-staging.test.ts @@ -246,6 +246,52 @@ describe('native Cloud staging', () => { }), /changed on this device/) assert.equal(h.files.get('note.md')!.toString(), 'changed again') }) + + it('puts the other device\'s version where it went and moves this device\'s different file beside it', async () => { + // Dave's phone: the computer trashed the note the phone edited, and the + // phone had trashed an Untitled.md of its own into the same slot. + const edited = Buffer.from('# Untitled\n\nEdited on the phone.\n') + const scratch = Buffer.from('# Untitled\n\nThe phone\'s own scratch.\n') + const other = Buffer.from('# Untitled\n\nAlready the second one.\n') + const h = harness({ + 'inbox/Daily Notes/Untitled.md': edited, + 'trash/Daily Notes/Untitled.md': scratch, + 'trash/Daily Notes/Untitled 2.md': other + }) + const cloud = Buffer.from('# Untitled\n\nWritten on the computer.\n') + const ref = h.reference('item', 3, cloud, 'utf8') + h.objects.set('https://objects.example.test/item/3', cloud) + const file = await h.staging.stage(h.source(ref, 'https://objects.example.test/item/3')) + await h.staging.resolve({ + expected_path: 'inbox/Daily Notes/Untitled.md', expected_sha256: hash(edited), + cloud_path: 'trash/Daily Notes/Untitled.md', file + }) + assert.deepEqual(h.files.get('trash/Daily Notes/Untitled.md'), cloud) + assert.deepEqual(h.files.get('trash/Daily Notes/Untitled 3.md'), scratch) + assert.deepEqual(h.files.get('trash/Daily Notes/Untitled 2.md'), other) + assert.equal(h.files.has('inbox/Daily Notes/Untitled.md'), false) + }) + + it('moves this device\'s file back when the other device\'s version cannot be published', async () => { + const edited = Buffer.from('edited') + const scratch = Buffer.from('scratch') + const h = harness({ 'inbox/a.md': edited, 'trash/a.md': scratch }) + const cloud = Buffer.from('cloud') + const ref = h.reference('item', 2, cloud, 'utf8') + h.objects.set('https://objects.example.test/item/2', cloud) + const file = await h.staging.stage(h.source(ref, 'https://objects.example.test/item/2')) + const rename = h.native.rename + h.native.rename = async (from, to) => { + if (from.startsWith('.zennotes/sync/downloads/')) throw new Error('publish failed') + await rename(from, to) + } + await assert.rejects(h.staging.resolve({ + expected_path: 'inbox/a.md', expected_sha256: hash(edited), cloud_path: 'trash/a.md', file + }), /publish failed/) + assert.deepEqual(h.files.get('trash/a.md'), scratch) + assert.equal(h.files.has('trash/a 2.md'), false) + assert.deepEqual(h.files.get('inbox/a.md'), edited) + }) }) const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' diff --git a/src/bridge/cloud-sync-staging.ts b/src/bridge/cloud-sync-staging.ts index d8b8430..2d8bf8f 100644 --- a/src/bridge/cloud-sync-staging.ts +++ b/src/bridge/cloud-sync-staging.ts @@ -1,5 +1,5 @@ import type { CloudSyncChange, CloudSyncContentReference } from '@zennotes/bridge-contract/cloud-sync' -import { cloudSyncPathKey, normalizeCloudSyncPath, shouldSyncVaultPath } from '@zennotes/shared-domain/cloud-sync' +import { cloudSyncFreePath, cloudSyncPathKey, normalizeCloudSyncPath, shouldSyncVaultPath } from '@zennotes/shared-domain/cloud-sync' import { ZNE1_HEADER_BYTES, fromBase64, toBase64 } from '@zennotes/shared-domain/cloud-e2ee' import { cloudSyncStagedHandle, @@ -183,7 +183,15 @@ export class NativeCloudStaging { const local = expected ? await this.fingerprint(expected) : null if ((local?.sha256 ?? null) !== input.expected_sha256) throw new Error('This file changed on this device.') const replacing = expected !== null && cloudSyncPathKey(expected) === cloudSyncPathKey(cloud) - if (!replacing && await this.native.statVerified(cloud) !== null) throw new Error(`${cloud} already exists.`) + // The other device's version goes where that device put it. A different + // file of this device already there moves to the next free name beside it, + // the way a second note moved into that folder is named. Dave's phone + // (2026-10-23) had trashed an Untitled.md of its own into the slot the + // computer's Untitled.md went to, and keeping the phone's version was the + // only way out of the conflict. + const displacement = replacing ? null + : await this.displacementFor(cloud, new Set([cloud, ...(keep ? [keep] : [])].map(cloudSyncPathKey))) + let displaced = false if (keep) { if (!expected || !local) throw new Error('Both versions are no longer available.') if (await this.native.statVerified(keep) !== null) throw new Error(`${keep} already exists.`) @@ -204,8 +212,15 @@ export class NativeCloudStaging { if (expected && (await this.fingerprint(expected))?.sha256 !== input.expected_sha256) { throw new Error('This file changed on this device.') } + if (displacement) { + await this.native.rename(displacement.from, displacement.to) + displaced = true + } await this.publish(cloud, handle, input.file.reference, replacing ? input.expected_sha256 : null) } catch (error) { + if (displacement && displaced && await this.native.statVerified(displacement.from) === null) { + await this.native.rename(displacement.to, displacement.from).catch(() => {}) + } // Remove only our unchanged duplicate after the original is confirmed // safe. A later edit or incomplete rollback must retain both copies. if (keep && expected && local) { @@ -225,6 +240,23 @@ export class NativeCloudStaging { } } + /** + * The next free name beside `path` for a different file of this device + * that the other device's version has to replace, or null when the path is + * free: `Note 2.md`, then `Note 3.md`, the way core's cloudSyncFreePath + * names it on every host. Anything but a plain file keeps the choice + * refused. + */ + private async displacementFor(path: string, reserved: Set): Promise<{ from: string; to: string } | null> { + const occupant = await this.native.statVerified(path) + if (occupant === null) return null + if (occupant !== 'file') throw new Error(`${path} already exists.`) + const to = await cloudSyncFreePath(path, async (candidate) => + reserved.has(cloudSyncPathKey(candidate)) || await this.native.statVerified(candidate) !== null) + reserved.add(cloudSyncPathKey(to)) + return { from: path, to } + } + private async publish(path: string, handle: StagedHandle, reference: CloudSyncContentReference, expectedHash: string | null, previous?: CloudSyncTrackedItem): Promise { throwIfCloudSyncCancelled(handle.signal) if (!await this.matches(handle.path, reference)) throw new Error('The staged Cloud file changed before publication.') diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 56e07f6..3206790 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -155,7 +155,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.45' +let appVersion = '1.1.46' export async function loadNativeAppVersion(): Promise { try { diff --git a/src/ui-mobile/mobile-layout.test.ts b/src/ui-mobile/mobile-layout.test.ts index 7e1877d..4766577 100644 --- a/src/ui-mobile/mobile-layout.test.ts +++ b/src/ui-mobile/mobile-layout.test.ts @@ -64,3 +64,53 @@ test('toasts on a phone sit where the dial column starts, clear of the ensō (#1 assert.equal(groupedDeclaration('.zn-phone [data-toast-host]', 'bottom'), declaration('.zn-mobile-fab-menu', 'bottom')) assert.equal(groupedDeclaration('.zn-phone [data-toast-host]', 'align-items'), 'center') }) + +// Since WebView 140 the page draws under the system bars, so nothing may +// paint into an inset: Review sync changes left its last lines under the +// 3-button navigation bar, and a landscape bar covered right-edge buttons. +test('dialog sheets keep their content out of the navigation bar', () => { + const sheet = ".zn-mobile.zn-phone .z-modal[role='presentation'] > [role='dialog']" + assert.equal(groupedDeclaration(sheet, 'padding-bottom'), 'env(safe-area-inset-bottom)') + assert.equal(groupedDeclaration(sheet, 'padding-left'), 'env(safe-area-inset-left)') + assert.equal(groupedDeclaration(sheet, 'padding-right'), 'env(safe-area-inset-right)') + // overflow:hidden would clip at the padding edge and let overflow paint + // into the inset; a scroll puts the padding at the end instead. + assert.equal(groupedDeclaration(sheet, 'overflow-y'), 'auto') + const block = `${sheet}:not(.flex):not(.grid)` + assert.equal(groupedDeclaration(block, 'display'), 'flex') + assert.equal(groupedDeclaration(block, 'flex-direction'), 'column') + let scrollerShrinks = false + css.walkRules((rule) => { + if (rule.selector.includes(':not(.flex):not(.grid)') && rule.selector.includes('overflow-y-auto')) { + rule.walkDecls('min-height', (decl) => { scrollerShrinks = decl.value === '0' }) + } + }) + assert.ok(scrollerShrinks, 'a vh-capped scroller in a block panel must be able to shrink') +}) + +test('full-screen phone surfaces clear every system bar 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]' + ]) { + const padding = groupedDeclaration(selector, 'padding') ?? '' + for (const edge of ['top', 'right', 'bottom', 'left']) { + assert.ok(padding.includes(`env(safe-area-inset-${edge})`), `${selector} ${edge}`) + } + } + for (const selector of ['.zn-mobile-sheet', '.zn-phone .zn-mobile-drawer', '.zn-onboard']) { + assert.ok(groupedDeclaration(selector, 'padding')?.includes('env(safe-area-inset-left)'), selector) + } +}) + +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') +}) + +test('the date calendar hides its key help on phones and keeps Today on the right', () => { + const hint = '.zn-mobile.zn-phone [data-date-picker] div.justify-between > span:first-child:not([data-date-picker-month])' + assert.equal(declaration(hint, 'display'), 'none') + assert.equal(declaration(`${hint} + button`, 'margin-left'), 'auto') +}) diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index e0197e9..bb7e172 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -205,8 +205,8 @@ create row would cover the circle that made it. They sit where the dial's column starts instead, centered. */ .zn-phone [data-toast-host] { - left: 1rem; - right: 1rem; + left: calc(1rem + env(safe-area-inset-left)); + right: calc(1rem + env(safe-area-inset-right)); bottom: calc(0.75rem + 54px + 0.875rem + env(safe-area-inset-bottom) + var(--zn-cloud-footer-height, 0px)); align-items: center; } @@ -309,7 +309,9 @@ border-radius: 1.375rem 1.375rem 0 0; background: rgb(var(--z-bg-1)); box-shadow: 0 -18px 50px -20px rgb(0 0 0 / 0.6); - padding: 0.5rem 0.875rem calc(0.75rem + env(safe-area-inset-bottom)); + /* Side insets: a landscape navigation bar or cutout (0 in portrait). */ + padding: 0.5rem calc(0.875rem + env(safe-area-inset-right)) + calc(0.75rem + env(safe-area-inset-bottom)) calc(0.875rem + env(safe-area-inset-left)); animation: zn-sheet-up 0.26s cubic-bezier(0.32, 0.72, 0.22, 1); translate: 0 var(--zn-sheet-drag, 0px); transition: translate 180ms ease-out; @@ -685,9 +687,12 @@ smallestWidth-based and therefore rotation-invariant, and sharing it with wireKeyboard's resize-mode choice keeps CSS and JS from drifting apart. */ +/* The side insets are 0 in portrait. In landscape they hold the camera + cutout and, with 3-button navigation, the navigation bar itself, which + since WebView 140 draws over the page rather than beside it. */ .zn-mobile.zn-phone .zn-app-shell { - padding-top: env(safe-area-inset-top); - padding-bottom: calc(0.75rem + env(safe-area-inset-bottom)); + 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-phone .zn-mobile-fab, @@ -777,12 +782,14 @@ top: 0; bottom: 0; left: 0; - width: min(84vw, 330px); + /* A left inset (landscape cutout or navigation bar) widens the drawer + rather than narrowing its rows. */ + width: calc(min(84vw, 330px) + env(safe-area-inset-left)); z-index: 49; display: flex; flex-direction: column; padding: calc(env(safe-area-inset-top) + 0.875rem) 0.75rem - calc(env(safe-area-inset-bottom) + 0.5rem); + calc(env(safe-area-inset-bottom) + 0.5rem) calc(0.75rem + env(safe-area-inset-left)); background: rgb(var(--z-bg)); box-shadow: 12px 0 40px -18px rgb(var(--z-shadow) / 0.55); animation: zn-slide-in-left 0.24s cubic-bezier(0.32, 0.72, 0.22, 1); @@ -1224,6 +1231,7 @@ z-index: 47; padding-top: env(safe-area-inset-top); padding-bottom: calc(0.75rem + env(safe-area-inset-bottom)); + padding-left: env(safe-area-inset-left); background: rgb(var(--z-bg)); box-shadow: 12px 0 40px -18px rgb(var(--z-shadow) / 0.5); } @@ -1240,9 +1248,10 @@ position: fixed; top: 0; bottom: 0; - right: 0; - left: 0; - width: 100vw !important; + right: env(safe-area-inset-right); + left: env(safe-area-inset-left); + /* Inset rather than padded: core gives each panel its own side padding. */ + width: calc(100vw - env(safe-area-inset-left) - env(safe-area-inset-right)) !important; z-index: 47; padding-top: calc(env(safe-area-inset-top) + 2.75rem); padding-bottom: calc(0.75rem + env(safe-area-inset-bottom)); @@ -1253,7 +1262,7 @@ .zn-phone .zn-panel-close { position: fixed; top: calc(env(safe-area-inset-top) + 0.4375rem); - right: 0.875rem; + right: calc(0.875rem + env(safe-area-inset-right)); z-index: 48; min-height: 34px; padding: 0 0.875rem; @@ -1272,16 +1281,57 @@ padding: 0 !important; } +/* The safe-area padding sits inside the 86dvh cap, so anything the panel + cannot fit paints into it: overflow:hidden clips at the padding edge, not + the content edge. Core sizes some dialog bodies in vh for the desktop + (Review sync changes: a 72vh scroller under its header), which on a phone + overruns the cap and left the body's last lines and buttons under the + navigation bar. Since WebView 140 the page draws under the system bars + (Capacitor stops padding the WebView natively), so that strip is covered, + not merely empty. A panel that still overflows scrolls instead, which puts + the padding at the end of the scroll; the sides clear a landscape + navigation bar or camera cutout. */ .zn-mobile.zn-phone .z-modal[role='presentation'] > [role='dialog'], .zn-mobile.zn-phone .z-nested[role='presentation'] > [role='dialog'] { width: 100vw !important; max-width: 100vw !important; max-height: 86dvh; border-radius: 1.375rem 1.375rem 0 0 !important; + padding-right: env(safe-area-inset-right); padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + overflow-y: auto; + overscroll-behavior: contain; animation: zn-sheet-up 0.26s cubic-bezier(0.32, 0.72, 0.22, 1); } +/* Block panels (header, body, footer stacked) become a column whose + scrolling child gives up height first, so the body shrinks to the cap and + the header and footer stay in view. Panels core already lays out as a flex + column or a grid keep their own layout. */ +.zn-mobile.zn-phone .z-modal[role='presentation'] > [role='dialog']:not(.flex):not(.grid), +.zn-mobile.zn-phone .z-nested[role='presentation'] > [role='dialog']:not(.flex):not(.grid) { + display: flex; + flex-direction: column; +} + +.zn-mobile.zn-phone .z-modal[role='presentation'] > [role='dialog']:not(.flex):not(.grid) > *, +.zn-mobile.zn-phone .z-nested[role='presentation'] > [role='dialog']:not(.flex):not(.grid) > * { + flex-shrink: 0; +} + +.zn-mobile.zn-phone + .z-modal[role='presentation'] + > [role='dialog']:not(.flex):not(.grid) + > :is([class*='overflow-y-auto'], [class*='overflow-auto']), +.zn-mobile.zn-phone + .z-nested[role='presentation'] + > [role='dialog']:not(.flex):not(.grid) + > :is([class*='overflow-y-auto'], [class*='overflow-auto']) { + flex-shrink: 1; + min-height: 0; +} + /* ---- Palettes (search / commands / buffers) ------------------------------ */ /* Full-screen takeover, iOS-search style: the desktop floating dropdown @@ -1306,7 +1356,11 @@ max-height: none !important; border-radius: 0 !important; box-shadow: none !important; - padding-top: env(safe-area-inset-top); + /* The results run to the bottom edge, so without the bottom inset the + last rows could never scroll out from under the navigation bar. With + the keyboard up the inset reads 0 and the list meets the keyboard. */ + padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) + env(safe-area-inset-left); background: rgb(var(--z-bg)) !important; animation: zn-fade-in 0.15s ease; } @@ -1330,7 +1384,7 @@ .zn-mobile.zn-phone .zn-palette-cancel { position: absolute; top: calc(env(safe-area-inset-top) + 0.5rem); - right: 0.75rem; + right: calc(0.75rem + env(safe-area-inset-right)); z-index: 2; min-height: 36px; padding: 0 0.5rem; @@ -1379,7 +1433,10 @@ max-height: 100dvh !important; border-radius: 0 !important; grid-template-columns: minmax(0, 1fr) !important; - padding-top: env(safe-area-inset-top); + /* Full height, so every edge is a system bar's: the footer note sat under + the navigation bar and, in landscape, a side bar covered Done. */ + padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) + env(safe-area-inset-left); animation: zn-sheet-up 0.26s cubic-bezier(0.32, 0.72, 0.22, 1); } @@ -1398,7 +1455,7 @@ /* Vertically centered on the "SETTINGS" header line (nav-bar feel), clear of the search field below it. */ top: calc(env(safe-area-inset-top) + 0.4375rem); - right: 0.875rem; + right: calc(0.875rem + env(safe-area-inset-right)); z-index: 5; min-height: 34px; padding: 0 0.875rem; @@ -1424,7 +1481,7 @@ .zn-phone .zn-settings-back { position: absolute; top: calc(env(safe-area-inset-top) + 0.5rem); - left: 0.5rem; + left: calc(0.5rem + env(safe-area-inset-left)); z-index: 5; display: flex; align-items: center; @@ -1617,6 +1674,17 @@ display: none; } +/* The date calendar's key help ("Arrows move. PageUp/PageDown change the + month…"), which the task menu's Choose a date… opens too; the Today + button beside it keeps its place on the right. */ +.zn-mobile.zn-phone [data-date-picker] div.justify-between > span:first-child:not([data-date-picker-month]) { + display: none; +} + +.zn-mobile.zn-phone [data-date-picker] div.justify-between > span:first-child:not([data-date-picker-month]) + button { + margin-left: auto; +} + /* Command-palette shortcut chips ("⌘2", "Space m") — hardware-keyboard hints, meaningless on a soft keyboard (2.13 changed the chip markup, so the rule above no longer catches them). */ @@ -1910,9 +1978,10 @@ box-shadow: 0 1px 2px rgb(0 0 0 / 0.35); } -/* "Group by" rows (tasks Kanban + database Board) render a desktop-size - native select — thumb-size it. The only two