Commit graph

151 commits

Author SHA1 Message Date
banisterious
8aa708eca7 1.7.2 2026-05-14 10:33:02 -07:00
banisterious
c183b61b08 docs(changelog): release prep for 1.7.2
Move the post-audit consistency-fix entries from [Unreleased] under
a dated 1.7.2 heading so the upcoming npm-version bump lands a
CHANGELOG that already reflects the new version.
2026-05-14 10:33:00 -07:00
banisterious
c379bbd7a9 chore: post-audit consistency fixes
Two micro-fixes from a post-audit code review:

1. src/main.ts — route addExcludedHeading and addExcludedPhrase catch
   blocks through this.log(..., errorMessage(error)) to match the
   pattern every other catch in the file already uses. Pre-fix the
   diagnostic printed via console.error unconditionally; post-fix it
   prints only when the user has debug logging enabled. The user-
   facing Notice is unchanged either way.

2. .gitignore — drop *.html and *.zip rules. The *.html rule was
   redundant with the docs/ui-mockups/ directory rule that already
   catches every HTML file currently in the tree, and the *.zip rule
   was a Phase-0-era defense-in-depth concern the CI release pipeline
   made obsolete. Removing both means future intentional HTML or zip
   files won't be silently swallowed.

CHANGELOG [Unreleased] entry covers both. All 134 vitest tests still
pass; lint and build clean.
2026-05-14 10:32:41 -07:00
banisterious
adbed06f8a chore(docs): archive audit planning docs
Audit is complete (1.7.1 shipped, catalog scan clean), so the planning
docs are now historical reference rather than active roadmap:

- docs/planning/audit-implementation-plan.md       -> archive/
- docs/planning/audit-phase-4-tests.md             -> archive/
- docs/planning/audit-phase-5-split.md             -> archive/
- docs/planning/audit-phase-6-cleanup.md           -> archive/

docs/planning/ now contains only the archive/ subdirectory. Future
non-audit planning work can land alongside as needed.
2026-05-13 13:09:03 -07:00
banisterious
57e34b52d0 1.7.1 2026-05-13 12:48:57 -07:00
banisterious
4ec805347d docs(changelog): release prep for 1.7.1
Move the Phase 6 cleanup entry from [Unreleased] under a dated
1.7.1 heading so the upcoming npm-version bump lands a CHANGELOG
that already reflects the new version.
2026-05-13 12:48:57 -07:00
banisterious
a0828dad59 docs(changelog): record Phase 6 entries under [Unreleased]
Six cleanup items in one [Unreleased] block: planning-doc archive,
sentence-regex dedup, Canvas polling review + decision, mobile
decision, new architecture overview doc, audit-plan status flip
to complete with intermediate releases footnoted.

The CLAUDE.md rewrite isn't listed because the file is gitignored;
each developer maintains a local copy that didn't reach the
[Unreleased] CHANGELOG via this commit.
2026-05-13 12:45:02 -07:00
banisterious
ac10a81e81 docs(planning): mark audit complete; flip Phase 6 status
audit-implementation-plan.md: the Status checklist ticks all seven
phases as shipped, with the corresponding release tags and dates.
Three intermediate patch releases (1.6.4 / 1.6.5 / 1.6.6) that
shipped during the audit but weren't on the original plan get a
footnote. The closing sentence flips to past tense: the audit is
complete; the doc stays at docs/planning/ as historical reference
until convenient to archive.

audit-phase-6-cleanup.md: Status header flips to Complete; the six
work items each tick with the commit that closed them.
2026-05-13 12:44:35 -07:00
banisterious
55b2bc91dc docs(architecture): add post-Phase-5 architecture overview
docs/architecture/overview.md describes the post-split module tree,
the data flow for counting a selection, applyExclusions's pipeline
shape, settings persistence (interface + bidirectional runtime /
persisted translation), the override mechanism (frontmatter + inline
markers), status bar + Canvas polling caveat with the event-driven
rationale, build / test / lint commands, the release pipeline, and a
"where to make changes" how-to-modify guide keyed by common
scenarios. Intended as the first stop for navigating the codebase;
the source itself is the second stop.

Closes the Phase 6 "Architecture documentation" item from the audit
plan.

(CLAUDE.md is gitignored, so per-developer copies live outside source
control. The corresponding Phase 6 "do not / prefer" patterns and
post-audit framing live in local CLAUDE.md edits, not in this
commit.)
2026-05-13 12:43:11 -07:00
banisterious
233567262b docs(planning): record Canvas polling and mobile-decision findings
Two of Phase 6's investigation items reach their conclusions:

- Canvas polling: keep the 500 ms setInterval. The polling exists
  because cross-frame selectionchange events don't bubble from
  Canvas iframe contentDocuments to the parent document. An
  event-driven alternative is technically possible (attach a
  selectionchange listener to each iframe contentDocument and
  manage that across iframe DOM mutations via MutationObserver)
  but the complexity-to-benefit ratio doesn't favor switching.
  Current polling is gated by enableLiveCount and no-ops outside
  Canvas view.

- Mobile: keep isDesktopOnly: true. Obsidian's developer docs
  explicitly state that custom status bar items are not supported
  on mobile (addStatusBarItem() is "Not available on mobile"),
  and the status bar is substantial in the plugin's UX. Partial
  mobile support (gating the status-bar features off on mobile
  while keeping the command and modal) is feasible but not
  justified at current cost/benefit; revisit if Obsidian adds
  mobile status-bar support or if there's specific user interest.

Full reasoning, including the citations and the conditions for a
future revisit, live in docs/planning/audit-phase-6-cleanup.md
Findings.
2026-05-13 12:37:59 -07:00
banisterious
0c1dfc69e1 chore(sentences): dedup the abbreviation alternation list
The abbreviation guard regex had nine duplicate alternation tokens —
'mil', 'museum', 'name', 'pro', 'travel', 'xxx', and 'tel' each
appeared twice; 'xxx' and 'tel' three times. Removed the duplicates,
preserving the first-occurrence order. 53 tokens -> 44 unique.

Behavior-preserving: regex alternations are set-based, so removing
duplicate alternatives doesn't change what the alternation matches.

Note: as Phase 4 finding #2 documented, this guard never actually
fires at runtime because the trailing period is consumed by the
sentence-split regex before the guard's `\\.` requirement can match.
The dedup is a readability fix, not a behavior change. The three
LOCKED-quirk tests in tests/counting/sentences.test.ts that confirm
"Mr.", "Dr.", "etc." produce false splits continue to pass.
2026-05-13 12:34:42 -07:00
banisterious
9dafb5625c chore(docs): archive completed pre-audit planning docs
Both planning docs cover work that pre-dates the audit and have been
marked complete since well before this audit started. Per the audit
plan's per-phase workflow, completed planning docs move to
docs/planning/archive/ after the work merges. Phase 6 implements that
move for the two longstanding ones; the active audit plan and the
phase planning docs (4, 5, 6) stay at docs/planning/ until the
audit closes.

The archive/ subdirectory is created with these as its first
inhabitants.
2026-05-13 12:31:04 -07:00
banisterious
b49894d82a docs(planning): add Phase 6 cleanup planning doc 2026-05-13 12:30:44 -07:00
banisterious
95bcdc316d 1.7.0 2026-05-13 12:13:50 -07:00
banisterious
8b48e4612b docs(changelog): release prep for 1.7.0
Move the Phase 5 split entry from [Unreleased] under a dated 1.7.0
heading so the upcoming npm-version bump lands a CHANGELOG that
already reflects the new version.
2026-05-13 12:13:36 -07:00
banisterious
76673386c0 docs: Phase 5 [Unreleased] CHANGELOG entry and planning-doc findings
CHANGELOG gets the Phase 5 split summary under [Unreleased]: the
17-module decomposition, the applyExclusions consolidation that
removes ~300 lines of duplicated pipeline code, and the no-behavior-
change guarantee that all 134 Phase 4 tests enforce.

Phase 5 planning doc's status flips to Complete and the Findings
section is populated with the pass-by-pass main.ts line counts, the
applyExclusions before/after, the audit-plan §Phase 1 questions that
got resolved during the refactor (URL/path stripping safety net,
DEFAULT_WORD_REGEX dedup), and the final dependency graph.
2026-05-13 12:05:30 -07:00
banisterious
189c5dd61b refactor(split): move Plugin class to src/main.ts; main.ts is now a re-export
Pass 6 (final pass) of Phase 5. The CustomSelectedWordCountPlugin class
moves verbatim from top-level main.ts to src/main.ts; top-level main.ts
becomes a thin re-export that bundles the default (for Obsidian) and
the named declarations the characterization test suite imports against
'../../main'.

The esbuild config still points at top-level main.ts as its entry, so
the produced main.js bundle is functionally identical. The plugin id,
manifest, on-disk settings shape, frontmatter property name, and inline
override marker syntax are unchanged.

modal.ts and tab.ts shortened their `import type
CustomSelectedWordCountPlugin from '../../main'` to `from '../main'`
now that the Plugin class is a sibling module rather than reached
through the top-level re-export. Both are still type-only imports;
no runtime cycle is introduced even though src/main.ts in turn imports
WordCountModal and WordCountSettingTab.

Phase 5 results:
- main.ts: 3260 -> 20 lines
- 17 new modules under src/, each focused on one concern
- applyExclusions consolidates the three previously-duplicated count
  pipelines into one orchestrator (~150 lines of duplication removed)
- All 134 characterization tests pass without modification at every
  commit on this branch
- esbuild bundle remains a single main.js; Obsidian load contract is
  unchanged

Phase 5 of the audit plan is complete.
2026-05-13 12:01:33 -07:00
banisterious
cf5f96aed0 refactor(split): extract WordCountSettingTab to src/settings/tab.ts
Pass 5 of Phase 5. WordCountSettingTab moves verbatim to its own
module; main.ts imports it where the Plugin class calls
`new WordCountSettingTab(this.app, this)`.

The settings tab is the largest single class in the codebase (~770
lines of UI construction code). Its only domain-level dependency is
DEFAULT_WORD_REGEX, which it uses for the placeholder text and the
"Default" hint in the custom-regex section. The class still
references its Plugin via `import type CustomSelectedWordCountPlugin`,
matching the pattern Pass 4 introduced for the modal.

AppInternals / AppWithInternals moved to src/obsidian-internals.ts
since both the settings tab (log export) and the Plugin class (settings
panel deep-link) need to cast `this.app` through this typed view. Sole
shared dependency on an Obsidian-runtime detail; not large enough to
warrant a folder of its own.

Several obsidian imports trimmed from main.ts (App, ButtonComponent,
DropdownComponent, Platform, PluginSettingTab, Setting, TextComponent,
ToggleComponent) plus DEFAULT_WORD_REGEX — all only used by the
settings tab that just moved out.

main.ts is now 854 lines, down from 3260 at the start of Phase 5
(74% reduction). The remaining content is purely the Plugin entry
class plus its top-of-file imports/re-exports.

All 134 characterization tests pass; lint and build clean.
2026-05-13 11:52:24 -07:00
banisterious
b318325ba2 refactor(split): extract WordCountModal and ConfirmModal to src/ui/
Pass 4 of Phase 5. Both modal classes move to src/ui/modal.ts; main.ts
imports WordCountModal where the Plugin class instantiates it.
ConfirmModal stays inside the modal module (it's only used by
WordCountModal's "Clear history" button) and is not re-exported.

WordCountModal's plugin parameter is typed as
`CustomSelectedWordCountPlugin` via `import type`, which TypeScript
erases at compile time and therefore introduces no runtime cycle even
while the Plugin class still lives in top-level main.ts. Pass 6 will
move the Plugin class to src/main.ts and the type import path will
shorten correspondingly.

Unused imports trimmed from main.ts (Modal, setIcon, CountResult — all
only referenced from the inline modal class that just moved out).

main.ts is now 1685 lines, down from 1991 at the start of this pass.
All 134 characterization tests pass; lint and build clean.
2026-05-13 11:22:30 -07:00
banisterious
ac3b449883 refactor(split): extract applyExclusions orchestrator and count functions
Pass 3 of Phase 5. The behavioral consolidation: the three count
functions previously each carried an independent copy of the same
exclusion pipeline (~150 lines of duplicated orchestration). Phase 5's
brief calls for collapsing those three copies into one shared function;
this commit does that.

New modules:
- src/counting/pipeline.ts    : applyExclusions(text, settings, plugin, disabled)
                                — the single orchestrator that wraps
                                  processTextWithOverrides around the
                                  seven exclusion processors, gated by
                                  settings + per-call disable list.
- src/counting/characters.ts  : countSelectedCharacters
                                — calls applyExclusions, then runs the
                                  mode switch (all / no-spaces / letters-only).
- src/counting/sentences.ts   : countSelectedSentences
                                — calls applyExclusions, then runs the
                                  sentence-specific code/heading/URL/path
                                  stripping safety net and the regex-based
                                  boundary detection. Phase 4's three
                                  LOCKED-quirk findings are preserved
                                  unchanged.
- src/counting/words.ts       : countSelectedWords
                                — calls applyExclusions, then runs the
                                  word-specific path classification and
                                  extension filter logic. Also picks up
                                  the Phase 1 dedup fix: both regex
                                  branches now construct from
                                  DEFAULT_WORD_REGEX instead of
                                  duplicating the literal inline.
- src/counting/index.ts       : countSelectedText aggregator
                                — calls the three counts in series and
                                  returns { words, characters, sentences }.

The count functions' parameter types changed from
CustomSelectedWordCountPlugin to DebugLoggable, matching the same
boundary that processing helpers crossed in Pass 2.

main.ts is now 1991 lines (down from 3260 at the start of Phase 5,
~39% reduction). The remaining content is the Plugin class, the modal,
and the settings tab — all targeted by later passes.

All 134 characterization tests pass without modification; lint and
build clean. The three LOCKED-quirk tests confirm the consolidation
preserved every pre-existing behavioral oddity.
2026-05-13 10:32:28 -07:00
banisterious
9bec17d3aa refactor(split): extract seven processing helpers to src/processing/
Pass 2 of Phase 5. Moves the exclusion-pipeline building blocks out of
main.ts into per-concern modules. Each module takes its plugin parameter
as DebugLoggable instead of CustomSelectedWordCountPlugin, so processing
code no longer references main.ts.

Modules created:
- src/processing/frontmatter.ts  : stripFrontmatter, getDisabledExclusionsFromFrontmatter
- src/processing/overrides.ts    : processTextWithOverrides
- src/processing/code.ts         : processCodeBlocks, processInlineCode
- src/processing/comments.ts     : processObsidianComments, processHtmlComments
- src/processing/links.ts        : processLinks
- src/processing/headings.ts     : processHeadings, processSelectiveHeadingSections
- src/processing/words-and-phrases.ts : processWordsAndPhrases

main.ts imports the helpers it still needs internally (count functions
call all seven) and re-exports each so tests/ continues to resolve them
via './main'. The function bodies are byte-for-byte identical to the
pre-split versions; the parameter-type change is type-level only.

main.ts is now 458 lines shorter. All 134 characterization tests pass;
lint and build clean.
2026-05-13 10:21:16 -07:00
banisterious
7e507a9a44 refactor(split): extract types, settings defaults, and debug utils to src/
Pass 1 of Phase 5. Pulls the lowest-coupling declarations out of main.ts
into the new src/ tree, leaving the file shorter and the new modules
with no internal dependencies.

Moved:
- WordCountPluginSettings, DEFAULT_SETTINGS, DEFAULT_EXCLUSION_LIST,
  DEFAULT_WORD_REGEX -> src/settings/types.ts
- CountResult, WordCountHistoryEntry -> src/types.ts
- debugLog, errorMessage -> src/utils/debug.ts

debugLog's parameter is retyped from CustomSelectedWordCountPlugin to a
new DebugLoggable structural interface ({ settings: { enableDebugLogging:
boolean } }). The plugin class satisfies it naturally; processing and
counting modules can now type their optional `plugin?` parameter as
DebugLoggable in later passes and avoid the circular import that would
otherwise result.

main.ts retains:
- The import of these symbols where it consumes them internally
  (DEFAULT_SETTINGS in loadSettings, DEFAULT_WORD_REGEX in
  countSelectedWords, types throughout).
- Named re-exports of the moved settings types so tests/ and any
  external consumer keep working with imports against './main'.

All 134 characterization tests pass; lint and build clean.
2026-05-13 10:00:52 -07:00
banisterious
23eac2c426 docs(planning): add Phase 5 split planning doc
Captures the target module layout, extraction order, the
applyExclusions extraction strategy, and the no-behavior-change
constraint that Phase 4's 134-test characterization suite enforces.
2026-05-13 09:42:51 -07:00
banisterious
8621106f80 1.6.7 2026-05-12 18:49:10 -07:00
banisterious
5b5e3c41f1 docs(changelog): release prep for 1.6.7
Move the Phase 4 vitest entries from [Unreleased] under a dated 1.6.7
heading so the upcoming npm-version bump lands a CHANGELOG that
already reflects the new version.
2026-05-12 18:46:18 -07:00
banisterious
27be06cd93 docs(changelog): record Phase 4 internal entries
Vitest test infrastructure with the 134-test characterization suite,
the obsidianmd 0.3.0 bump and the lint-config adaptation it required,
the @types/node bump to align with the Node 22 target, and the dead
.eslintignore cleanup. All Phase 4 work is internal: no user-visible
behavior changes.
2026-05-12 18:44:45 -07:00
banisterious
1e336b7bb1 ci(release): run vitest between Lint and Build
Phase 4 added a vitest characterization suite. Hook it into the release
workflow so a failing test blocks the draft release alongside lint and
build. Ordering matches the per-phase quality gates: Lint, Test, Build.
2026-05-12 18:44:35 -07:00
banisterious
974bd2b3aa test: add vitest infrastructure and Phase 4 characterization suite
134 tests across six files lock the current behavior of the three count
entry points and their support code, giving Phase 5's planned single-file
split a load-bearing safety net.

Files:
- vitest.config.ts: node environment; resolve.extensions prefers .ts so
  imports resolve to main.ts source (not the main.js build artifact);
  alias redirects 'obsidian' to the test mock.
- tests/mocks/obsidian.ts: minimal no-op stubs (App, Plugin, Modal,
  Setting, etc.) so main.ts's imports resolve in tests.
- tests/fixtures/settings.ts: makeSettings(overrides) helper for spreading
  DEFAULT_SETTINGS with per-test overrides.
- tests/counting/words.test.ts (37 tests): baseline, code/comment/link/
  heading/words-and-phrases/extension/path exclusion modes, advanced
  regex, combination cases.
- tests/counting/characters.test.ts (18 tests): all/no-spaces/letters-only
  modes plus the exclusion matrix.
- tests/counting/sentences.test.ts (21 tests): baseline, abbreviation /
  decimal / URL / file-path / file-extension / ellipsis guards, and
  settings-driven exclusions.
- tests/counting/text.test.ts (5 tests): countSelectedText aggregator.
- tests/exclusions/overrides.test.ts (18 tests): the four override
  mechanisms (frontmatter array, string, "all" expansion, inline
  comment markers in both HTML and Obsidian styles) plus the
  disabledExclusions parameter.
- tests/exclusions/processors.test.ts (35 tests): direct unit tests for
  the individual process* helpers.

Three pre-existing quirks surfaced during test-writing and are locked
in by 'LOCKED quirk:' tests so a later phase can decide whether to fix
each:
- Path-exclusion buffer is greedy (a detected path swallows trailing
  words to end of input).
- Sentence-detection abbreviation guard never fires (the period is
  consumed by the split regex before the guard runs).
- Sentence-detection file-extension guard discards entire sentences
  ending in name.ext rather than just the in-filename period.

Details in docs/planning/audit-phase-4-tests.md § Findings.
2026-05-12 18:44:28 -07:00
banisterious
81f1aa6a51 refactor(main): export count and processor functions for test access
Phase 4's characterization test suite needs to import the counting
entry points and the processing helpers. Add 'export' to the relevant
top-level declarations:

- stripFrontmatter, getDisabledExclusionsFromFrontmatter
- processTextWithOverrides
- processCodeBlocks, processInlineCode
- processObsidianComments, processHtmlComments
- processLinks
- processHeadings, processSelectiveHeadingSections
- processWordsAndPhrases
- countSelectedCharacters, countSelectedSentences
- countSelectedText, countSelectedWords
- WordCountPluginSettings interface
- CountResult interface
- DEFAULT_EXCLUSION_LIST, DEFAULT_SETTINGS

No runtime behavior change; the export keyword is a no-op at the
bundler level. Phase 5's planned module split was going to make
these visible at the file boundary anyway.
2026-05-12 18:43:53 -07:00
banisterious
8724fdafbc chore(lint): adapt to eslint-plugin-obsidianmd 0.3.0; drop dead .eslintignore
The 0.3.0 recommended config has one entry that applies its rules to
every file matched by 'eslint .' without a files: restriction. Some
of those rules (e.g. no-plugin-as-component) require typescript-eslint
parser services and crash on non-TS inputs like package.json and
eslint.config.mjs. Wrap each unrestricted entry to scope it to
**/*.ts and **/*.tsx.

Also extend the ignores array with common non-source file types
(.json, .md, .css, .yml, .lock, .nvmrc, etc.) so an 'eslint .'
invocation can't inadvertently feed them through any future
unrestricted rule.

Removed .eslintignore — ESLint 9 no longer reads it (its presence
triggers ESLintIgnoreWarning on every run), and both entries
(node_modules/, main.js) were already in eslint.config.mjs's
ignores array.
2026-05-12 18:43:44 -07:00
banisterious
4a35373b46 chore(deps): bump @types/node to ^22, add vitest, bump obsidianmd to 0.3.0
- @types/node was pinned to ^16.11.6 while the project's actual Node
  target (.nvmrc) is 22.20.0. The mismatch blocked installing vitest,
  whose peer dependency requires @types/node@^18 || ^20 || >=22. Types
  are dev-only and erased at runtime.
- Added vitest@^3 to host the Phase 4 characterization test suite.
- Bumped eslint-plugin-obsidianmd from ^0.2.9 to ^0.3.0 to match the
  version the Obsidian Community website's automated rescan runs
  server-side, so local lint stays aligned with what the catalog
  scanner sees on release tags.
2026-05-12 18:42:32 -07:00
banisterious
b074065bfc 1.6.6 2026-05-12 15:32:42 -07:00
banisterious
beab33c967 docs: extend 1.6.6 CHANGELOG with the unsafe-* lint-fix entry
The fix(types) commit landed after the initial CHANGELOG entry was
cut, so the 1.6.6 section was missing an honest accounting of the
type-narrowing work that went in to clear the new CI lint gate. Add
one Internal bullet describing the errorMessage helper, the catch-
block migration, the frontmatter input narrowing, and the loadData
return cast.
2026-05-12 15:32:33 -07:00
banisterious
86896f0929 fix(types): narrow caught errors and frontmatter input for lint pass
The 1.6.6-rc1 CI run failed at the lint step on ten
`@typescript-eslint/no-unsafe-*` findings - the family I had
previously dismissed as "local strictness, not catalog barriers."
That was true for the community-site rescan, but CI runs
`npm run lint` strictly and these errors block the workflow.

Seven of the ten findings are `error.message` / `e.message` reads in
`catch` blocks where TypeScript's useUnknownInCatchVariables types
the binding as `unknown`. Add a module-level `errorMessage(error:
unknown): string` helper that narrows via `instanceof Error`, then
route each catch-block message access through it. Falls back to
`String(error)` for thrown primitives or non-Error objects.

The remaining three:

- `getDisabledExclusionsFromFrontmatter` reads `cache.frontmatter
  ['cswc-disable']` (typed `any` from the Obsidian metadata cache),
  assigning it to a local. Annotate the local as `unknown` so the
  subsequent `Array.isArray` / `typeof` checks are real narrowings.
  The `.filter(item => ...)` for the array branch uses a `(item):
  item is string =>` type predicate so the filtered result is
  properly typed `string[]` instead of `any[]`.

- `loadSettings` was assigning the result of `Object.assign({},
  DEFAULT_SETTINGS, await this.loadData())` to the typed
  `this.settings`. `Plugin.loadData()` returns `Promise<any>`, so
  the merged result was `any`. Cast `loadData()`'s return through
  `as Partial<WordCountPluginSettings> | null` to make the merge
  well-typed.

Lint now exits clean (zero errors) which clears the CI gate.
2026-05-12 15:28:15 -07:00
banisterious
0c3f9547c1 docs: CHANGELOG entry for 1.6.6
Capture the two threads landing in this release: the responsive
@media block now uses `:has()` for selector scoping and drops the
four `!important` declarations (CSS rescan win), and the new
GitHub Actions release workflow produces verifiable artifact
attestations for every release asset.
2026-05-12 15:21:33 -07:00
banisterious
d5823a7a26 refactor(ui): scope responsive @media rule to our modal, drop !important
The `@media (max-width: 600px)` block targeted the bare `.modal`
class - every Obsidian modal, not just the plugin's own - and used
`!important` four times to override Obsidian's default modal width
on narrow viewports. Change the selector to
`.modal:has(.word-count-modal)`, which scopes the rule to this
plugin's modal and supplies enough specificity to drop all four
`!important` declarations.

Trades the four `!important` rescan warnings for one `:has()`
warning (net -3). The replacement matches the `:has()` pattern
already used at `styles.css:87` and elsewhere; both are
catalog-acceptance Warnings, not Errors, so this is a code-quality
improvement rather than a release blocker.

The non-`.modal` selectors in the same rule list
(`.word-count-label`, `.word-count-count`, etc.) are already
plugin-specific; they did not need `!important` for specificity in
the first place and now drop it along with the `.modal` rewrite.
2026-05-12 15:21:30 -07:00
banisterious
8dff24e71e feat(ci): release workflow with build-provenance attestations
Adopt the same release pipeline that landed in Draft Bench, adapted
to this plugin's existing scripts and assets.

What lands together:

- `.github/workflows/release.yml`. Tag-push trigger matching plain
  SemVer (`1.6.6`) and pre-release SemVer (`1.6.6-rc1`). Runs
  `npm ci`, `npm run lint` (the obsidianmd ruleset), and
  `npm run build`. Calls `actions/attest-build-provenance@v2` on
  `main.js`, `manifest.json`, and `styles.css` so each release asset
  carries a verifiable GitHub artifact attestation. Creates a draft
  release with the three assets attached; pre-release tags get
  `--prerelease`. The draft step preserves the editorial gate -
  release-description markdown is pasted into the draft by hand
  before publishing, keeping writing-style review on every release.

- `.nvmrc` pinning Node 22.20.0 (matches local dev). Both CI and
  local environments resolve the same Node via
  `actions/setup-node@v4`'s `node-version-file: '.nvmrc'`.

- `docs/developer/release.md` rewritten to document the CI flow:
  required artifacts, the trial-run procedure with `-rc1` tags, the
  attestation-verification command, and the recovery path for a
  failed CI run.

Customizations from the reference workflow:

- Dropped the `lint:css` step. The plugin has CSS sources but no
  stylelint config; adding one is real follow-up work and the
  community-site rescan covers CSS lint server-side until that
  lands.
- Dropped the `test` step. No test infrastructure yet; that's Phase
  4 of the audit plan. The step is straightforward to add later.

Release assets are the standard Obsidian three: `main.js` (built
fresh by CI), `manifest.json` (committed), `styles.css` (committed).
2026-05-12 15:21:28 -07:00
banisterious
f65aac45a3 1.6.5 2026-05-12 14:53:39 -07:00
banisterious
3bf78e99b7 docs: CHANGELOG entry for 1.6.5 CSS polish
Documents the duplicate-selector merges and the two redundant
`!important` removals from the previous commit. The four `!important`
uses in the `@media (max-width: 600px)` block are intentionally left
in place since the alternative (`:has()` or JS-driven classes) would
trade one rescan warning for another. The remaining six `:has()`
selectors are similarly load-bearing and not addressed here.
2026-05-12 14:49:43 -07:00
banisterious
16d41d26be refactor(ui): merge duplicate CSS selectors and drop two !important uses
Three pairs of duplicate selectors were doing partial styling against
the same element. Merge each pair into one rule with the cascaded
final values:

- `.word-count-modal .modal-header` at lines 104 + 174. The earlier
  "Original ... (restored)" block was a subset of the "Enhanced"
  block that came after; the cascade was already using the second
  block's values. Drop the duplicate and keep the merged result.
- `.word-count-modal .modal-title` at lines 109 + 191. The two
  blocks split between flex layout (109) and final font sizing (191).
  Combine into a single rule with `display: flex`, `align-items`,
  `gap` from the first and `font-size: 18px` from the second.
- `.word-count-settings-group` at lines 667 + 852 (both inside the
  `.word-count-settings` parent). Two separate `&:has(...)` nested
  rules collapse into one parent block with both nested
  `&:has(...)` selectors as siblings.

Drop `!important` from two utility rules where it was redundant:

- `.word-count-hidden { display: none !important; }` -> no
  `!important`. The class is toggled via `toggleClass` on settings
  sub-section containers; nothing sets a competing inline `display`
  on those elements, so the class-based rule wins on cascade.
- `body.word-count-hide-core .status-bar-item.plugin-word-count {
  display: none !important; }` -> no `!important`. The selector
  already has high specificity (body class plus two element-class
  selectors), and Obsidian's core word-count item carries no
  competing rule.

The four `!important` uses inside the `@media (max-width: 600px)`
block stay. They override Obsidian's default `.modal` width, which
needs either a higher-specificity selector or `:has()` to replace.
The latter would just trade one rescan warning for another, so
deferring until a cleaner replacement is available.
2026-05-12 14:49:28 -07:00
banisterious
4d67f4df75 1.6.4 2026-05-12 14:22:47 -07:00
banisterious
f3bcdb0917 docs: CHANGELOG entry for 1.6.4 rescan fixes
Captures the three buckets addressed by this patch: the CSS brace
balance error (the only Error in the community-site rescan), the
timer-function migration from `activeWindow.*` back to `window.*`
(eight sites, mirrors the rescan's "Timer functions should use
'window'" guidance), and the nine no-explicit-any cleanups
(logging helpers, `getHeadingAtCursor`, and the
`(this.app as any).setting / appVersion` escape hatches).

Artifact attestations for `main.js` / `styles.css` remain
Recommendations only and are deferred to Phase 6 of the audit plan,
which adds an actual release workflow with
`actions/attest-build-provenance`.
2026-05-12 14:16:20 -07:00
banisterious
6f1df18a92 refactor(types): type the nine no-explicit-any findings
The community-site rescan flagged nine `any` uses left after the
1.6.3 Phase 3e settings-callback typing pass. Each kind addressed:

- The two logging helpers (`debugLog` at module scope, `log` as a
  Plugin private method) take a rest-args `...args: any[]`. Both
  forward args to `console.log` (which accepts `unknown[]`), so
  `unknown[]` is the right swap. No call-site changes needed.

- `getHeadingAtCursor(editor: any, cursor: any)` was already called
  from the `editor-menu` workspace event, which provides the
  arguments as `Editor` and `EditorPosition` (both from `obsidian`).
  Importing those types and typing the parameters removes both
  `any`s. The function body only uses `editor.getLine(...)` and
  `cursor.line`, both available on the proper types.

- The five `(this.app as any).setting.open()` /
  `openTabById(...)` / `appVersion` accesses are reaching for
  internal App members that aren't in Obsidian's published types.
  Declare a local `AppInternals` interface that names the members
  we use, plus `type AppWithInternals = App & AppInternals`, and
  cast through that. Still a cast, but no longer `any` - the rule
  fires on `any` specifically, not on intersection-typed assertions.
2026-05-12 14:09:57 -07:00
banisterious
d4a31da315 fix: use window.* for timer functions instead of activeWindow.*
The 1.6.3 Phase 3b migration switched bare `setTimeout` /
`clearTimeout` / `setInterval` / `clearInterval` calls to
`activeWindow.*` per `eslint-plugin-obsidianmd@0.2.9`'s
`prefer-active-window-timers` rule. The community-site rescan
disagreed: timer functions should use `window.*`, not `activeWindow.*`.
Eight sites in main.ts (debounce timer, canvas polling, Reading-view
Select-All, two copy-button feedback flashes, the onunload
debounce-clear) now use `window.setTimeout` / `clearTimeout` /
`setInterval` / `clearInterval`.

`window.*` is more predictable than `activeWindow.*` for timer ids:
`activeWindow` is a focus-following getter, so a timer id obtained
from `activeWindow.setTimeout()` could end up tied to a different
window than the corresponding `activeWindow.clearTimeout()` if focus
shifted in between. `window.*` always refers to the same window
object that holds the plugin's runtime context, which is the right
semantic for the debounce / polling / animation timers here.

`prefer-active-window-timers` does not flag the explicit `window.*`
form, so the local lint config doesn't need adjustment.
2026-05-12 14:06:29 -07:00
banisterious
db6cce19e5 fix: balance braces in .word-count-settings nested block
styles.css had an extra `}` on line 679 that prematurely closed the
`.word-count-container-indented` rule, leaving the second indentation
rule and everything after (down to line 882) accidentally outside
the `.word-count-settings` container. The `}` at line 883 was then
unmatched, which the community-site CSS lint flagged as an Error.

The fix removes the stray closer and lets the rule nest naturally:
`.word-count-container-indented` opens once, contains its own
padding rule, then nests a deeper-indentation child rule before the
parent closes. Total brace count is now balanced (141 open / 141
close).
2026-05-12 14:00:10 -07:00
banisterious
202a8243f8 1.6.3 2026-05-12 13:26:18 -07:00
banisterious
bc2f988437 docs: cut CHANGELOG entry for 1.6.3 release
Move the accumulated [Unreleased] entries (Phase 0 release-blocker
work, Phase 2 bug fixes plus cleanup, Phase 3 API conformance sweep,
the popout listener fix, and the supporting documentation changes)
under a dated `[1.6.3] - 2026-05-12` heading. Leave [Unreleased]
empty for the next cycle.
2026-05-12 13:25:51 -07:00
banisterious
a842fe9556 docs: CHANGELOG entry for popout live-update fix
Adds a Fixed entry describing the listener-binding bug surfaced by
the popout smoke test, the per-window registration via
workspace.on('window-open'), the activeWindow.getSelection() change,
and the known limitation that the plugin's status-bar item still
lives in the main window only.
2026-05-12 13:22:11 -07:00
banisterious
75e430712e fix: register live-update listeners per window for popout support
The Phase 3b migration of `document` -> `activeDocument` was
incomplete for cross-window event listeners. `activeDocument` is a
getter that resolves to the currently-focused document at the moment
its line evaluates - at `setupStatusBar()` time, that's almost always
the main window. The listeners ended up bound to the main document
only, so live status bar updates never fired from selections inside a
popped-out leaf.

Three changes:

1. Move listener registration out of `setupStatusBar()` (where it was
   gated on `enableLiveCount` and ran in whichever document was
   active at the time) into `onload()`. Use `this.registerDomEvent`
   on the main window's document at load, and subscribe to
   `workspace.on('window-open', ...)` so each popout that opens later
   gets the same two listeners on its own document. `handleSelectionChange`
   and `handleKeyDown` already bail when `enableLiveCount` is off, so
   permanent registration is safe.

2. Replace every `window.getSelection()` with `activeWindow.getSelection()`
   (six sites across the selection / Ctrl+A / Canvas paths). The
   handler now reads selection from whichever window currently has
   focus, so selections inside a popout produce the right text.

3. Drop the manual `removeEventListener` calls from `setupStatusBar`
   (settings-change re-entry) and `onunload`. `registerDomEvent` /
   `registerEvent` clean up on plugin unload automatically; the
   handler's `enableLiveCount` guard handles the settings-change case
   without needing to detach.

Canvas polling stays in `setupStatusBar` since it's tied to the
status bar item's lifecycle, not to whichever document is focused.

Known limitation surfaced by this change: the plugin's status-bar
item still lives in the main window only (Obsidian's
`addStatusBarItem` adds to the main status bar). Selections inside a
popout now correctly update that main-window item, but the popout's
own status bar does not carry the plugin's `Selected: N` text.
2026-05-12 13:21:51 -07:00
banisterious
b4fa29308f ui: restore proper-noun capitalization for Windows / Unix path settings
Expand the obsidianmd/ui/sentence-case brand list to include Unix,
Windows, macOS, Linux, iOS, and Android so the setting names read
naturally ("Exclude Windows paths", "Exclude Unix paths", "(e.g.,
C:\)", "Exclude Unix-style paths..."). Markdown and Obsidian were
already in the list.

The catch: the same lint rule then wants to capitalize the brand
inside the lowercase identifier strings used by cswc-disable
overrides (`exclude-windows-paths` -> `exclude-Windows-paths`),
which would break the runtime lookup if a user copy-pasted the
capitalized identifier into their note's frontmatter. Each path
description carrying that identifier gets a targeted
`eslint-disable-next-line` so the brand stays correctly capitalized
in prose but the identifier stays lowercase as the parser expects.
2026-05-12 13:19:17 -07:00