diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index 38c1be92..b4e7e779 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -82,6 +82,14 @@ import { isP2PMainRemote } from "@/common/remoteConfiguration.ts"; // For creating a document // const toc = new Set(); +function registerLiveSyncSettingsModal(component: Component, containerEl: HTMLElement | undefined): void { + if (!containerEl) return; + const modalEl = containerEl.closest(".modal.mod-settings"); + if (!modalEl) return; + modalEl.addClass("sls-setting-modal"); + component.register(() => modalEl.removeClass("sls-setting-modal")); +} + export class ObsidianLiveSyncSettingTab extends PluginSettingTab { plugin: ObsidianLiveSyncPlugin; private _lifetimeComponent?: Component; @@ -701,6 +709,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { private renderCustomPage(page: SettingPage, entry: SettingsPageEntry): Component { if (requireApiVersion("1.13.0")) { const component = this.beginRenderScope(() => page.display()); + registerLiveSyncSettingsModal(component, this.containerEl); this.isShown = true; page.title = entry.name(); page.containerEl.empty(); @@ -1083,8 +1092,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { private displayImperative(): void { const changeDisplay = this.changeDisplay.bind(this); // Make sure the page-owned component is loaded for markdown rendering in panes. - this.beginRenderScope(() => this.displayImperative()); + const component = this.beginRenderScope(() => this.displayImperative()); const { containerEl } = this; + registerLiveSyncSettingsModal(component, containerEl); this.screenElements = {}; if (this._editingSettings == undefined || this.initialSettings == undefined) { this.reloadAllSettings(); diff --git a/src/modules/features/SettingDialogue/PanePatches.ts b/src/modules/features/SettingDialogue/PanePatches.ts index 1816d6d5..5b0262ce 100644 --- a/src/modules/features/SettingDialogue/PanePatches.ts +++ b/src/modules/features/SettingDialogue/PanePatches.ts @@ -177,7 +177,7 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch"); }); void addPanel(paneEl, "Remediation").then((paneEl) => { - const setting = new Setting(paneEl); + const setting = new Setting(paneEl).setClass("sls-setting-mobile-wrap-controls"); const dateEl = setting.controlEl.createSpan(); setting .addText((text) => { diff --git a/src/modules/features/SettingDialogue/PanePatches.unit.spec.ts b/src/modules/features/SettingDialogue/PanePatches.unit.spec.ts index 60f29b84..9ed2a25b 100644 --- a/src/modules/features/SettingDialogue/PanePatches.unit.spec.ts +++ b/src/modules/features/SettingDialogue/PanePatches.unit.spec.ts @@ -12,11 +12,13 @@ const remediationHarness = vi.hoisted(() => { onChange: vi.fn(), setValue: vi.fn(), }; + const setClass = vi.fn(); return { createSpan, dateElement, inputEl, + setClass, textComponent, }; }); @@ -36,6 +38,11 @@ vi.mock("./LiveSyncSetting.ts", () => ({ return this; } + setClass(value: string): this { + remediationHarness.setClass(value); + return this; + } + addApplyButton(): this { return this; } @@ -93,5 +100,6 @@ describe("panePatches remediation setting", () => { expect(createSpan).not.toHaveBeenCalled(); expect(remediationHarness.createSpan).toHaveBeenCalledOnce(); expect(remediationHarness.dateElement.textContent).toBe("No limit configured"); + expect(remediationHarness.setClass).toHaveBeenCalledWith("sls-setting-mobile-wrap-controls"); }); }); diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts index bd2143ef..5ecbabeb 100644 --- a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts @@ -105,7 +105,9 @@ export function paneRemoteConfig( void addPanel(paneEl, "E2EE Configuration", () => {}).then((paneEl) => { const infoPanel = new SveltePanel(InfoPanel, paneEl, E2EESummaryWritable); this.lifetimeComponent.register(() => infoPanel.destroy()); - const setupButton = new Setting(paneEl).setName("Configure E2EE"); + const setupButton = new Setting(paneEl) + .setName("Configure E2EE") + .setClass("sls-setting-mobile-wrap-controls"); setupButton .addButton((button) => setButtonDestructiveState(button) diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts index a11f20f7..8853fad7 100644 --- a/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts @@ -30,6 +30,10 @@ vi.mock("./LiveSyncSetting.ts", () => ({ return this; } + setClass() { + return this; + } + addButton() { return this; } diff --git a/styles.css b/styles.css index 241176e0..5dddf444 100644 --- a/styles.css +++ b/styles.css @@ -536,14 +536,35 @@ div.workspace-leaf-content[data-type="bases"] .livesync-status { } .sls-setting-panel-title { - position: sticky; font-size: medium; - top: 2.5em; background-color: var(--background-secondary-alt); border-radius: 10px; padding: 0.5em 1em; } +body.is-mobile .sls-setting button { + max-width: 100%; + white-space: normal; +} + +body.is-mobile .sls-setting-mobile-wrap-controls { + flex-wrap: wrap; +} + +body.is-mobile .sls-setting-mobile-wrap-controls .setting-item-control { + min-width: 0; + flex: 1 1 100%; + flex-wrap: wrap; +} + +body.is-mobile .sls-setting-mobile-wrap-controls .setting-item-control > button { + flex: 1 1 12rem; +} + +body.is-mobile .modal.mod-settings.sls-setting-modal .modal-header { + background-color: var(--background-primary); +} + .active-pane .sls-setting-panel-title { border: 1px solid var(--interactive-accent); } diff --git a/test/e2e-obsidian/scripts/settings-ui.ts b/test/e2e-obsidian/scripts/settings-ui.ts index 31de3061..5d9a3396 100644 --- a/test/e2e-obsidian/scripts/settings-ui.ts +++ b/test/e2e-obsidian/scripts/settings-ui.ts @@ -1,8 +1,12 @@ import { mkdir } from "node:fs/promises"; import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; -import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; -import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; +import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { + assertMobileDialogueLayout, + setObsidianMobileTestMode, + setObsidianMobileTestModeBeforePluginStart, +} from "../runner/mobileUi.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { allowPendingObsidianTestVaultOpenAction, @@ -160,39 +164,206 @@ async function setConfiguredStateForLandingInspection(page: Page, configured: bo }, configured); } -async function captureDeclarativeMobileLanding(): Promise { +async function captureDeclarativeMobileSettings(): Promise< + | { + landingPage: string; + maintenance: string; + patches: string; + remoteConfiguration: string; + } + | undefined +> { const port = obsidianRemoteDebuggingPort(); - await setObsidianMobileTestMode(port, true, uiTimeoutMs); - try { - return await withObsidianPage(port, async (page) => { - const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); - if (settingsNavigator.renderer !== "declarative") { - await settingsNavigator.close(); - return undefined; - } - await settingsNavigator.returnToCatalogue(); - await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true); - await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true); - const remoteConfiguration = settingsNavigator.dialogue - .locator(".setting-item-name") - .filter({ hasText: "Remote Configuration" }) - .first(); - await remoteConfiguration.waitFor({ state: "visible", timeout: uiTimeoutMs }); - const path = `${diagnosticsDirectory}/settings-declarative-landing-mobile.png`; - await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path }); - const remotePosition = await remoteConfiguration.evaluate((element) => { - const bounds = element.getBoundingClientRect(); - return { top: bounds.top, bottom: bounds.bottom, viewportHeight: window.innerHeight }; - }); - if (remotePosition.top < 0 || remotePosition.bottom > remotePosition.viewportHeight) { - throw new Error("Remote Configuration was not visible at the top of the mobile settings landing page."); - } + return await withObsidianPage(port, async (page) => { + const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); + if (settingsNavigator.renderer !== "declarative") { await settingsNavigator.close(); - return path; + return undefined; + } + await settingsNavigator.returnToCatalogue(); + await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true); + await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true); + const remoteConfiguration = settingsNavigator.dialogue + .locator(".setting-item-name") + .filter({ hasText: "Remote Configuration" }) + .first(); + await remoteConfiguration.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const path = `${diagnosticsDirectory}/settings-declarative-landing-mobile.png`; + await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path }); + const remotePosition = await remoteConfiguration.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return { top: bounds.top, bottom: bounds.bottom, viewportHeight: window.innerHeight }; }); - } finally { - await setObsidianMobileTestMode(port, false, uiTimeoutMs); - } + if (remotePosition.top < 0 || remotePosition.bottom > remotePosition.viewportHeight) { + throw new Error("Remote Configuration was not visible at the top of the mobile settings landing page."); + } + + const remotePage = await settingsNavigator.openPage("Remote Configuration"); + const e2eeHeading = remotePage + .locator("h4.sls-setting-panel-title") + .filter({ hasText: "E2EE Configuration" }) + .first(); + const e2eeActions = remotePage.locator(".setting-item").filter({ + has: settingsNavigator.page.getByText("Configure E2EE", { exact: true }), + }); + await e2eeHeading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await e2eeActions.waitFor({ state: "visible", timeout: uiTimeoutMs }); + + const layoutFailures: string[] = []; + const actionLayout = await e2eeActions.evaluate((setting) => { + const control = setting.querySelector(".setting-item-control"); + if (control === null) throw new Error("The E2EE action row did not contain a control group."); + const settingBounds = setting.getBoundingClientRect(); + const buttonBounds = Array.from(control.querySelectorAll("button")).map((button) => + button.getBoundingClientRect() + ); + return { + controlClientWidth: control.clientWidth, + controlScrollWidth: control.scrollWidth, + rightmostButton: Math.max(...buttonBounds.map((bounds) => bounds.right)), + settingRight: settingBounds.right, + }; + }); + if ( + actionLayout.controlScrollWidth > actionLayout.controlClientWidth + 1 || + actionLayout.rightmostButton > actionLayout.settingRight + 1 + ) { + layoutFailures.push(`the E2EE actions overflowed their setting row (${JSON.stringify(actionLayout)})`); + } + + await remotePage.evaluate((content) => { + content.scrollTop = content.scrollHeight - content.clientHeight; + content.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await settingsNavigator.page.waitForTimeout(50); + const panelLayout = await e2eeHeading.evaluate((heading) => { + const infoPanel = heading.parentElement?.querySelector(".info-panel"); + if (infoPanel === null || infoPanel === undefined) { + throw new Error("The E2EE section did not contain its information panel."); + } + const modalHeader = heading.closest(".modal.mod-settings")?.querySelector(".modal-header"); + if (modalHeader === null || modalHeader === undefined) { + throw new Error("The mobile settings page did not contain its native header."); + } + const headingBounds = heading.getBoundingClientRect(); + const infoBounds = infoPanel.getBoundingClientRect(); + return { + headingBottom: headingBounds.bottom, + headingPosition: getComputedStyle(heading).position, + headingTop: headingBounds.top, + infoBottom: infoBounds.bottom, + infoTop: infoBounds.top, + modalHeaderBackground: getComputedStyle(modalHeader).backgroundColor, + }; + }); + if ( + panelLayout.headingBottom > panelLayout.infoTop + 1 && + panelLayout.headingTop < panelLayout.infoBottom - 1 + ) { + layoutFailures.push(`the E2EE section heading overlapped its contents (${JSON.stringify(panelLayout)})`); + } + if ( + panelLayout.modalHeaderBackground === "transparent" || + panelLayout.modalHeaderBackground === "rgba(0, 0, 0, 0)" + ) { + layoutFailures.push("the native mobile settings header remained transparent over scrolling content"); + } + + const remotePath = `${diagnosticsDirectory}/settings-declarative-remote-mobile.png`; + await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path: remotePath }); + if (layoutFailures.length > 0) { + throw new Error(`The mobile Remote Configuration layout was invalid: ${layoutFailures.join("; ")}.`); + } + + const maintenancePage = await settingsNavigator.openPage("Maintenance"); + const markResolvedButton = maintenancePage + .locator(".op-warn button") + .filter({ hasText: "I've made a backup, mark this device 'resolved'" }) + .first(); + await markResolvedButton.evaluate((button) => { + const warning = button.closest(".op-warn"); + if (warning === null) throw new Error("The Maintenance recovery action had no warning container."); + warning.removeClass("sls-setting-hidden"); + }); + await markResolvedButton.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await markResolvedButton.scrollIntoViewIfNeeded(); + const maintenanceLayout = await markResolvedButton.evaluate((button) => { + const content = button.closest(".vertical-tab-content"); + if (content === null) throw new Error("The Maintenance button was outside the settings content."); + const buttonBounds = button.getBoundingClientRect(); + const contentBounds = content.getBoundingClientRect(); + return { + buttonLeft: buttonBounds.left, + buttonRight: buttonBounds.right, + contentLeft: contentBounds.left, + contentRight: contentBounds.right, + rootClientWidth: document.documentElement.clientWidth, + rootScrollWidth: document.documentElement.scrollWidth, + }; + }); + const maintenancePath = `${diagnosticsDirectory}/settings-declarative-maintenance-mobile.png`; + await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path: maintenancePath }); + if ( + maintenanceLayout.buttonLeft < maintenanceLayout.contentLeft - 1 || + maintenanceLayout.buttonRight > maintenanceLayout.contentRight + 1 || + maintenanceLayout.rootScrollWidth > maintenanceLayout.rootClientWidth + 1 + ) { + layoutFailures.push( + `the Maintenance recovery action overflowed the settings pane (${JSON.stringify(maintenanceLayout)})` + ); + } + + const patchesPage = await settingsNavigator.openPage("Patches"); + const remediationSetting = patchesPage.locator(".setting-item").filter({ + has: settingsNavigator.page.locator('input[type="datetime-local"]'), + }); + await remediationSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await remediationSetting.scrollIntoViewIfNeeded(); + const patchesLayout = await remediationSetting.evaluate((setting) => { + const content = setting.closest(".vertical-tab-content"); + const control = setting.querySelector(".setting-item-control"); + if (content === null || control === null) { + throw new Error("The Patches remediation row was incomplete."); + } + const applyButton = control.querySelector("button"); + if (applyButton === null) throw new Error("The Patches remediation row did not contain Apply."); + const settingBounds = setting.getBoundingClientRect(); + const contentBounds = content.getBoundingClientRect(); + const buttonBounds = applyButton.getBoundingClientRect(); + return { + buttonRight: buttonBounds.right, + contentRight: contentBounds.right, + controlClientWidth: control.clientWidth, + controlScrollWidth: control.scrollWidth, + rootClientWidth: document.documentElement.clientWidth, + rootScrollWidth: document.documentElement.scrollWidth, + settingRight: settingBounds.right, + }; + }); + const patchesPath = `${diagnosticsDirectory}/settings-declarative-patches-mobile.png`; + await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path: patchesPath }); + if ( + patchesLayout.buttonRight > patchesLayout.settingRight + 1 || + patchesLayout.buttonRight > patchesLayout.contentRight + 1 || + patchesLayout.controlScrollWidth > patchesLayout.controlClientWidth + 1 || + patchesLayout.rootScrollWidth > patchesLayout.rootClientWidth + 1 + ) { + layoutFailures.push( + `the Patches remediation actions overflowed their setting row (${JSON.stringify(patchesLayout)})` + ); + } + + if (layoutFailures.length > 0) { + throw new Error(`The mobile settings layout was invalid: ${layoutFailures.join("; ")}.`); + } + await settingsNavigator.close(); + return { + landingPage: path, + maintenance: maintenancePath, + patches: patchesPath, + remoteConfiguration: remotePath, + }; + }); } async function openSettingsInitialisationDialogueForInspection(isP2P: boolean): Promise { @@ -795,6 +966,72 @@ async function verifyPendingSettingsInitialisationFlow(): Promise<{ choice: stri }); } +function createSettingsPluginData(settingsOnlyRun: boolean): Record { + return { + doctorProcessedVersion: settingsOnlyRun ? "1.0.0" : "0.25.27", + isConfigured: true, + liveSync: false, + versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage, + notifyThresholdOfRemoteStorageSize: 0, + syncOnStart: false, + syncOnSave: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncAfterMerge: false, + periodicReplication: false, + handleFilenameCaseSensitive: false, + useAdvancedMode: false, + usePowerUserMode: false, + useEdgeCaseMode: false, + }; +} + +async function captureDeclarativeMobileSettingsInFreshSession( + binary: string, + cliBinary: string +): Promise< + | { + landingPage: string; + maintenance: string; + patches: string; + remoteConfiguration: string; + } + | undefined +> { + // Enter mobile mode before LiveSync first loads so Obsidian fires the + // mobile settings-registration lifecycle used by a real mobile start-up. + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + ...createSettingsPluginData(true), + useAdvancedMode: true, + useEdgeCaseMode: true, + usePowerUserMode: true, + }, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + lifecycle: { + beforePluginStart: async ({ remoteDebuggingPort }) => { + await setObsidianMobileTestModeBeforePluginStart(remoteDebuggingPort, true, uiTimeoutMs); + }, + }, + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await resumePendingCompatibilityReviewForSettings(); + return await captureDeclarativeMobileSettings(); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + async function main(): Promise { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); @@ -804,29 +1041,14 @@ async function main(): Promise { const vault = await createTemporaryVault(); await mkdir(diagnosticsDirectory, { recursive: true }); let session: ObsidianLiveSyncSession | undefined; + let settingsRenderer: "declarative" | "imperative" | undefined; try { session = await startObsidianLiveSyncSession({ binary, cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), - pluginData: { - doctorProcessedVersion: settingsOnly ? "1.0.0" : "0.25.27", - isConfigured: true, - liveSync: false, - versionUpFlash: settingsOnly ? "" : compatibilityReviewMessage, - notifyThresholdOfRemoteStorageSize: 0, - syncOnStart: false, - syncOnSave: false, - syncOnEditorSave: false, - syncOnFileOpen: false, - syncAfterMerge: false, - periodicReplication: false, - handleFilenameCaseSensitive: false, - useAdvancedMode: false, - usePowerUserMode: false, - useEdgeCaseMode: false, - }, + pluginData: createSettingsPluginData(settingsOnly), lifecycle: settingsOnly ? { afterLaunch: async ({ remoteDebuggingPort }) => { @@ -843,11 +1065,9 @@ async function main(): Promise { await verifyCompatibilityReview(); await verifyConfigDoctorFollowsCompatibilityReview(); } - const settingsRenderer = await verifyEffectiveSettings(); + settingsRenderer = await verifyEffectiveSettings(); const initialisation = await verifyPendingSettingsInitialisationFlow(); const p2pInitialisation = await captureP2PSettingsInitialisationDialogue(); - const mobileLanding = settingsRenderer === "declarative" ? await captureDeclarativeMobileLanding() : undefined; - if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`); console.log( `Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}` ); @@ -858,6 +1078,17 @@ async function main(): Promise { } await vault.dispose(); } + + const mobileSettings = + settingsRenderer === "declarative" + ? await captureDeclarativeMobileSettingsInFreshSession(binary, cli.binary) + : undefined; + if (mobileSettings) { + console.log(`Declarative mobile settings landing page: ${mobileSettings.landingPage}`); + console.log(`Declarative mobile Remote Configuration page: ${mobileSettings.remoteConfiguration}`); + console.log(`Declarative mobile Maintenance page: ${mobileSettings.maintenance}`); + console.log(`Declarative mobile Patches page: ${mobileSettings.patches}`); + } } main().catch((error: unknown) => { diff --git a/updates.md b/updates.md index d2c40750..0785b24a 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Interface and translation + +#### Fixed + +- Remote Configuration section headings no longer overlap their contents when scrolling on mobile. Action buttons in Remote Configuration, Maintenance, and Patches now remain inside the settings pane on narrow screens. + ## 1.0.20 ~~1.0.19~~ was cancelled because prerelease validation exposed an incorrect warning at start-up.