diff --git a/README.md b/README.md index e874a46..da62317 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. A clean checkout installs them with `npm ci`, without a source clone or sibling repository. 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). +[core-2.76.0-core.h076cc71aa1ce0bbe](https://github.com/ZenNotes/zennotes/releases/tag/core-2.76.0-core.h076cc71aa1ce0bbe) +(desktop commit `61628218`, clean tree). `npm run boundaries:check` verifies the pins, installed versions, singleton React/CodeMirror peers, and public export usage. `npm run core:adopt -- ` diff --git a/docs/safe-area-insets-validation-2026-10-23.md b/docs/safe-area-insets-validation-2026-10-23.md new file mode 100644 index 0000000..eb0f6ba --- /dev/null +++ b/docs/safe-area-insets-validation-2026-10-23.md @@ -0,0 +1,131 @@ +# Safe-area insets: verification, 23 October 2026 + +Branch `fix/safe-area-insets`, cut from `origin/main` at 6e0887e (1.31.0, +build 47). 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. + +This is the iPhone port of the Android shell's system bar fix +(zennotesandroid `fix/system-bar-insets`, from Dave's report on 2026-10-23: +Review sync changes drew its last line under the navigation bar). Both +shells carry the same `mobile.css` dialog-sheet and app-shell rules, so the +iPhone had the same geometry problems; only the insets differ. + +## Cause + +The iPhone shell has always drawn edge to edge (`viewport-fit=cover`, +`ios.contentInset: 'never'`), so `env(safe-area-inset-*)` carries the real +values and the CSS owns every inset: the status bar and Dynamic Island at +the top, the home indicator at the bottom, and in landscape the sensor +housing on one side with a matching inset on the other. Measured on the +iPhone 17 Pro simulator: portrait 402x874 with top 62 and bottom 34; +landscape 874x402 with left 62, right 62, bottom 20 and top 0; bottom 0 +while the keyboard is up. + +1. **Dialog sheets overflow into the bottom inset.** `mobile.css` turns core + dialogs into bottom sheets capped at `86dvh`, with the home indicator's + height as `padding-bottom` inside that cap. 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 `overflow: hidden` clips at the padding edge, so the body + paints into the strip under the home indicator. The overflow is + header + inset minus 14% of the screen height: 8 px on a 17 Pro in + portrait, 36 px in landscape (20 px under the indicator, 16 px off the + bottom of the screen, so the end of the body could not be reached). +2. **Full-screen surfaces ignore the bottom inset.** Settings' footer note + sat under the home indicator, and the search palette's results ran to + the screen bottom, so with a long list the last rows could never scroll + clear of it. +3. **Nothing honors the side insets.** In landscape the editor's lines, the + note header's back button, the More and Create sheets, dialog sheets, the + drawer, the search palette, Outline and Settings all ran under the sensor + housing side and the mirrored inset ("Finish later" in Review sync + changes sat in the right inset). + +It went unnoticed because the app is mostly used in portrait, where the +overflow is a few pixels into a mostly empty strip. + +## Fix (shell CSS only, same rules as the Android shell) + +- 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 and 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 separate 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. + +The comments describe the iPhone's insets (home indicator, sensor housing) +rather than Android's navigation bar; the selectors and values match the +Android change one for one, so the two shells stay in step. + +Deliberately not done: no app-core edit (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), and no +native change. + +Visible side effect: in landscape a dialog's tinted footer strip (for +example the Move to Trash confirm) now stops at the safe area, 62 px from +each edge, with the sheet's own background in the insets. Android has the +same. + +## Verification + +Scratch simulator `zn-safe-area-scratch` (iPhone 17 Pro, iOS 26.5, Xcode +27.1), Debug build of this branch and of `origin/main`. The page was driven +through a small eval channel injected into the scratch builds' `dist` +(never into the source), rotation through the simulator. A stand-in for +Review sync changes was mounted with the exact core 2.75.0 Modal, Header and +Body class strings, in two states: six queued files with the resolver still +loading (Dave's screenshot), and the resolver loaded (both versions, the +merge box and its buttons), since the simulator has no Cloud account to +raise a real conflict. The probe flags any button, link, status, input or +text whose clipped box reaches into a non-zero inset. + +| Case | Before (main, 1.31.0) | After | +| --- | --- | --- | +| Review sync changes, loading, portrait | fits (body 619 px, under its 629 px cap) | unchanged | +| Review sync changes, loaded, portrait | body ends at 848, indicator from 840 | body shrinks to end at 840; nothing to scroll | +| Review sync changes, loaded, landscape (both rotations) | body spans x 0 to 874, ends at 418 (screen 402): 20 px under the indicator, 16 px off-screen; text from x 20 under the sensor housing; Finish later at 762 to 854 | body spans x 62 to 812, ends at 382; scrolled to the end the last line ends at 366 | +| Home, More, Create, Browse, Search, Settings, Outline, landscape | probe hits on every surface (editor lines 32 to 842, back button at 16, sheet rows 14 to 860, drawer rows from 12, Cancel at 798 to 862) | no hits, both landscape rotations | +| Settings, portrait | "Settings save automatically on this device." at 828 to 873, under the indicator | clear | +| Search palette, portrait | results scroller ends at 874 | ends at 840; with the keyboard up the inset is 0 and it meets the keyboard | +| Move to Trash confirm, Rename prompt, landscape | | header and footer inside the safe area, same stacking as before | +| Settings, landscape | | keeps its grid; rows 75 to 799, Done at 733 to 798 | + +Before/after composites (insets tinted red) and every screenshot are in the +side-session notes folder `iphone-safe-area-insets/`, not in the repo; start +with `before-after-review-sync-landscape.png` and +`before-after-review-sync-portrait.png`. + +Automated: `npm test` 285 passed (the two new layout contracts fail on the +`origin/main` CSS and pass here); `npm run typecheck` clean; `npm run build` +and the simulator `xcodebuild` succeed. + +Not verified: a real Cloud conflict on a real iPhone (no account on the +simulator), shorter phones (the overflow grows as the screen gets shorter; +not measured), the toast host (no toast was raised), side panels such as +the calendar and the separate note-list column (not opened), iPad (the +dialog and app-shell rules are phone-gated, and iPad side insets are 0), and +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. Open `ios/App/App.xcworkspace` and run the App scheme on an iPhone 17 + Pro simulator. Fresh install: "Get started", then "On this iPhone". +3. Portrait: open the ensō, More, Settings: the footer note sits above the + home indicator (before: under it). +4. Rotate to landscape (Cmd+Left): the editor text, the More sheet, Browse + and Search stay clear of the sensor housing side (before: they ran under + it). Rotate the other way (Cmd+Right) and check again. +5. With a Cloud account and two devices, edit one note on both and sync: + Review sync changes keeps its body, its last line and its buttons inside + the safe area, portrait and landscape (before, in landscape, the end of + the body was off-screen). diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 5531803..db282e4 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -608,12 +608,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 47; + CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.31.0; + MARKETING_VERSION = 1.32.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -737,12 +737,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 47; + CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.31.0; + MARKETING_VERSION = 1.32.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -759,12 +759,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 47; + CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.31.0; + MARKETING_VERSION = 1.32.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -779,12 +779,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 47; + CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.31.0; + MARKETING_VERSION = 1.32.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -801,12 +801,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 47; + CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.31.0; + MARKETING_VERSION = 1.32.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -839,12 +839,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 47; + CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.31.0; + MARKETING_VERSION = 1.32.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/package-lock.json b/package-lock.json index 6541b91..6df461d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.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", @@ -3666,9 +3666,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", @@ -3698,8 +3698,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", @@ -3743,19 +3743,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 9290748..579b9b0 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.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/ui-mobile/mobile-layout.test.ts b/src/ui-mobile/mobile-layout.test.ts index bd3b4ac..6705dea 100644 --- a/src/ui-mobile/mobile-layout.test.ts +++ b/src/ui-mobile/mobile-layout.test.ts @@ -32,3 +32,54 @@ test('toasts on a phone sit where the dial column starts, clear of the ensō', ( assert.equal(declaration('.zn-phone [data-toast-host]', 'bottom'), declaration('.zn-mobile-fab-menu', 'bottom')) assert.equal(declaration('.zn-phone [data-toast-host]', 'align-items'), 'center') }) + +// The page draws edge to edge (viewport-fit=cover, contentInset never), so +// nothing may paint into an inset: Review sync changes left its last lines +// under the home indicator, and in landscape the sensor housing covered the +// editor, the sheets and the dialogs. Same contract as the Android shell's. +test('dialog sheets keep their content out of the home indicator and the sensor housing', () => { + const sheet = ".zn-mobile.zn-phone .z-modal[role='presentation'] > [role='dialog']" + assert.equal(declaration(sheet, 'padding-bottom'), 'env(safe-area-inset-bottom)') + assert.equal(declaration(sheet, 'padding-left'), 'env(safe-area-inset-left)') + assert.equal(declaration(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(declaration(sheet, 'overflow-y'), 'auto') + const block = `${sheet}:not(.flex):not(.grid)` + assert.equal(declaration(block, 'display'), 'flex') + assert.equal(declaration(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 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]' + ]) { + const padding = declaration(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(declaration(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 29817dd..da3a327 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -194,8 +194,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; } @@ -298,7 +298,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: the landscape sensor housing and its mirror (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); } @@ -638,9 +640,13 @@ 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 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. */ .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, @@ -750,12 +756,14 @@ top: 0; bottom: 0; left: 0; - width: min(84vw, 330px); + /* A left inset (the landscape sensor housing) 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); @@ -1206,6 +1214,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); } @@ -1222,9 +1231,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)); @@ -1235,7 +1245,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; @@ -1254,16 +1264,56 @@ 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, so the body's last lines ran under the home indicator + (and, in landscape, off the bottom of the screen). A panel that still + overflows scrolls instead, which puts the padding at the end of the + scroll; the sides clear the landscape sensor housing. Same rules as the + Android shell's dialog sheets. */ .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 @@ -1288,7 +1338,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 home indicator. 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; } @@ -1312,7 +1366,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; @@ -1361,7 +1415,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 an inset's: the footer note sat under the + home indicator and, in landscape, the sensor housing covered the rows. */ + 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); } @@ -1380,7 +1437,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; @@ -1406,7 +1463,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; @@ -1599,6 +1656,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). */ @@ -1892,9 +1960,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