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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <core-tag>`
Expand Down
131 changes: 131 additions & 0 deletions docs/safe-area-insets-validation-2026-10-23.md
Original file line number Diff line number Diff line change
@@ -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).
24 changes: 12 additions & 12 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)";
Expand All @@ -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 = "";
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 15 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions src/bridge/cloud-sync-staging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand Down
Loading
Loading