From f10fbe9df80559547963f0527c86fc9c4e908a6f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 18:29:14 -0500 Subject: [PATCH 1/4] fix(mobile): the status bar's clock and icons stay readable on the theme On a phone whose WebView draws the page under the system bars (WebView 140 and up, which Play keeps current), the clock, signal and battery drew near-black on the dark ZenNotes theme, and the navigation buttons did the same. capacitor.config.ts asks the StatusBar plugin for light icons, but core's SystemBars plugin loads too, resolves its default style from the system's night mode and posts it to the main thread after StatusBar has applied the config, so it always won: dark icons whenever the phone was in light mode. Both plugins also re-apply their own stale choice on every configuration change, so turning the system dark while the app ran kept the dark icons, and a light ZenNotes theme in a dark system got light icons on a light page. Neither plugin knows the theme. The shell now reads the theme where it lands, app-core's --z-bg on the root, so built-in themes, Auto, custom CSS themes and color tweaks all count, and hands it to a small Android-only plugin, SystemBarTheme, whenever it changes. The plugin paints the strips the app owns with the theme's background (the decor view, which shows behind the bars below WebView 140, and the window's bar colors before Android 15) and gives the icons whichever of white or black contrasts more with it. After a configuration change it re-applies once the other two plugins have had their turn. Dark navigation buttons need Android 8, so Android 7 keeps the launch theme's navigation bar. It does not touch the launch theme, the splash or the StatusBar config: until the WebView has measured the theme the app keeps its dark launch chrome, as before. Checked on an API 37 emulator (WebView 145): with the system in light mode the icons are white over the dark theme and turn dark the moment a light theme is picked; they keep following the theme through system dark-mode toggles, a resume and a rotation. On API 35 (WebView 124) the strip behind the bars now takes the light theme's background, with dark icons. Tests: system-bar-theme-color.test.ts checks every built-in theme's background against its mode and the contrast rule. --- .../main/java/md/zennotes/MainActivity.java | 1 + .../md/zennotes/SystemBarThemePlugin.java | 96 +++++++++++++++++++ src/bridge/system-bar-theme-color.test.ts | 73 ++++++++++++++ src/bridge/system-bar-theme-color.ts | 41 ++++++++ src/bridge/system-bar-theme.ts | 56 +++++++++++ src/main.tsx | 3 + 6 files changed, 270 insertions(+) create mode 100644 android/app/src/main/java/md/zennotes/SystemBarThemePlugin.java create mode 100644 src/bridge/system-bar-theme-color.test.ts create mode 100644 src/bridge/system-bar-theme-color.ts create mode 100644 src/bridge/system-bar-theme.ts diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index cdb2429..f87d8b0 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -40,6 +40,7 @@ public void onCreate(Bundle savedInstanceState) { registerPlugin(ImagePastePlugin.class); registerPlugin(EdgeSwipePlugin.class); registerPlugin(OpenFilePlugin.class); + registerPlugin(SystemBarThemePlugin.class); super.onCreate(savedInstanceState); // Cold-start share: the launch intent IS the share. Stash it now; the // WebView drains the inbox after the vault opens (importPendingShares). diff --git a/android/app/src/main/java/md/zennotes/SystemBarThemePlugin.java b/android/app/src/main/java/md/zennotes/SystemBarThemePlugin.java new file mode 100644 index 0000000..0133b04 --- /dev/null +++ b/android/app/src/main/java/md/zennotes/SystemBarThemePlugin.java @@ -0,0 +1,96 @@ +package md.zennotes; + +import android.content.res.Configuration; +import android.graphics.Color; +import android.os.Build; +import android.view.Window; + +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsControllerCompat; + +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +/** + * Status and navigation bar icons in the ZenNotes theme's contrast, not the + * system's. Two Capacitor plugins drive the bars' icon color and neither + * knows the theme: core's SystemBars resolves its default style from the + * system's night mode when it loads (dark icons whenever the phone is in + * light mode) and posts it after the StatusBar plugin has applied the + * config's light icons, so it always wins; both then re-apply their own + * stale choice on every configuration change. On a WebView 140+ phone the + * page draws under the bars, so a dark theme got near-black icons on its + * dark background. + * + * The JS shell (src/bridge/system-bar-theme.ts) reads the theme's background + * and hands it here whenever it changes. This paints every strip the app + * owns with it, so what sits under the icons is the theme on any Android and + * WebView: from Android 15 the bars are transparent and below WebView 140 + * Capacitor pads the decor view, whose background shows through; before + * Android 15 the window's bar colors are what show. Then the icons take the + * contrasting color. A configuration change (rotation, the system's dark + * mode) re-applies it after the other two plugins have had their say. + * Android-only; the iPhone status bar sits over the page already. + */ +@CapacitorPlugin(name = "SystemBarTheme") +public class SystemBarThemePlugin extends Plugin { + + /** Null until the JS shell has measured the theme: the launch theme's + * dark chrome stays until then. */ + private Integer color = null; + private boolean light = false; + + @PluginMethod + public void setTheme(PluginCall call) { + String raw = call.getString("color"); + if (raw == null || raw.isEmpty()) { + call.reject("color must be #rrggbb"); + return; + } + int parsed; + try { + parsed = Color.parseColor(raw); + } catch (IllegalArgumentException e) { + call.reject("color must be #rrggbb"); + return; + } + boolean isLight = Boolean.TRUE.equals(call.getBoolean("light", false)); + getActivity().runOnUiThread(() -> { + color = parsed; + light = isLight; + apply(); + call.resolve(); + }); + } + + @Override + protected void handleOnConfigurationChanged(Configuration newConfig) { + super.handleOnConfigurationChanged(newConfig); + // SystemBars and StatusBar re-apply their styles in this same pass, + // in an order nothing promises; go after both. + getActivity().getWindow().getDecorView().post(this::apply); + } + + @SuppressWarnings("deprecation") + private void apply() { + if (color == null) return; + Window window = getActivity().getWindow(); + window.getDecorView().setBackgroundColor(color); + boolean edgeToEdge = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM; + if (!edgeToEdge) { + window.setStatusBarColor(color); + } + WindowInsetsControllerCompat bars = WindowCompat.getInsetsController(window, window.getDecorView()); + bars.setAppearanceLightStatusBars(light); + // Dark navigation buttons arrived in Android 8; before that a light + // bar would hide its white buttons, so it keeps the launch theme's. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!edgeToEdge) { + window.setNavigationBarColor(color); + } + bars.setAppearanceLightNavigationBars(light); + } + } +} diff --git a/src/bridge/system-bar-theme-color.test.ts b/src/bridge/system-bar-theme-color.test.ts new file mode 100644 index 0000000..10b04d5 --- /dev/null +++ b/src/bridge/system-bar-theme-color.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { systemBarThemeFor } from './system-bar-theme-color.ts' + +// `--z-bg` of every built-in theme in core 2.78.0 (dist/styles/index.css). +const DARK = { + 'apple-dark': '28 28 30', + 'dark-hard': '29 32 33', + 'dark-medium': '40 40 40', + 'dark-soft': '50 48 47', + 'catppuccin-frappe': '48 52 70', + 'catppuccin-macchiato': '36 39 58', + 'catppuccin-mocha': '30 30 46', + 'rose-pine-main': '25 23 36', + 'rose-pine-moon': '35 33 54', + 'github-dark': '13 17 23', + 'github-dark-dimmed': '34 39 46', + 'github-dark-high-contrast': '0 0 0', + 'solarized-dark': '0 43 54', + 'one-dark': '40 44 52', + 'nord-dark': '46 52 64', + 'tokyo-night-storm': '36 40 59', + 'kanagawa-wave': '31 31 40', + 'kanagawa-dragon': '24 22 22', + 'kanagawa-paper-ink': '20 19 27', + 'black-metal': '0 0 0' +} +const LIGHT = { + 'apple-light': '255 255 255', + 'light-hard': '249 245 215', + 'light-medium': '251 241 199', + 'light-soft': '242 229 188', + 'catppuccin-latte': '239 241 245', + 'rose-pine-dawn': '250 244 237', + 'github-light': '255 255 255', + 'solarized-light': '253 246 227', + 'one-light': '250 250 250', + 'nord-light': '236 239 244', + 'tokyo-night-day': '225 226 231', + 'kanagawa-lotus': '242 236 188', + 'black-metal-day': '244 244 242' +} + +describe('systemBarThemeFor', () => { + it('gives every dark built-in theme light icons', () => { + for (const [id, bg] of Object.entries(DARK)) { + assert.equal(systemBarThemeFor(bg)?.light, false, id) + } + }) + + it('gives every light built-in theme dark icons', () => { + for (const [id, bg] of Object.entries(LIGHT)) { + assert.equal(systemBarThemeFor(bg)?.light, true, id) + } + }) + + it('hands the native side the background as #rrggbb', () => { + assert.deepEqual(systemBarThemeFor('29 32 33'), { color: '#1d2021', light: false }) + assert.deepEqual(systemBarThemeFor(' 251 241 199 '), { color: '#fbf1c7', light: true }) + }) + + it('picks the icon color with more contrast around the middle', () => { + // Relative luminance about 0.16 and 0.20, either side of the tie. + assert.equal(systemBarThemeFor('112 112 112')?.light, false) + assert.equal(systemBarThemeFor('124 124 124')?.light, true) + }) + + it('ignores anything that is not three channels', () => { + for (const raw of ['', '29 32', '29 32 33 1', 'rgb(29, 32, 33)', '#1d2021', '29 32 256', '29.5 32 33']) { + assert.equal(systemBarThemeFor(raw), null, raw) + } + }) +}) diff --git a/src/bridge/system-bar-theme-color.ts b/src/bridge/system-bar-theme-color.ts new file mode 100644 index 0000000..2db663c --- /dev/null +++ b/src/bridge/system-bar-theme-color.ts @@ -0,0 +1,41 @@ +/** + * The system bars' look for a ZenNotes theme: the theme's background, and + * whether the bar icons go dark on it. Pure, so node --test covers it; the + * wiring is in system-bar-theme.ts. + */ +export interface SystemBarTheme { + /** `#rrggbb`: what the native side paints the bars' strips with. */ + color: string + /** Dark icons (a light background) rather than light ones. */ + light: boolean +} + +/** sRGB channel to linear light, as WCAG's relative luminance has it. */ +function linear(channel: number): number { + const c = channel / 255 + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 +} + +/** + * From app-core's `--z-bg` ("29 32 33": the channels, space separated). + * Null for anything else, so a theme mid-swap never reaches the bars. + * + * The icons take whichever of white or black contrasts more with the + * background. The two tie at a relative luminance of about 0.179, well away + * from every built-in theme (dark ones sit under 0.06, light ones over 0.6). + */ +export function systemBarThemeFor(rawBackground: string): SystemBarTheme | null { + const channels = rawBackground.trim().split(/\s+/).map(Number) + if ( + channels.length !== 3 || + channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255) + ) { + return null + } + const [r, g, b] = channels as [number, number, number] + const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + const contrastWithWhite = 1.05 / (luminance + 0.05) + const contrastWithBlack = (luminance + 0.05) / 0.05 + const hex = channels.map((channel) => channel.toString(16).padStart(2, '0')).join('') + return { color: `#${hex}`, light: contrastWithBlack > contrastWithWhite } +} diff --git a/src/bridge/system-bar-theme.ts b/src/bridge/system-bar-theme.ts new file mode 100644 index 0000000..725f4b5 --- /dev/null +++ b/src/bridge/system-bar-theme.ts @@ -0,0 +1,56 @@ +/** + * Keeps Android's status and navigation bar icons readable on the ZenNotes + * theme: light icons on a dark theme, dark ones on a light theme, following + * every theme switch while the app runs. The system's own light or dark mode + * is not the question; the page under the icons is (SystemBarThemePlugin.java + * has why neither Capacitor bar plugin gets this right). + * + * The theme is read where it ends up, app-core's `--z-bg` on the root, so a + * built-in theme, Auto following the system, a custom CSS theme and a color + * tweak all count. Those arrive as root attributes or as style elements in + * the head, so both are watched, and the bars hear only real changes. + * Android-only and best-effort, like EdgeSwipe. + */ +import { Capacitor, registerPlugin } from '@capacitor/core' +import { systemBarThemeFor, type SystemBarTheme } from './system-bar-theme-color' + +interface SystemBarThemePlugin { + setTheme(options: SystemBarTheme): Promise +} + +const NativeSystemBarTheme = registerPlugin('SystemBarTheme') + +let sent = '' + +function sync(): void { + const theme = systemBarThemeFor( + getComputedStyle(document.documentElement).getPropertyValue('--z-bg') + ) + if (!theme) return + const key = `${theme.color}:${theme.light}` + if (key === sent) return + sent = key + void NativeSystemBarTheme.setTheme(theme).catch(() => { + sent = '' + }) +} + +export function installSystemBarTheme(): void { + if (Capacitor.getPlatform() !== 'android') return + let queued = false + const schedule = (): void => { + if (queued) return + queued = true + requestAnimationFrame(() => { + queued = false + sync() + }) + } + sync() + const observer = new MutationObserver(schedule) + observer.observe(document.documentElement, { attributes: true }) + observer.observe(document.head, { childList: true, subtree: true, characterData: true }) + // Auto mode flips the theme with the system; app-core's own listener sets + // the attributes, but a frame later is cheap insurance. + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', schedule) +} diff --git a/src/main.tsx b/src/main.tsx index 2b03f86..a48a302 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,7 @@ import { mobileBackgroundSyncHost } from './bridge/mobile-bridge' import { installBackgroundSync } from './bridge/background-sync' +import { installSystemBarTheme } from './bridge/system-bar-theme' import { configureMobileCloudAuth } from './bridge/mobile-cloud-auth' import { maybeRunFirstRunOnboarding } from './ui-mobile/Onboarding' import { installWidgetPublisher } from './bridge/widgets' @@ -88,6 +89,8 @@ async function boot(): Promise { // on the transient tab before the guard puts Home back. installHomeGuard() wireKeyboard() + // Before onboarding, which draws in the theme too. + installSystemBarTheme() wireForegroundRescan() // True first run: welcome + storage choice BEFORE the vault is created, so From d9af4321e476baeeff42661b201d8ac96309d7bf Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 18:29:14 -0500 Subject: [PATCH 2/4] fix(widgets): the Calendar widget's day count drops with a tick Ticking a task in the Calendar widget drew it ticked at once, but the day's header kept the snapshot's number (27 TUE ... 4) until the app ran and published a new snapshot. The header showed the snapshot's `total`, while the rows already followed the widget's tap log. The count now comes from the same log: the snapshot's total, less the day's listed tasks whose latest tap ticked them. An untick puts it back, and once the app has applied the tap its snapshot no longer lists the task and prunes the tap, so nothing is taken off twice. "+N more" still counts the rows a day leaves out, which no tap can reach. It does not touch the grid's corner mark, which counts open tasks written in the day's own note (a different number) and still waits for the app. Checked on an API 37 emulator with the app process killed: 3 went to 2 with the tick and back to 3 with the untick; after the app applied the tick and republished, the day read 2 with the task gone. Tests: CalendarAgendaTest (JVM) covers ticks written and queued, an untick, a pruned tap and the lower bound. --- .../md/zennotes/widgets/CalendarAgenda.java | 24 ++++- .../zennotes/widgets/CalendarAgendaTest.java | 98 +++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 android/app/src/test/java/md/zennotes/widgets/CalendarAgendaTest.java diff --git a/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java b/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java index 17a5780..f9ad2c0 100644 --- a/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java +++ b/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java @@ -40,7 +40,8 @@ static final class Row { final int type; final String iso; final CalendarWidgetData.Task task; - /** Header: open tasks that day (including any past the snapshot's cap). */ + /** Header: open tasks that day (including any past the snapshot's + * cap), less the ones a widget tap has ticked since. */ final int count; /** Task: the latest widget tap on it, or null. */ final WidgetActions.Mark mark; @@ -64,7 +65,7 @@ static List rows(CalendarWidgetData data, String selected, Map rows(CalendarWidgetData data, String selected, Map marks) { + if (day == null) return 0; + int ticked = 0; + for (CalendarWidgetData.Task task : day.tasks) { + WidgetActions.Mark mark = marks.get(task.id); + if (mark != null && mark.checked) ticked++; + } + return Math.max(0, day.total - ticked); + } + /** Position of the selected day's header, to scroll the list to. */ static int positionOf(List rows, String iso) { for (int i = 0; i < rows.size(); i++) { diff --git a/android/app/src/test/java/md/zennotes/widgets/CalendarAgendaTest.java b/android/app/src/test/java/md/zennotes/widgets/CalendarAgendaTest.java new file mode 100644 index 0000000..50ddfb7 --- /dev/null +++ b/android/app/src/test/java/md/zennotes/widgets/CalendarAgendaTest.java @@ -0,0 +1,98 @@ +package md.zennotes.widgets; + +import static org.junit.Assert.assertEquals; + +import org.json.JSONObject; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CalendarAgendaTest { + /** A snapshot for the week of Mon 2026-10-26: four tasks open on Tuesday, + * two of them listed (the rest past the snapshot's cap), one on Wednesday. */ + private static CalendarWidgetData week() throws Exception { + String json = "{" + + "\"todayIso\":\"2026-10-27\",\"weekStart\":1,\"dailyEnabled\":true," + + "\"from\":\"2026-09-28\",\"to\":\"2026-11-08\",\"vaultKey\":\"zn://My Vault\"," + + "\"agenda\":{" + + "\"2026-10-27\":{\"total\":4,\"tasks\":[" + + task("inbox/Errands.md#0", "Pick up the dry cleaning") + "," + + task("inbox/Errands.md#1", "Return the library books") + "]}," + + "\"2026-10-28\":{\"total\":1,\"tasks\":[" + + task("inbox/Errands.md#2", "Buy stamps") + "]}" + + "}}"; + return CalendarWidgetData.parse(new JSONObject(json)); + } + + private static String task(String id, String content) { + return "{\"id\":\"" + id + "\",\"path\":\"inbox/Errands.md\",\"noteTitle\":\"Errands\"," + + "\"content\":\"" + content + "\",\"line\":2,\"raw\":\"- [ ] " + content + "\"," + + "\"toggled\":\"- [x] " + content + "\"}"; + } + + private static int headerCount(List rows, String iso) { + for (CalendarAgenda.Row row : rows) { + if (row.type == CalendarAgenda.HEADER && row.iso.equals(iso)) return row.count; + } + throw new AssertionError("no header for " + iso); + } + + @Test + public void countsTheSnapshotsOpenTasksWhenNothingIsTicked() throws Exception { + List rows = CalendarAgenda.rows(week(), "2026-10-27", new HashMap<>()); + assertEquals(4, headerCount(rows, "2026-10-27")); + assertEquals(1, headerCount(rows, "2026-10-28")); + assertEquals(0, headerCount(rows, "2026-10-29")); + } + + @Test + public void aTickComesOffItsDaysCountAtOnce() throws Exception { + Map marks = new HashMap<>(); + marks.put("inbox/Errands.md#1", new WidgetActions.Mark(true, true)); + List rows = CalendarAgenda.rows(week(), "2026-10-27", marks); + assertEquals(3, headerCount(rows, "2026-10-27")); + assertEquals(1, headerCount(rows, "2026-10-28")); + } + + @Test + public void aTickTheAppHasYetToWriteCountsToo() throws Exception { + Map marks = new HashMap<>(); + marks.put("inbox/Errands.md#0", new WidgetActions.Mark(true, false)); + marks.put("inbox/Errands.md#2", new WidgetActions.Mark(true, false)); + List rows = CalendarAgenda.rows(week(), "2026-10-27", marks); + assertEquals(3, headerCount(rows, "2026-10-27")); + assertEquals(0, headerCount(rows, "2026-10-28")); + } + + @Test + public void anUntickPutsItBack() throws Exception { + Map marks = new HashMap<>(); + marks.put("inbox/Errands.md#1", new WidgetActions.Mark(false, true)); + assertEquals(4, headerCount(CalendarAgenda.rows(week(), "2026-10-27", marks), "2026-10-27")); + } + + @Test + public void aTickOnATaskTheSnapshotNoLongerListsIsNotTakenOffTwice() throws Exception { + // The app applied the tick and published before the log was pruned: + // the task has left the list and the total already counts it out. + Map marks = new HashMap<>(); + marks.put("inbox/Errands.md#9", new WidgetActions.Mark(true, true)); + assertEquals(4, headerCount(CalendarAgenda.rows(week(), "2026-10-27", marks), "2026-10-27")); + } + + @Test + public void neverDropsBelowZero() throws Exception { + // A snapshot whose total undercounts its own rows still reads 0, not -1. + CalendarWidgetData data = CalendarWidgetData.parse(new JSONObject("{" + + "\"weekStart\":1,\"dailyEnabled\":true,\"from\":\"2026-09-28\",\"to\":\"2026-11-08\"," + + "\"agenda\":{\"2026-10-27\":{\"total\":1,\"tasks\":[" + + task("a.md#0", "One") + "," + task("a.md#1", "Two") + "]}}}")); + Map marks = new HashMap<>(); + marks.put("a.md#0", new WidgetActions.Mark(true, true)); + marks.put("a.md#1", new WidgetActions.Mark(true, true)); + assertEquals(0, CalendarAgenda.openCount(data.agenda.get("2026-10-27"), marks)); + assertEquals(0, CalendarAgenda.openCount(null, marks)); + } +} From 5993906ee66721dca595baa1f45763cb2f0bd445 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 18:29:14 -0500 Subject: [PATCH 3/4] fix(mobile): opening a note to read it leaves the keyboard down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a note from Home's Recent list raised the keyboard over half the screen with the caret on the first line, so a heading showed its raw `#`. The Tasks list and a widget's task link did the same when the app was already running. App-core hands the editor the keyboard focus whenever it opens a note from one of its lists or jumps to a task (#863, #415); on desktop that decides where typing goes, and core 2.57 brought it to the phones with no phone decision behind it. Inside a tap, Android shows the keyboard for that focus; the iPhone shows it for any focus. On the phone layout a note opened from a zennotes:// link (open, task, day, week) or from one of core's note lists (Home, Tasks, Quick Notes, Tags, Archive, Trash) now arms a short hold that refuses the editor focus core follows the open with, including its retries. Any touch releases it, so tapping into the text raises the keyboard as before, and whatever a tap elsewhere starts (a new note, the ensō dial's Quick, the Calendar's "Add a task") still focuses. A row's own controls (tick, pin, delete) do not arm it. Desktop-layout tablets keep core's behavior. read-open.ts and keyboard-hold.ts are the same files in both shells. It does not hide the `#`: core's live preview reveals the markers of the line holding the selection whether or not the editor has focus, and changing that is a core decision. Checked on an API 37 emulator: Recent rows, Tasks rows and a warm task link open with no keyboard and focus on the page; a tap into the text then raises it; Quick from the dial and the widget's "Add a task" still come up with the keyboard. Tests: keyboard-hold.test.ts covers the hold's timing. --- src/ui-mobile/MobileShell.tsx | 2 + src/ui-mobile/deep-links.ts | 8 ++- src/ui-mobile/keyboard-hold.test.ts | 69 +++++++++++++++++++++ src/ui-mobile/keyboard-hold.ts | 59 ++++++++++++++++++ src/ui-mobile/read-open.ts | 93 +++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 src/ui-mobile/keyboard-hold.test.ts create mode 100644 src/ui-mobile/keyboard-hold.ts create mode 100644 src/ui-mobile/read-open.ts diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 937deb6..1429ccd 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -61,6 +61,7 @@ import ensoUrl from '../assets/enso.png' import { getStoragePref } from '../bridge/icloud' import { backgroundSyncEnabled, setBackgroundSyncEnabled } from '../bridge/background-sync' import { getPinnedNotes, loadPins } from './pins' +import { installReadOpenGuard } from './read-open' import { placeVaultSettingsIsland, vaultSettingsAccessForLayout } from './vault-settings-access' import { getLayoutMode, isPhoneViewport, setLayoutMode, type LayoutMode } from '../viewport' import { @@ -3149,6 +3150,7 @@ function MobileShellRoot(): React.JSX.Element { export function mountMobileShell(): void { document.documentElement.classList.add('zn-mobile') + installReadOpenGuard() // Pin state loads async from native Preferences; the drawer re-renders via // the pins subscription when it lands. void loadPins() diff --git a/src/ui-mobile/deep-links.ts b/src/ui-mobile/deep-links.ts index cda751f..5a6db88 100644 --- a/src/ui-mobile/deep-links.ts +++ b/src/ui-mobile/deep-links.ts @@ -37,6 +37,7 @@ import { closeNoteMenu } from './note-actions' import { closeMobileSheet } from './sheet-state' import { parseWidgetLink, type WidgetLink } from './widget-links' import { closeCalendarSheet, requestCalendar } from './calendar-request' +import { openToRead } from './read-open' const DUPLICATE_WINDOW_MS = 5000 const LANDING_SETTLE_MS = 80 @@ -130,11 +131,12 @@ async function run(link: WidgetLink): Promise { await runAppCommand('note.new.inbox') return case 'open': - if (hasNote(s, link.path)) await openNote(link.path) + // A widget opens a note to be read: no keyboard until a tap asks for it. + if (hasNote(s, link.path)) await openToRead(() => openNote(link.path)) else goHome() return case 'task': - await openLinkedTask(s, link) + await openToRead(() => openLinkedTask(s, link)) return case 'tasks': await openAppPage('tasks') @@ -149,7 +151,7 @@ async function run(link: WidgetLink): Promise { case 'week': // A day with no note opens the Calendar on it, where "Day +" asks // before creating one, as tapping that day in the app would. - if (!(await openDateNote(link.kind === 'day' ? 'daily' : 'weekly', link.date))) { + if (!(await openToRead(() => openDateNote(link.kind === 'day' ? 'daily' : 'weekly', link.date)))) { requestCalendar({ date: link.date, add: false }) } return diff --git a/src/ui-mobile/keyboard-hold.test.ts b/src/ui-mobile/keyboard-hold.test.ts new file mode 100644 index 0000000..79944a9 --- /dev/null +++ b/src/ui-mobile/keyboard-hold.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { KeyboardHold, READ_OPEN_SETTLE_MS, READ_OPEN_WAIT_MS } from './keyboard-hold.ts' + +function clock(): { now: () => number; advance: (ms: number) => void } { + let t = 1_000_000 + return { now: () => t, advance: (ms) => { t += ms } } +} + +describe('KeyboardHold', () => { + it('lets every focus through until a note opens to be read', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + assert.equal(hold.refuse(), false) + }) + + it('refuses core\'s focus and its retries, then lets go', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + c.advance(400) // the note takes a while to open + assert.equal(hold.refuse(), true) + for (let i = 0; i < 10; i++) { + c.advance(24) // focusEditorNormalMode's retry cadence + assert.equal(hold.refuse(), true) + } + c.advance(READ_OPEN_SETTLE_MS) + assert.equal(hold.refuse(), false) + }) + + it('lets a tap into the text through at once', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + assert.equal(hold.refuse(), true) + hold.release() + assert.equal(hold.refuse(), false) + }) + + it('does not wait forever for a focus that never comes', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + c.advance(READ_OPEN_WAIT_MS) + assert.equal(hold.active, false) + assert.equal(hold.refuse(), false) + }) + + it('stops waiting soon after an open it was told about finishes', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + c.advance(100) + hold.opened() + c.advance(READ_OPEN_SETTLE_MS - 1) + assert.equal(hold.active, true) + c.advance(1) + assert.equal(hold.refuse(), false) + }) + + it('ignores an open that finishes after a touch released it', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + hold.release() + hold.opened() + assert.equal(hold.refuse(), false) + }) +}) diff --git a/src/ui-mobile/keyboard-hold.ts b/src/ui-mobile/keyboard-hold.ts new file mode 100644 index 0000000..24598e5 --- /dev/null +++ b/src/ui-mobile/keyboard-hold.ts @@ -0,0 +1,59 @@ +/** + * The clock of the read-open guard (read-open.ts), kept apart from the DOM + * so node --test covers it. Same file in both phone shells. + * + * A note opened to be read arms it. Core then hands the editor focus the way + * desktop wants it, once or in a short burst of retries + * (focusEditorNormalMode tries for about a quarter of a second), and every + * try is refused until the burst has gone quiet. A touch releases it at + * once: tapping into the text is how the keyboard should come up, and + * anything else the user taps is a new intent. + */ + +/** How long an armed guard waits for core's focus: a note read from a + * folder vault can take a few seconds to open. */ +export const READ_OPEN_WAIT_MS = 5000 +/** How long it keeps refusing after a refused focus or a finished open, + * past core's retries. */ +export const READ_OPEN_SETTLE_MS = 700 + +export class KeyboardHold { + private waitUntil = 0 + private settleUntil = 0 + private readonly now: () => number + + constructor(now: () => number) { + this.now = now + } + + /** A note is opening to be read. */ + arm(): void { + this.waitUntil = this.now() + READ_OPEN_WAIT_MS + this.settleUntil = 0 + } + + /** The open has finished: core's focus, if any, follows within the settle. */ + opened(): void { + if (!this.active) return + this.waitUntil = Math.min(this.waitUntil, this.now() + READ_OPEN_SETTLE_MS) + } + + /** The user touched the screen. */ + release(): void { + this.waitUntil = 0 + this.settleUntil = 0 + } + + get active(): boolean { + const t = this.now() + return t < this.waitUntil || t < this.settleUntil + } + + /** An editor focus is about to land: true refuses it. */ + refuse(): boolean { + if (!this.active) return false + this.waitUntil = 0 + this.settleUntil = this.now() + READ_OPEN_SETTLE_MS + return true + } +} diff --git a/src/ui-mobile/read-open.ts b/src/ui-mobile/read-open.ts new file mode 100644 index 0000000..24be096 --- /dev/null +++ b/src/ui-mobile/read-open.ts @@ -0,0 +1,93 @@ +/** + * Opening a note to read it does not raise the soft keyboard on a phone. + * + * App-core hands the editor the keyboard focus whenever it opens a note + * from a list or jumps to a task (#863, #415): right on desktop, where + * focus decides where typing goes and nothing covers the screen. On a phone + * that focus is the soft keyboard over half the note, with the caret parked + * on the first line. Android raised it for Home's Recent rows and the Tasks + * list (a focus inside the tap's gesture shows the keyboard), the iPhone for + * a widget's task link too (Capacitor's WKWebView shows it for any focus). + * Core 2.57 brought those hand-offs to the phones with no phone decision + * behind them; the shells already keep the keyboard down for their own + * navigation (wikilink taps, Back, sheets). + * + * So on the phone layout, a note opened from a zennotes:// link or from one + * of core's note lists (Home, Tasks, Quick Notes, Tags, Archive, Trash) + * arms a short hold (keyboard-hold.ts) that refuses the editor focus core + * follows the open with. Tapping into the text releases it and raises the + * keyboard as always, and any other touch releases it too, so whatever a + * tap elsewhere starts (a new note, the ensō dial, the Calendar's "Add a + * task") focuses as before. Desktop-layout tablets keep core's behavior. + * Same file in both phone shells. + */ +import { Keyboard } from '@capacitor/keyboard' +import { isPhoneViewport } from '../viewport' +import { KeyboardHold } from './keyboard-hold' + +const hold = new KeyboardHold(() => Date.now()) + +/** Core's note rows that open their note on a tap. */ +const READ_ROWS = + '[data-home-nav] [data-home-item], [data-task-row], [data-quick-row], [data-tag-row], [data-archive-row], [data-trash-row]' +/** Controls inside a row that do something else (tick, pin, delete). */ +const ROW_CONTROLS = 'button, a, input, [role="button"], [role="checkbox"]' + +function isEditor(el: Element): boolean { + return el.closest('.cm-editor') !== null +} + +/** A tap on a row that opens its note (not on one of the row's own controls). */ +function opensToRead(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false + const row = target.closest(READ_ROWS) + if (!row) return false + const control = target.closest(ROW_CONTROLS) + return !control || control === row || !row.contains(control) || control.matches('[aria-label^="Open "]') +} + +let installed = false + +export function installReadOpenGuard(): void { + if (installed) return + installed = true + // CodeMirror's view.focus() is contentDOM.focus(): refusing the call keeps + // the keyboard from ever starting up, with no focus and blur for the + // editor to react to. + const focus = HTMLElement.prototype.focus + HTMLElement.prototype.focus = function (this: HTMLElement, options?: FocusOptions): void { + if (isEditor(this) && hold.refuse()) return + focus.call(this, options) + } + // Any other way focus lands in the editor while held, undone at once. + document.addEventListener( + 'focusin', + (event) => { + const target = event.target + if (!(target instanceof HTMLElement) || !isEditor(target) || !hold.refuse()) return + target.blur() + void Keyboard.hide().catch(() => {}) + }, + true + ) + document.addEventListener('pointerdown', () => hold.release(), true) + // After the pointerdown that released it: the row's own click arms it. + document.addEventListener( + 'click', + (event) => { + if (isPhoneViewport() && opensToRead(event.target)) hold.arm() + }, + true + ) +} + +/** Open a note to read it (a widget or zennotes:// link). */ +export async function openToRead(open: () => Promise): Promise { + if (!isPhoneViewport()) return open() + hold.arm() + try { + return await open() + } finally { + hold.opened() + } +} From 1a33f17c2c632bc4c7a8e948a051573ea6df3f70 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 18:54:00 -0500 Subject: [PATCH 4/4] chore: bump Android to 1.1.49 (versionCode 52) --- android/app/build.gradle | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- src/bridge/mobile-bridge.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 49a0efe..98b9f08 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 51 - versionName "1.1.48" + versionCode 52 + versionName "1.1.49" 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/package-lock.json b/package-lock.json index 3290d25..4e16ba5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.48", + "version": "1.1.49", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.1", "@capacitor/android": "^8.5.3", diff --git a/package.json b/package.json index 7e5927b..7185a8b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.48", + "version": "1.1.49", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index b65c70f..fa15749 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -156,7 +156,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.48' +let appVersion = '1.1.49' export async function loadNativeAppVersion(): Promise { try {