diff --git a/package-lock.json b/package-lock.json
index 717ba6f1..b92503c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,7 +24,7 @@
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.2.2",
- "@vrtmrz/obsidian-plugin-kit": "0.1.4",
+ "@vrtmrz/obsidian-plugin-kit": "0.1.6",
"@vrtmrz/ui-interactions": "0.1.2",
"@vrtmrz/value-store": "0.1.0",
"diff-match-patch": "^1.0.5",
@@ -4170,12 +4170,14 @@
}
},
"node_modules/@vrtmrz/obsidian-plugin-kit": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.4.tgz",
- "integrity": "sha512-MxZgd7UOr8DXk0e+JsAXJmYj/8bfIoEliI4ruvp1Cu4U+mmVI/p63nZ5we3dDW+EaCgRAgF+ztuVDkcMUk1DwA==",
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.6.tgz",
+ "integrity": "sha512-88HhpN3G53qIQxvsTpe7VE6/unW3YtcZjmqko3Q58yRl/Y+iJDruT1HuEQYEgtkWAB6F2T93z2fhqhvRBXDDWQ==",
"license": "MIT",
"dependencies": {
- "@vrtmrz/ui-interactions": "0.1.2"
+ "@vrtmrz/secret-store": "0.1.0",
+ "@vrtmrz/ui-interactions": "0.1.2",
+ "@vrtmrz/value-store": "0.1.0"
},
"peerDependencies": {
"obsidian": ">=1.8.7"
@@ -4195,6 +4197,15 @@
"playwright": ">=1.50.0"
}
},
+ "node_modules/@vrtmrz/secret-store": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@vrtmrz/secret-store/-/secret-store-0.1.0.tgz",
+ "integrity": "sha512-9qkJQdt5IRxhPIIQr55bRMaKZjDpF9fLpTtIOsR0Ab90Wl52hj3wsY28EUtgCCRZ1qbXHAgDuplbg0NNMjAolQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vrtmrz/value-store": "0.1.0"
+ }
+ },
"node_modules/@vrtmrz/ui-interactions": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@vrtmrz/ui-interactions/-/ui-interactions-0.1.2.tgz",
diff --git a/package.json b/package.json
index 84e21ce5..8b8368cd 100644
--- a/package.json
+++ b/package.json
@@ -205,7 +205,7 @@
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.2.2",
- "@vrtmrz/obsidian-plugin-kit": "0.1.4",
+ "@vrtmrz/obsidian-plugin-kit": "0.1.6",
"@vrtmrz/ui-interactions": "0.1.2",
"@vrtmrz/value-store": "0.1.0",
"diff-match-patch": "^1.0.5",
diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte
index a163ceee..2c640ee0 100644
--- a/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte
+++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte
@@ -314,14 +314,16 @@
{#if processing}
{translateMessage("Checking connection... Please wait.")}
{:else}
+ {#if setupMode === "settings"}
+
+ {translateMessage(
+ "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
+ )}
+
+ {/if}
checkAndCommit()} />
{#if setupMode === "settings"}
-
- {translateMessage(
- "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
- )}
-
button) {
+ margin-left: 0;
+ }
+
.dialog-host :global(.button-group) {
display: flex;
flex-direction: column;
diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md
index f47ad6e6..c61427a0 100644
--- a/test/e2e-obsidian/README.md
+++ b/test/e2e-obsidian/README.md
@@ -50,6 +50,8 @@ Set `showVerboseLog: true` only in isolated plug-in data when a focused investig
Collect evidence before cleanup, and keep process, Vault, profile, and remote-fixture cleanup in `finally`. After `app.emulateMobile(true)`, use the active CDP renderer for fixture operations because Obsidian may remove desktop-only CLI commands. Visually inspect screenshots before copying selected images into user documentation; a passing locator assertion does not establish that a dialogue is readable or unobstructed.
+The dialogue scenario simulates the host keyboard-height variable while the mobile CouchDB setup dialogue is open. It verifies that the space after the final action does not grow with the keyboard height and restores the original variable and scroll position afterwards. It also checks that the warning about saving without a connection test scrolls with the form and leaves room for at least two input rows above the fixed actions. This layout fixture does not open a physical software keyboard.
+
## Local Setup
Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location. Set `OBSIDIAN_CLI` as well when its companion executable is outside the built-in discovery paths.
diff --git a/test/e2e-obsidian/scripts/dialog-mounts.ts b/test/e2e-obsidian/scripts/dialog-mounts.ts
index 60d7cf1e..d65fe153 100644
--- a/test/e2e-obsidian/scripts/dialog-mounts.ts
+++ b/test/e2e-obsidian/scripts/dialog-mounts.ts
@@ -1,13 +1,19 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
-import type { Page } from "playwright";
+import type { Locator, Page } from "playwright";
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "../../../src/common/translation.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
-import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
+import {
+ assertMobileDialogueLayout,
+ assertMobileNoticeLayout,
+ iPhoneSafeArea,
+ mobileViewport,
+ setObsidianMobileTestMode,
+} from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianDialogue,
@@ -400,6 +406,185 @@ async function verifyRemoteSizeNoticeAndDialogue(): Promise<{
};
}
+async function assertDialogueActionAlignment(modal: Locator, mode: DialogueMode, name: string): Promise {
+ const groups = await modal.locator(".dialog-host .button-group").evaluateAll((elements) => {
+ return elements
+ .filter((element) => element.getClientRects().length > 0)
+ .map((element) => {
+ const group = element.getBoundingClientRect();
+ const buttons = Array.from(element.children)
+ .filter((child) => child instanceof HTMLButtonElement && child.getClientRects().length > 0)
+ .map((child) => {
+ const rect = child.getBoundingClientRect();
+ return { left: rect.left, right: rect.right, width: rect.width };
+ });
+ return { group: { left: group.left, right: group.right, width: group.width }, buttons };
+ })
+ .filter((group) => group.buttons.length > 0);
+ });
+ if (groups.length === 0) {
+ throw new Error(`Expected visible action buttons in ${name} (${mode}).`);
+ }
+ console.log(JSON.stringify({ event: "dialogue-action-alignment", name, mode, groups }));
+ for (const { group, buttons } of groups) {
+ for (const button of buttons) {
+ if (Math.abs(button.left - group.left) > 1 || Math.abs(button.right - group.right) > 1) {
+ throw new Error(
+ `Expected ${name} (${mode}) action buttons to align with both edges of their content column: ${JSON.stringify({ group, button })}`
+ );
+ }
+ }
+ }
+}
+
+async function assertNoExtraKeyboardSpace(page: Page, modal: Locator): Promise {
+ const original = await page.evaluate(() => ({
+ value: document.body.style.getPropertyValue("--keyboard-height"),
+ priority: document.body.style.getPropertyPriority("--keyboard-height"),
+ }));
+ const originalScroll = await modal.locator(".modal-content").evaluate((element) => element.scrollTop);
+ const keyboardHeight = await page.evaluate(() => Math.round(innerHeight * 0.4));
+ let baseline: number | undefined;
+ try {
+ for (const height of [0, keyboardHeight, 0]) {
+ await page.evaluate((value) => document.body.style.setProperty("--keyboard-height", `${value}px`), height);
+ await modal.locator(".modal-content").evaluate((element) => {
+ element.scrollTop = element.scrollHeight;
+ });
+ const gap = await modal.locator(".dialog-host").evaluate((host) => {
+ const buttons = Array.from(host.querySelectorAll("button")).filter(
+ (button) => button.getClientRects().length > 0
+ );
+ const last = buttons[buttons.length - 1];
+ if (last === undefined) throw new Error("The dialogue has no action button.");
+ return host.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom;
+ });
+ console.log(JSON.stringify({ event: "dialogue-keyboard-spacing", height, gap, baseline }));
+ baseline ??= gap;
+ if (Math.abs(gap - baseline) > 1) {
+ throw new Error(
+ `Expected the keyboard not to add blank content after the final action: ${JSON.stringify({ height, gap, baseline })}`
+ );
+ }
+ }
+ } finally {
+ await page.evaluate(({ value, priority }) => {
+ if (value === "") document.body.style.removeProperty("--keyboard-height");
+ else document.body.style.setProperty("--keyboard-height", value, priority);
+ }, original);
+ await modal.locator(".modal-content").evaluate((element, top) => {
+ element.scrollTop = top;
+ }, originalScroll);
+ }
+}
+
+async function assertCouchDBKeyboardFormSpace(page: Page, modal: Locator): Promise {
+ const originalViewport = await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
+ const properties = ["--keyboard-height", "--safe-area-inset-top", "--safe-area-inset-bottom"];
+ const original = await page.evaluate(
+ (names) =>
+ names.map((name) => ({
+ name,
+ value: document.body.style.getPropertyValue(name),
+ priority: document.body.style.getPropertyPriority(name),
+ })),
+ properties
+ );
+ const content = modal.locator(".modal-content");
+ const originalScroll = await content.evaluate((element) => element.scrollTop);
+ const keyboardHeight = Math.round(mobileViewport.height * 0.42);
+ try {
+ await page.setViewportSize(mobileViewport);
+ await page.evaluate(
+ ({ keyboardHeight, top }) => {
+ document.body.style.setProperty("--keyboard-height", `${keyboardHeight}px`);
+ document.body.style.setProperty("--safe-area-inset-bottom", `${keyboardHeight}px`);
+ document.body.style.setProperty("--safe-area-inset-top", `${top}px`);
+ },
+ { keyboardHeight, top: iPhoneSafeArea.top }
+ );
+ await modal.locator('input[name="couchdb-username"]').focus();
+ await page.waitForTimeout(350);
+ await page.screenshot({
+ path: join(
+ process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e",
+ "setup-couchdb-keyboard-form.png"
+ ),
+ animations: "disabled",
+ style: ".notice-container { visibility: hidden !important; }",
+ });
+ const warning = modal.locator(".sls-info-note").filter({
+ hasText:
+ "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
+ });
+ if ((await warning.count()) !== 1) throw new Error("Expected the untested-save warning to remain available.");
+ const pinned = await warning.evaluate((element) => {
+ for (let parent = element.parentElement; parent !== null; parent = parent.parentElement) {
+ if (parent.classList.contains("modal-content")) break;
+ const position = getComputedStyle(parent).position;
+ if (position === "sticky" || position === "fixed") return true;
+ }
+ return false;
+ });
+ if (pinned) throw new Error("The untested-save warning still occupies the fixed action area.");
+ const bounds = await modal.evaluate((element) => {
+ const host = element.querySelector(".dialog-host");
+ const content = element.querySelector(".modal-content");
+ const actionGroups = host?.querySelectorAll(":scope > .button-group");
+ const footerElement = actionGroups?.item(actionGroups.length - 1);
+ const rowElement = host?.querySelector(".row");
+ if (!host || !content || !footerElement || !rowElement)
+ throw new Error("CouchDB form layout is unavailable.");
+ const footer = footerElement.getBoundingClientRect();
+ const row = rowElement.getBoundingClientRect();
+ const actions = Array.from(footerElement.querySelectorAll("button"), (button) =>
+ button.getBoundingClientRect()
+ );
+ const footerTop = Math.min(footer.top, ...actions.map((action) => action.top));
+ const footerBottom = Math.max(footer.bottom, ...actions.map((action) => action.bottom));
+ const modalElement = element.querySelector(".modal");
+ if (!modalElement) throw new Error("The CouchDB modal is unavailable.");
+ const modalRect = modalElement.getBoundingClientRect();
+ const contentRect = content.getBoundingClientRect();
+ const headerBottom =
+ element.querySelector(".modal-header")?.getBoundingClientRect().bottom ?? modalRect.top;
+ const formTop = Math.max(contentRect.top, modalRect.top, headerBottom);
+ return {
+ width: innerWidth,
+ height: innerHeight,
+ formTop,
+ footerTop,
+ formHeight: Math.min(footerTop, contentRect.bottom, modalRect.bottom) - formTop,
+ rowHeight: row.height + (parseFloat(getComputedStyle(host).rowGap) || 0),
+ footerBottom,
+ actionBounds: actions.map((action) => ({ top: action.top, bottom: action.bottom })),
+ };
+ });
+ console.log(JSON.stringify({ event: "couchdb-keyboard-form-space", keyboardHeight, bounds }));
+ if (
+ bounds.width !== mobileViewport.width ||
+ bounds.height !== mobileViewport.height ||
+ bounds.formHeight < bounds.rowHeight * 2 ||
+ bounds.footerBottom > bounds.height - keyboardHeight + 1
+ ) {
+ throw new Error(
+ `Expected room for at least two input rows above keyboard actions: ${JSON.stringify(bounds)}`
+ );
+ }
+ } finally {
+ await page.evaluate((entries) => {
+ for (const { name, value, priority } of entries) {
+ if (value === "") document.body.style.removeProperty(name);
+ else document.body.style.setProperty(name, value, priority);
+ }
+ }, original);
+ await page.setViewportSize(originalViewport);
+ await content.evaluate((element, top) => {
+ element.scrollTop = top;
+ }, originalScroll);
+ }
+}
+
async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise {
await openRemoteSelectionDialogue();
const screenshotPath = await captureObsidianDialogue(
@@ -446,6 +631,7 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise