From cf3dafe1d84073f93eefd181cffd443540b7cc5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:08:12 +0000 Subject: [PATCH 1/5] build(deps): bump undici Bumps and [undici](https://github.com/nodejs/undici). These dependencies needed to be updated together. Updates `undici` from 7.27.1 to 7.29.1 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.27.1...v7.29.1) Updates `undici` from 6.26.0 to 6.28.1 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.27.1...v7.29.1) --- updated-dependencies: - dependency-name: undici dependency-version: 7.29.1 dependency-type: indirect - dependency-name: undici dependency-version: 6.28.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index 90fa449..c072cbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3925,10 +3925,11 @@ } }, "node_modules/cheerio/node_modules/undici": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", - "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=20.18.1" } @@ -4521,18 +4522,6 @@ } } }, - "node_modules/create-wdio/node_modules/@types/node": { - "version": "22.20.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", - "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/create-wdio/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4631,15 +4620,6 @@ "node": ">=8" } }, - "node_modules/create-wdio/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/create-wdio/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -11544,10 +11524,11 @@ } }, "node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18.17" } From 1d1e22e8e9463f1fcf166e39751cca27477ecc83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:08:16 +0000 Subject: [PATCH 2/5] build(deps-dev): bump ip-address from 10.2.0 to 10.7.2 Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.7.2. - [Release notes](https://github.com/beaugunderson/ip-address/releases) - [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.7.2) --- updated-dependencies: - dependency-name: ip-address dependency-version: 10.7.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 90fa449..72e9ae3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4521,18 +4521,6 @@ } } }, - "node_modules/create-wdio/node_modules/@types/node": { - "version": "22.20.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", - "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/create-wdio/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4631,15 +4619,6 @@ "node": ">=8" } }, - "node_modules/create-wdio/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/create-wdio/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -7378,10 +7357,11 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 12" } From 677d2404fb035a28b3496a4c4d5f9e0a2e37372d Mon Sep 17 00:00:00 2001 From: mrKazzila Date: Thu, 17 Sep 2026 15:08:06 +0300 Subject: [PATCH 3/5] fix: stabilize plugin lifecycle and settings layout --- README.md | 4 +- docs/PROJECT_STATE.md | 12 ++- docs/RELEASE_QA.md | 4 +- src/main.ts | 9 +- src/settings-tab.ts | 134 ++++++++++-------------- src/settings-welcome.ts | 14 ++- styles.css | 27 ++++- tests/e2e/specs/read-only-smoke.e2e.mjs | 28 ++++- tests/main.editor-events.test.ts | 4 +- tests/main.observer.test.ts | 16 +-- tests/plugin-settings.test.ts | 30 +++++- tests/settings-tab-lifecycle.test.ts | 66 ++++++++++-- tests/settings-welcome.test.ts | 37 +++++++ 13 files changed, 270 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 23c734e..d438903 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ If the rule does not apply, paste the exact note path into **Path tester** and c *All Markdown files mode protects every Markdown note unless an exclude rule matches.* -The welcome window is shown once for the current onboarding version and provides a shortcut to the settings page. +The welcome window is shown only after a new installation. Plugin updates, re-enabling, and Obsidian restarts do not show it again. It provides a shortcut to the settings page. ## Installation @@ -245,6 +245,8 @@ The settings screen is organized around the main workflow: - **Path tester** explains how a source resolves and whether the resulting note is protected. - **Advanced** contains collapsible `Matching` and `Debug flags` sections. +Each section title is contained in its single section card; the settings UI does not add a second heading or outer frame around these cards. Top-level cards stay aligned with compact spacing, with extra separation before **Path rules** to distinguish configuration from rule management. + ### Path rules Each row contains: diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 105f0f5..271f1e7 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -64,7 +64,7 @@ High-level modules: - `src/settings-path-tester.ts` - Path tester section rendering - `src/settings-welcome.ts` - - Versioned onboarding modal and best-effort settings opening helper + - First-install welcome modal and best-effort settings opening helper - `src/source-input-limits.ts` - Source-input length policy, overflow handling, and display-safe truncation helpers - `src/constants.ts` @@ -240,17 +240,19 @@ UI module split: - `src/settings-focus.ts` owns stable focus capture, restoration, and first-control focus. - `src/settings-ui-state.ts` owns the pure summary/warning calculation used by the rules section. - `src/settings-path-tester.ts` owns the path tester section. -- `src/settings-welcome.ts` owns the versioned onboarding modal shown for undismissed onboarding versions. +- `src/settings-welcome.ts` owns the first-install welcome modal. - `src/source-input-limits.ts` owns accepted source lengths, overflow state, and display truncation. - `src/rule-diagnostics.ts` provides pure helpers used by settings UI (rule diagnostics + path tester computations). - Welcome modal: - - shown only when `dismissedWelcomeVersion < WELCOME_VERSION` - - dismissing or using `Open settings` saves the current onboarding version + - shown only when plugin data is absent on load; existing data marks an update or restart + - updates, re-enabling, and Obsidian restarts do not trigger it + - buttons, Escape, and the close control save the current dismissal version through one idempotent path - action buttons use separated 44 px targets and inset focus indicators to avoid visual overlap - Settings layout keeps the header, combined Enabled/mode card, Path rules, and Path tester workflow permanently visible. `Matching` and `Debug flags` remain inline collapsible sections with ephemeral open state in both renderers. - On Obsidian 1.13+, `Read-only behavior` is one custom declarative item with `Enabled` and `Mode` search aliases, preserving the legacy two-card composition instead of allowing the host to split those controls into separate blocks. -- Obsidian 1.13+ indexes the two Advanced sections and exposes their four control names as search aliases; custom `render` callbacks preserve the plugin-owned accordion controls and their persistence side effects. +- Obsidian 1.13+ renders the complete settings stack through one custom item inside one heading-free group. The plugin therefore owns every inter-card gap directly; Obsidian cannot insert declarative section spacing between Mode, Path rules, Path tester, and Advanced. The scoped wrapper reset removes host borders/backgrounds, all cards remain full-width, and the stack uses the compact `--size-4-2` gap on desktop and narrow layouts. Path rules adds one extra `--size-4-2` top margin, making only the Mode-to-Path-rules separation twice the base gap. +- The Advanced declarative item exposes both section names and all four control names as search aliases; its custom `render` callback preserves the plugin-owned accordion controls and their persistence side effects. - Obsidian versions before 1.13 use the legacy `display()` path, so the supported runtime baseline remains 1.10.3. - Header card: - title `Read Only View` diff --git a/docs/RELEASE_QA.md b/docs/RELEASE_QA.md index fbd2c7d..08b95f9 100644 --- a/docs/RELEASE_QA.md +++ b/docs/RELEASE_QA.md @@ -23,7 +23,9 @@ Concise manual QA checklist for the next plugin release. Use the detailed releas ## Welcome, keyboard, and focus -- With an undismissed onboarding version, confirm the welcome modal appears once and **Open settings** opens this plugin's settings page. +- With plugin data absent, confirm the welcome modal appears and **Open settings** opens this plugin's settings page. +- Close the welcome modal with Escape or its close control, reload the plugin, and confirm it does not reappear. +- Update or re-enable an existing installation and confirm the welcome modal does not appear. - Tab through both mode buttons, rule-row controls, Path tester, and the **Matching** and **Debug flags** disclosure buttons. - Confirm native `Enter`/`Space` activation works, focus indicators stay visible, and disclosure controls expose correct `aria-expanded` state. - Add, delete, enable, disable, or edit a rule and confirm focus returns to the corresponding stable control after the UI rerenders. diff --git a/src/main.ts b/src/main.ts index 0c01fed..3f34d0f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,7 +34,7 @@ export default class ReadOnlyViewPlugin extends Plugin { private compiledRuleMatcherKey = getCompiledRuleMatcherKey(this.settings); async onload(): Promise { - await this.loadSettings(); + const isFreshInstall = await this.loadSettings(); this.registerEditorExtension(createEditorReadOnlyExtension({ shouldForceReadOnlyPath: (path) => this.shouldForceReadOnlyPath(path), onReadOnlyInteraction: (info, reason) => { @@ -89,7 +89,9 @@ export default class ReadOnlyViewPlugin extends Plugin { this.addSettingTab(new ForceReadModeSettingTab(this.app, this)); await this.applyAllOpenMarkdownLeaves('onload'); - maybeShowWelcomeModal(this.app, this, this.manifest.id); + if (isFreshInstall) { + maybeShowWelcomeModal(this.app, this, this.manifest.id); + } } onunload(): void { @@ -108,10 +110,11 @@ export default class ReadOnlyViewPlugin extends Plugin { } } - async loadSettings(): Promise { + async loadSettings(): Promise { const loaded: unknown = await this.loadData(); this.settings = mergeLoadedSettings(loaded); this.rebuildCompiledRuleMatcher(); + return loaded === null || loaded === undefined; } async saveSettings(): Promise { diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 4895fe0..84743bd 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -43,6 +43,7 @@ type DisclosureController = { }; type StaticSectionController = { + sectionEl: HTMLElement; bodyEl: HTMLElement; setSummary: (summary: string) => void; }; @@ -87,77 +88,27 @@ export class ForceReadModeSettingTab extends PluginSettingTab { declarativeTab.update?.(); }; return [ - { - name: 'Read-only behavior', - desc: 'Enable read-only enforcement and choose how it applies to Markdown notes.', - aliases: ['Enabled', 'Mode'], - render: (setting) => { - const containerEl = this.prepareDeclarativeSetting(setting); - this.renderHeaderSection(containerEl); - const sectionEl = this.createCardSection(containerEl); - renderPrimarySettings(sectionEl, this.plugin, refresh); - renderModeSelector(sectionEl, this.plugin, refresh); - }, - }, - { - type: 'group', - heading: 'Path rules', - items: [ - { - name: 'Path rules', - desc: 'Choose folders or notes to keep in Reading view.', - render: (setting) => this.renderDeclarativePathRules(setting), - }, - ], - }, { type: 'group', - heading: 'Path tester', + cls: 'read-only-view-flat-group', items: [ { - name: 'Path tester', - desc: 'Test a vault path against the current rules.', - render: (setting) => this.renderDeclarativePathTester(setting), - }, - ], - }, - { - type: 'group', - heading: 'Advanced', - items: [ - { - name: 'Matching', - desc: 'Choose how paths are compared before rules are evaluated.', - aliases: ['Use glob patterns', 'Case sensitive'], - render: (setting) => { - const containerEl = this.prepareDeclarativeSetting(setting); - const section = this.createCollapsibleSection( - containerEl, - 'matching', - 'Matching', - 'Choose how paths are compared before rules are evaluated.', - getMatchingSummary(this.plugin.settings), - false, - ); - renderMatchingSettings(section.bodyEl, this.plugin, refresh); - }, - }, - { - name: 'Debug flags', - desc: 'Enable extra logging only when diagnosing rule behavior.', - aliases: ['Debug logging', 'Debug: verbose paths'], - render: (setting) => { - const containerEl = this.prepareDeclarativeSetting(setting); - const section = this.createCollapsibleSection( - containerEl, - 'debugFlags', - 'Debug flags', - 'Enable extra logging only when diagnosing rule behavior.', - getDebugSummary(this.plugin.settings), - false, - ); - renderDebugSettings(section.bodyEl, this.plugin, refresh); - }, + name: 'Read-only behavior', + desc: 'Configure read-only behavior, path rules, matching, and diagnostics.', + aliases: [ + 'Enabled', + 'Mode', + 'Path rules', + 'Path tester', + 'Advanced', + 'Matching', + 'Use glob patterns', + 'Case sensitive', + 'Debug flags', + 'Debug logging', + 'Debug: verbose paths', + ], + render: (setting) => this.renderDeclarativeSettings(setting, refresh), }, ], }, @@ -193,6 +144,7 @@ export class ForceReadModeSettingTab extends PluginSettingTab { !this.plugin.settings.forceAllMarkdownReadOnly, ), ); + pathRulesSection.sectionEl.addClass('read-only-view-path-rules-card'); this.renderRuleEditor(pathRulesSection, headerIndicators); const pathTesterSection = this.createStaticWorkflowSection( @@ -241,9 +193,14 @@ export class ForceReadModeSettingTab extends PluginSettingTab { return setting.settingEl; } - private renderDeclarativePathRules(setting: Setting): () => void { + private renderDeclarativeSettings(setting: Setting, refresh: () => void): () => void { const containerEl = this.prepareDeclarativeSetting(setting); - const section = this.createStaticWorkflowSection( + const headerIndicators = this.renderHeaderSection(containerEl); + const modeSectionEl = this.createCardSection(containerEl); + renderPrimarySettings(modeSectionEl, this.plugin, refresh); + renderModeSelector(modeSectionEl, this.plugin, refresh); + + const pathRulesSection = this.createStaticWorkflowSection( containerEl, 'Path rules', 'Choose folders or notes to keep in Reading view.', @@ -255,20 +212,42 @@ export class ForceReadModeSettingTab extends PluginSettingTab { !this.plugin.settings.forceAllMarkdownReadOnly, ), ); - const controller = this.renderRuleEditor(section); - return () => this.disposeRuleEditor(controller); - } + pathRulesSection.sectionEl.addClass('read-only-view-path-rules-card'); + const ruleEditor = this.renderRuleEditor(pathRulesSection, headerIndicators); - private renderDeclarativePathTester(setting: Setting): () => void { - const containerEl = this.prepareDeclarativeSetting(setting); - const section = this.createStaticWorkflowSection( + const pathTesterSection = this.createStaticWorkflowSection( containerEl, 'Path tester', 'Test a vault path against the current rules.', getPathTesterSummary(), ); - const controller = this.renderPathTester(section.bodyEl); - return () => this.disposePathTester(controller); + const pathTester = this.renderPathTester(pathTesterSection.bodyEl); + + const advancedSectionEl = this.createCardSection(containerEl, 'Advanced'); + const matchingSection = this.createCollapsibleSection( + advancedSectionEl, + 'matching', + 'Matching', + 'Choose how paths are compared before rules are evaluated.', + getMatchingSummary(this.plugin.settings), + false, + ); + renderMatchingSettings(matchingSection.bodyEl, this.plugin, refresh); + + const debugSection = this.createCollapsibleSection( + advancedSectionEl, + 'debugFlags', + 'Debug flags', + 'Enable extra logging only when diagnosing rule behavior.', + getDebugSummary(this.plugin.settings), + false, + ); + renderDebugSettings(debugSection.bodyEl, this.plugin, refresh); + + return () => { + this.disposeRuleEditor(ruleEditor); + this.disposePathTester(pathTester); + }; } private renderRuleEditor( @@ -412,6 +391,7 @@ export class ForceReadModeSettingTab extends PluginSettingTab { const bodyEl = sectionEl.createDiv({ cls: 'read-only-view-static-section-body' }); return { + sectionEl, bodyEl, setSummary: (nextSummary: string) => { summaryEl.setText(nextSummary); diff --git a/src/settings-welcome.ts b/src/settings-welcome.ts index 3fa8e69..9ea628c 100644 --- a/src/settings-welcome.ts +++ b/src/settings-welcome.ts @@ -20,6 +20,8 @@ export function shouldShowWelcomeModal(settings: ForceReadModeSettings): boolean } export class WelcomeModal extends Modal { + private dismissalPromise: Promise | null = null; + constructor( app: App, private readonly plugin: WelcomeSettingsPlugin, @@ -65,14 +67,22 @@ export class WelcomeModal extends Modal { onClose(): void { this.contentEl.empty(); + void this.persistDismissal(); } private async dismiss(): Promise { - this.plugin.settings.dismissedWelcomeVersion = WELCOME_VERSION; - await this.plugin.saveSettings(); + await this.persistDismissal(); this.close(); } + private persistDismissal(): Promise { + if (!this.dismissalPromise) { + this.plugin.settings.dismissedWelcomeVersion = WELCOME_VERSION; + this.dismissalPromise = this.plugin.saveSettings(); + } + return this.dismissalPromise; + } + private async dismissAndOpenSettings(): Promise { await this.dismiss(); openPluginSettingsBestEffort(this.app, this.pluginId); diff --git a/styles.css b/styles.css index 1606a83..c1ae3a2 100644 --- a/styles.css +++ b/styles.css @@ -22,18 +22,32 @@ .read-only-view-settings { display: flex; flex-direction: column; - gap: 1rem; + row-gap: var(--size-4-2) !important; } .read-only-view-settings .read-only-view-declarative-setting { display: flex; flex-direction: column; align-items: stretch; - gap: 1rem; + row-gap: var(--size-4-2) !important; + box-sizing: border-box; + width: 100%; padding: 0; border: 0; } +.read-only-view-settings .read-only-view-flat-group, +.read-only-view-settings .read-only-view-flat-group > .setting-items { + box-sizing: border-box; + width: 100%; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + .read-only-view-header-card, .read-only-view-section-card, .read-only-view-disclosure-row { @@ -44,9 +58,16 @@ .read-only-view-header-card, .read-only-view-section-card { + box-sizing: border-box; + width: 100%; padding: 1rem; } +.read-only-view-settings > .read-only-view-path-rules-card, +.read-only-view-declarative-setting > .read-only-view-path-rules-card { + margin-top: var(--size-4-2); +} + .read-only-view-header-title { font-size: 1.45rem; font-weight: 780; @@ -644,7 +665,7 @@ @media (max-width: 800px) { .read-only-view-settings { - gap: 0.85rem; + row-gap: var(--size-4-2) !important; } .read-only-view-header-card, diff --git a/tests/e2e/specs/read-only-smoke.e2e.mjs b/tests/e2e/specs/read-only-smoke.e2e.mjs index 281a8f3..2863207 100644 --- a/tests/e2e/specs/read-only-smoke.e2e.mjs +++ b/tests/e2e/specs/read-only-smoke.e2e.mjs @@ -1,4 +1,4 @@ -/* eslint-disable no-undef */ +/* eslint-disable no-undef, obsidianmd/prefer-active-doc -- Webdriver executes this code inside the active Obsidian test window. */ import assert from 'node:assert/strict'; import { @@ -24,6 +24,32 @@ describe('Read Only View desktop smoke tests', () => { assert.equal(await isPluginEnabled('read-only-view'), true); }); + it('keeps settings cards aligned with compact, even gaps', async () => { + await browser.execute(() => { + const electron = globalThis.require?.('electron'); + electron?.remote?.getCurrentWindow?.().setSize?.(1800, 1200); + }); + await browser.pause(250); + const snapshot = await browser.execute(() => { + globalThis.app?.setting?.open?.(); + globalThis.app?.setting?.openTabById?.('read-only-view'); + const cards = Array.from(document.querySelectorAll( + '.read-only-view-header-card, .read-only-view-section-card', + )); + return cards.map((card) => { + const rect = card.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, width: rect.width }; + }); + }); + assert.ok(snapshot.length >= 5); + + const expectedWidth = snapshot[0].width; + assert.ok(snapshot.every(({ width }) => Math.abs(width - expectedWidth) < 0.5)); + const gaps = snapshot.slice(1).map(({ top }, index) => top - snapshot[index].bottom); + assert.ok(gaps[1] > 10 && gaps[1] <= 18); + assert.ok(gaps.filter((_, index) => index !== 1).every((gap) => gap > 0 && gap <= 10)); + }); + it('keeps a protected note in Reading view', async () => { await openMarkdownFile('Read Only/Docs/API overview.md'); await waitForMode('preview'); diff --git a/tests/main.editor-events.test.ts b/tests/main.editor-events.test.ts index bb6df8b..ea8618b 100644 --- a/tests/main.editor-events.test.ts +++ b/tests/main.editor-events.test.ts @@ -6,7 +6,7 @@ import { DEFAULT_SETTINGS } from '../src/matcher.js'; import { createMainTestHarness } from './helpers/test-setup.js'; type PatchablePlugin = ReadOnlyViewPlugin & { - loadSettings: () => Promise; + loadSettings: () => Promise; registerEvent: (unsubscribe: () => void) => void; addCommand: (command: unknown) => unknown; applyAllOpenMarkdownLeaves: (reason: string) => Promise; @@ -38,7 +38,7 @@ function createEditorEventPlugin() { debug: false, debugVerbosePaths: false, }; - plugin.loadSettings = async () => undefined; + plugin.loadSettings = async () => false; plugin.registerEvent = () => undefined; plugin.addCommand = ((command: unknown) => command) as PatchablePlugin['addCommand']; plugin.applyAllOpenMarkdownLeaves = async () => undefined; diff --git a/tests/main.observer.test.ts b/tests/main.observer.test.ts index 0f0aef3..8213b86 100644 --- a/tests/main.observer.test.ts +++ b/tests/main.observer.test.ts @@ -8,7 +8,7 @@ import { createMockWorkspaceLeaf } from './helpers/obsidian-mocks.js'; import { createMainTestHarness } from './helpers/test-setup.js'; type PatchablePlugin = ReadOnlyViewPlugin & { - loadSettings: () => Promise; + loadSettings: () => Promise; applyAllOpenMarkdownLeaves: (reason: string) => Promise; registerEvent: (unsubscribe: () => void) => void; }; @@ -206,7 +206,7 @@ test('layout-change invalidates leaf container cache', async () => { const container = leaf.view.containerEl as unknown as MockHTMLElement; container.appendChild(nestedNode); - plugin.loadSettings = async () => undefined; + plugin.loadSettings = async () => false; plugin.applyAllOpenMarkdownLeaves = async () => undefined; plugin.registerEvent = () => undefined; (plugin as unknown as { addCommand: (command: unknown) => unknown }).addCommand = () => ({}); @@ -233,7 +233,7 @@ test('layout-change invalidates leaf container cache', async () => { test('workspace reconciliation attaches observer for popout document added after load', async () => { const { harness, plugin } = createObserverPlugin(); - plugin.loadSettings = async () => undefined; + plugin.loadSettings = async () => false; plugin.applyAllOpenMarkdownLeaves = async () => undefined; plugin.registerEvent = () => undefined; (plugin as unknown as { addCommand: (command: unknown) => unknown }).addCommand = () => ({}); @@ -306,7 +306,7 @@ test('workspace event burst is coalesced into one reapply pass', async () => { const { harness, plugin } = createObserverPlugin(); const reapplyReasons: string[] = []; - plugin.loadSettings = async () => undefined; + plugin.loadSettings = async () => false; plugin.applyAllOpenMarkdownLeaves = async (reason: string) => { reapplyReasons.push(reason); }; @@ -341,7 +341,7 @@ test('active-leaf-change uses targeted leaf reapply and skips full-scan reapply' const { harness, leaf, plugin } = createObserverPlugin(); const reapplyReasons: string[] = []; - plugin.loadSettings = async () => undefined; + plugin.loadSettings = async () => false; plugin.applyAllOpenMarkdownLeaves = async (reason: string) => { reapplyReasons.push(reason); }; @@ -368,7 +368,7 @@ test('active-leaf-change + file-open uses targeted leaf reapply and skips full-s const { harness, leaf, plugin } = createObserverPlugin(); const reapplyReasons: string[] = []; - plugin.loadSettings = async () => undefined; + plugin.loadSettings = async () => false; plugin.applyAllOpenMarkdownLeaves = async (reason: string) => { reapplyReasons.push(reason); }; @@ -396,7 +396,7 @@ test('layout-change in burst keeps full-scan reapply', async () => { const { harness, leaf, plugin } = createObserverPlugin(); const reapplyReasons: string[] = []; - plugin.loadSettings = async () => undefined; + plugin.loadSettings = async () => false; plugin.applyAllOpenMarkdownLeaves = async (reason: string) => { reapplyReasons.push(reason); }; @@ -428,7 +428,7 @@ test('re-apply command remains immediate and bypasses workspace event scheduler' const reapplyReasons: string[] = []; const commands = new Map Promise>(); - plugin.loadSettings = async () => undefined; + plugin.loadSettings = async () => false; plugin.applyAllOpenMarkdownLeaves = async (reason: string) => { reapplyReasons.push(reason); }; diff --git a/tests/plugin-settings.test.ts b/tests/plugin-settings.test.ts index 16c7474..b484075 100644 --- a/tests/plugin-settings.test.ts +++ b/tests/plugin-settings.test.ts @@ -13,7 +13,7 @@ import { PATH_SOURCE_INPUT_MAX_LENGTH } from '../src/source-input-limits.js'; type LoadSettingsPlugin = { loadData: () => Promise; - loadSettings: () => Promise; + loadSettings: () => Promise; getCompiledRuleMatcher: () => CompiledRuleMatcher; settings: ForceReadModeSettings; compiledRuleMatcher: CompiledRuleMatcher; @@ -254,6 +254,34 @@ test('completely invalid loaded payload is handled safely', () => { }); }); +test('loadSettings identifies missing persisted data as a fresh install', async () => { + for (const loaded of [null, undefined]) { + const plugin = createPlugin(loaded); + + assert.equal(await plugin.loadSettings(), true); + } +}); + +test('loadSettings treats legacy settings without welcome state as an existing install', async () => { + const plugin = createPlugin({ + enabled: true, + includeRules: ['docs/**'], + }); + + assert.equal(await plugin.loadSettings(), false); +}); + +test('loadSettings treats valid and malformed persisted payloads as existing installs', async () => { + for (const loaded of [ + { ...DEFAULT_SETTINGS, dismissedWelcomeVersion: 1 }, + 'broken-payload', + ]) { + const plugin = createPlugin(loaded); + + assert.equal(await plugin.loadSettings(), false); + } +}); + test('loadSettings handles malformed persisted settings and rebuilds matcher safely', async () => { const plugin = createPlugin({ enabled: true, diff --git a/tests/settings-tab-lifecycle.test.ts b/tests/settings-tab-lifecycle.test.ts index 3df5b65..4aad9de 100644 --- a/tests/settings-tab-lifecycle.test.ts +++ b/tests/settings-tab-lifecycle.test.ts @@ -5,6 +5,7 @@ import test from 'node:test'; import { Setting, type SettingDefinition, + type SettingDefinitionGroup, type SettingDefinitionItem, } from 'obsidian'; import { ForceReadModeSettingTab } from '../src/settings-tab.js'; @@ -116,6 +117,7 @@ test('declarative settings expose every searchable setting', () => { 'Mode', 'Path rules', 'Path tester', + 'Advanced', 'Matching', 'Use glob patterns', 'Case sensitive', @@ -124,6 +126,12 @@ test('declarative settings expose every searchable setting', () => { 'Debug: verbose paths', ]); assert.ok(definitions.every((definition) => definition.desc)); + const groups = tab.getSettingDefinitions().filter( + (definition): definition is SettingDefinitionGroup => 'type' in definition && definition.type === 'group', + ); + assert.equal(groups.length, 1); + assert.ok(groups.every((group) => group.heading === undefined)); + assert.ok(groups.every((group) => group.cls === 'read-only-view-flat-group')); }); test('declarative primary settings preserve the legacy card composition', () => { @@ -143,7 +151,7 @@ test('declarative primary settings preserve the legacy card composition', () => const headerCards = container.querySelectorAll('.read-only-view-header-card'); const sectionCards = container.querySelectorAll('.read-only-view-section-card'); assert.equal(headerCards.length, 1); - assert.equal(sectionCards.length, 1); + assert.equal(sectionCards.length, 4); assert.ok(collectTexts(headerCards[0]!).includes('Read Only View')); assert.ok(collectTexts(sectionCards[0]!).includes('Enabled')); assert.ok(collectTexts(sectionCards[0]!).includes('Mode')); @@ -153,6 +161,34 @@ test('declarative primary settings preserve the legacy card composition', () => } }); +test('declarative workflow sections render one card with one internal title', () => { + const dom = installDomMocks(); + const container = new MockHTMLElement(); + const { plugin } = createPlugin(); + const tab = new ForceReadModeSettingTab({} as never, plugin as never); + tab.containerEl = container as unknown as HTMLElement; + + try { + const definition = collectSettingDefinitions(tab.getSettingDefinitions()) + .find((item) => item.name === 'Read-only behavior'); + assert.ok(definition?.render); + const setting = new Setting(container as unknown as HTMLElement); + const cleanup = definition.render(setting, {} as never); + const sectionCards = container.querySelectorAll('.read-only-view-section-card'); + assert.equal(sectionCards.length, 4); + assert.equal(container.querySelectorAll('.read-only-view-path-rules-card').length, 1); + + for (const sectionName of ['Path rules', 'Path tester']) { + assert.equal(collectTexts(container).filter((text) => text === sectionName).length, 1); + } + if (typeof cleanup === 'function') { + cleanup(); + } + } finally { + dom.restore(); + } +}); + test('declarative advanced settings render inline collapsible sections', () => { const dom = installDomMocks(); const container = new MockHTMLElement(); @@ -161,23 +197,31 @@ test('declarative advanced settings render inline collapsible sections', () => { tab.containerEl = container as unknown as HTMLElement; try { - const matchingDefinition = collectSettingDefinitions(tab.getSettingDefinitions()) - .find((definition) => definition.name === 'Matching'); - assert.ok(matchingDefinition?.render); + const primaryDefinition = collectSettingDefinitions(tab.getSettingDefinitions()) + .find((definition) => definition.name === 'Read-only behavior'); + assert.ok(primaryDefinition?.render); const setting = new Setting(container as unknown as HTMLElement); - matchingDefinition.render(setting, {} as never); + const cleanup = primaryDefinition.render(setting, {} as never); + + const sectionCards = container.querySelectorAll('.read-only-view-section-card'); + assert.equal(sectionCards.length, 4); + assert.equal(collectTexts(container).filter((text) => text === 'Advanced').length, 1); - const disclosure = container.querySelector('.read-only-view-disclosure-row'); - assert.ok(disclosure); - assert.ok(!disclosure.matches('.is-open')); + const disclosures = container.querySelectorAll('.read-only-view-disclosure-row'); + assert.equal(disclosures.length, 2); + assert.ok(!disclosures[0]!.matches('.is-open')); + assert.ok(!disclosures[1]!.matches('.is-open')); assert.ok(collectTexts(container).includes('Glob matching ยท Case-insensitive')); const toggle = container.querySelector('.read-only-view-disclosure-toggle'); assert.ok(toggle); toggle.trigger('click'); - assert.ok(disclosure.matches('.is-open')); + assert.ok(disclosures[0]!.matches('.is-open')); assert.ok(collectTexts(container).includes('Use glob patterns')); assert.ok(collectTexts(container).includes('Case sensitive')); + if (typeof cleanup === 'function') { + cleanup(); + } } finally { dom.restore(); } @@ -226,7 +270,7 @@ test('declarative rule editor cleanup cancels pending saves', async () => { try { await withFakeTimeouts(async ({ flushAll }) => { const definition = collectSettingDefinitions(tab.getSettingDefinitions()) - .find((item) => item.name === 'Path rules'); + .find((item) => item.name === 'Read-only behavior'); assert.ok(definition?.render); const setting = new Setting(container as unknown as HTMLElement); const cleanup = definition.render(setting, {} as never); @@ -259,7 +303,7 @@ test('declarative path tester cleanup cancels pending render work', async () => try { await withFakeTimeouts(async ({ flushAll }) => { const definition = collectSettingDefinitions(tab.getSettingDefinitions()) - .find((item) => item.name === 'Path tester'); + .find((item) => item.name === 'Read-only behavior'); assert.ok(definition?.render); const setting = new Setting(container as unknown as HTMLElement); const cleanup = definition.render(setting, {} as never); diff --git a/tests/settings-welcome.test.ts b/tests/settings-welcome.test.ts index 6127ce3..f7e6daa 100644 --- a/tests/settings-welcome.test.ts +++ b/tests/settings-welcome.test.ts @@ -80,6 +80,43 @@ test('welcome modal close button saves dismissal state for current version', asy } }); +test('closing the welcome modal saves dismissal state', async () => { + const dom = installDomMocks(); + const { plugin, getSaveCalls } = createPlugin(); + + try { + const modal = maybeShowWelcomeModal(new App(), plugin, 'read-only-view'); + assert.ok(modal); + + modal.close(); + await Promise.resolve(); + + assert.equal(plugin.settings.dismissedWelcomeVersion, WELCOME_VERSION); + assert.equal(getSaveCalls(), 1); + } finally { + dom.restore(); + } +}); + +test('welcome dismissal is saved only once when the modal closes repeatedly', async () => { + const dom = installDomMocks(); + const { plugin, getSaveCalls } = createPlugin(); + + try { + const modal = maybeShowWelcomeModal(new App(), plugin, 'read-only-view'); + assert.ok(modal); + + modal.close(); + modal.close(); + await Promise.resolve(); + + assert.equal(plugin.settings.dismissedWelcomeVersion, WELCOME_VERSION); + assert.equal(getSaveCalls(), 1); + } finally { + dom.restore(); + } +}); + test('welcome modal open settings button saves dismissal state and opens settings when available', async () => { const dom = installDomMocks(); const { plugin, getSaveCalls } = createPlugin(); From 7c3bffa1c4961b16f18fee07f7a127b5c9a331b7 Mon Sep 17 00:00:00 2001 From: mrKazzila Date: Thu, 17 Sep 2026 16:11:50 +0300 Subject: [PATCH 4/5] fix: prevent rule type picker from reopening on mobile --- README.md | 2 ++ docs/PROJECT_STATE.md | 1 + src/settings-rule-editor.ts | 26 ++++++++++---- tests/settings-rule-editor.test.ts | 54 ++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d438903..faa41a9 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,8 @@ For a system folder, the tester shows the resolved folder and asks for a specifi On narrow phone and tablet settings panes, the interface switches to a stacked layout so rule controls, descriptions, resolved paths, and errors remain readable. Rules created from a system path on desktop continue to work from their saved vault-relative paths on mobile. +Changing a rule between `Include` and `Exclude` updates the existing row in place, so the mobile system picker closes normally after a selection. + ### Advanced The `Matching` and `Debug flags` sections are collapsed by default and expand inline. Their summaries show the current state, and their individual controls remain discoverable through settings search. Open `Matching` to switch between prefix and glob matching or to change case sensitivity. Debug options are intended only for diagnosing rule behavior. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 271f1e7..eef5315 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -279,6 +279,7 @@ UI module split: - advanced rows show their resolved vault path or a specific inline error without rewriting the active input - all-Markdown mode visually marks every include row as inactive, excludes includes from active counts and diagnostics, and preserves each include rule's persisted enabled state - exclude rows remain active in all-Markdown mode unless individually disabled + - changing a row between include and exclude updates it in place; the type selector is not recreated or programmatically refocused, preventing the native mobile picker from reopening - add-rule button - zero rules is a valid editor state; deleting the final row does not create a placeholder or empty-line diagnostic - inline syntax help and README link diff --git a/src/settings-rule-editor.ts b/src/settings-rule-editor.ts index 122f602..8a9d135 100644 --- a/src/settings-rule-editor.ts +++ b/src/settings-rule-editor.ts @@ -174,15 +174,18 @@ function getRulesChangeReason(previous: RuleEditorUiState, next: RuleEditorUiSta const excludeChanged = previous.excludeText !== next.excludeText; const enabledChanged = previous.includeRuleEnabled.join() !== next.includeRuleEnabled.join() || previous.excludeRuleEnabled.join() !== next.excludeRuleEnabled.join(); - if (enabledChanged) { - return 'settings-rule-enabled'; - } if (includeChanged && !excludeChanged) { return 'settings-include-rules'; } if (!includeChanged && excludeChanged) { return 'settings-exclude-rules'; } + if (includeChanged && excludeChanged) { + return 'settings-path-rules'; + } + if (enabledChanged) { + return 'settings-rule-enabled'; + } return 'settings-path-rules'; } @@ -675,7 +678,7 @@ export function renderRuleEditor(options: RenderRuleEditorOptions): RuleEditorCo excludeIndex++; } } - rowControllers.set(row.id, { + const controller: RuleRowController = { row, type: row.type, indexWithinType, @@ -683,7 +686,8 @@ export function renderRuleEditor(options: RenderRuleEditorOptions): RuleEditorCo inactiveByMode, messageEl, resolution, - }); + }; + rowControllers.set(row.id, controller); enabledEl.addEventListener('change', () => { row.enabled = enabledEl.checked; @@ -693,7 +697,17 @@ export function renderRuleEditor(options: RenderRuleEditorOptions): RuleEditorCo typeSelectEl.addEventListener('change', () => { row.type = typeSelectEl.value === 'exclude' ? 'exclude' : 'include'; - renderRows(); + const inactiveByMode = row.type === 'include' && !includeRulesActive; + controller.type = row.type; + controller.inactiveByMode = inactiveByMode; + inputEl.placeholder = row.type === 'include' ? 'projects/' : 'projects/drafts/'; + inputEl.setAttr('aria-label', `${row.type === 'include' ? 'Include' : 'Exclude'} rule value`); + deleteButtonEl.setAttr('aria-label', `Delete ${row.type} rule`); + if (inactiveByMode) { + rowEl.addClass('is-inactive-by-mode'); + } else { + rowEl.removeClass('is-inactive-by-mode'); + } syncRows(true); }); diff --git a/tests/settings-rule-editor.test.ts b/tests/settings-rule-editor.test.ts index 9eec143..29a73e4 100644 --- a/tests/settings-rule-editor.test.ts +++ b/tests/settings-rule-editor.test.ts @@ -188,6 +188,60 @@ test('rules editor keeps focus on a control when rows rerender', async () => { } }); +test('rules editor changes rule type in place without refocusing the mobile select', async () => { + const dom = installDomMocks(); + const container = new MockHTMLElement(); + container.ownerDocument = dom.document; + const committed: Array<{ includeRules: string[]; excludeRules: string[]; reason: string }> = []; + + try { + renderRuleEditor({ + containerEl: container as unknown as HTMLElement, + includeRules: ['docs/a.md'], + excludeRules: [], + useGlobPatterns: true, + includeRulesActive: false, + onChange: async (state, reason) => { + committed.push({ + includeRules: state.includeRules, + excludeRules: state.excludeRules, + reason, + }); + }, + }); + + const initialSelect = container.querySelector('select'); + const initialRow = container.querySelector('.read-only-view-rule-row'); + const input = container.querySelector('.read-only-view-rule-input'); + const deleteButton = container.querySelector('.read-only-view-delete-rule-button'); + assert.ok(initialSelect); + assert.ok(initialRow); + assert.ok(input); + assert.ok(deleteButton); + assert.ok(initialRow.matches('.is-inactive-by-mode')); + + initialSelect.focus(); + initialSelect.value = 'exclude'; + initialSelect.trigger('change'); + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(container.querySelector('select'), initialSelect); + assert.equal(dom.document.activeElement, initialSelect); + assert.ok(!initialRow.matches('.is-inactive-by-mode')); + assert.equal(input.placeholder, 'projects/drafts/'); + assert.equal(input.getAttr('aria-label'), 'Exclude rule value'); + assert.equal(deleteButton.getAttr('aria-label'), 'Delete exclude rule'); + assert.deepEqual(committed.at(-1), { + includeRules: [], + excludeRules: ['docs/a.md'], + reason: 'settings-path-rules', + }); + } finally { + dom.restore(); + } +}); + test('all-Markdown mode visually inactivates only include rules without changing enabled state', () => { const dom = installDomMocks(); const container = new MockHTMLElement(); From 29750a45bc24b443ceb00e619249db07b733e3ab Mon Sep 17 00:00:00 2001 From: mrKazzila Date: Thu, 17 Sep 2026 16:25:07 +0300 Subject: [PATCH 5/5] ci: fix warnings --- package-lock.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4145049..7eca696 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4522,6 +4522,18 @@ } } }, + "node_modules/create-wdio/node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/create-wdio/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4620,6 +4632,15 @@ "node": ">=8" } }, + "node_modules/create-wdio/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/create-wdio/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",