diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24242c93..86936a18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,43 @@
# Changelog
+## v0.12.0 (2026-04-11)
+
+### Changed
+- Overlay: Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
+- Overlay: Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
+- Overlay: Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
+- Overlay: Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery.
+- Stats: Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
+- Stats: Trends add a 365-day range next to the existing 7d/30d/90d/all options.
+- Stats: Library detail view gets a delete-episode action that removes the video and all its sessions.
+- Stats: Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
+- Stats: Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
+- Stats: Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
+- Stats: Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
+- Stats: Session timeline no longer plots seek-forward/seek-backward markers — they were too noisy on sessions with lots of rewinds.
+- Stats: Replaced the "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector.
+
+### Fixed
+- Overlay: Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
+- Overlay: Addressed the latest CodeRabbit follow-ups on PR #49, including generation-scoped Lua session binding names, stricter session command validation, session-help shortcut visibility, the numeric-selection key guard, stats-overlay startup classification, and safer session-binding persistence.
+- Overlay: Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss.
+- Overlay: Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus.
+- Overlay: Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably.
+- Overlay: Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click.
+- Overlay: Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open.
+- Overlay: Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line.
+- Overlay: Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears.
+- Overlay: Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles.
+- Overlay: Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area.
+- Overlay: Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons.
+- Overlay: Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
+- Stats: Fixed immersion-tracker timestamp handling under Bun/libsql so library rows, session timelines, and lifetime summaries keep real wall-clock millisecond values instead of truncating to invalid negative timestamps.
+- Mpv Plugin: Fixed the mpv Lua plugin so hover and environment modules no longer use the `goto continue` pattern that can fail to parse on some user Lua runtimes.
+
+### Internal
+- Release: Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
+- Release: Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
+
## v0.11.2 (2026-04-07)
### Changed
diff --git a/backlog/tasks/task-290 - Cut-stable-release-v0.12.0-on-main.md b/backlog/tasks/task-290 - Cut-stable-release-v0.12.0-on-main.md
new file mode 100644
index 00000000..0d418857
--- /dev/null
+++ b/backlog/tasks/task-290 - Cut-stable-release-v0.12.0-on-main.md
@@ -0,0 +1,56 @@
+---
+id: TASK-290
+title: Cut stable release v0.12.0 on main
+status: Done
+assignee:
+ - codex
+created_date: '2026-04-12 04:47'
+updated_date: '2026-04-12 04:51'
+labels: []
+dependencies: []
+documentation:
+ - docs/RELEASING.md
+priority: high
+---
+
+## Description
+
+
+Prepare the main branch for the stable SubMiner v0.12.0 release by applying the release-version updates, formatting changes required by the branch state, and rerunning the full release verification gate.
+
+
+## Acceptance Criteria
+
+- [x] #1 Main branch version and stable release metadata are updated for v0.12.0.
+- [x] #2 Required formatting changes for the release candidate tree are applied and verified.
+- [x] #3 The documented release verification gate passes locally and any remaining push or tag prerequisites are documented.
+
+
+## Implementation Plan
+
+
+1. Audit main-branch release state: package version, release artifacts, current CI status, and current formatting debt.
+2. Apply required formatting fixes to the files reported by `bun run format:check:src` and verify the formatting lane passes.
+3. Update the package version to 0.12.0 and generate stable release metadata (`CHANGELOG.md`, `release/release-notes.md`, `docs-site/changelog.md`) using the documented release workflow.
+4. Run the full local release gate on main (`changelog:lint`, `changelog:check --version 0.12.0`, `verify:config-example`, `typecheck`, `test:fast`, `test:env`, `build`, `docs:test`, `docs:build`, plus dist smoke) and document any remaining tag/push prerequisites.
+
+
+## Implementation Notes
+
+
+Applied Prettier to all 39 files reported by `bun run format:check:src` on main and verified the formatting lane now passes.
+
+Reapplied the stable changelog build entrypoint fix on main: added `writeStableReleaseArtifacts`, covered it with a focused regression test, and updated `package.json` so `changelog:build` forwards `--version` and `--date` through a single `build-release` command.
+
+Verified the formatted mainline release tree with `bun run changelog:lint`, `bun run changelog:check --version 0.12.0`, `bun run verify:config-example`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run docs:test`, `bun run docs:build`, and `bun run test:smoke:dist`; all passed.
+
+Remote main CI also completed successfully for `Windows update (#49)` after the local release-prep pass. Remaining operational steps are commit/tag/push only.
+
+
+## Final Summary
+
+
+Prepared `main` for the stable `v0.12.0` cut. Formatted the previously failing source files so `bun run format:check:src` is now clean, bumped `package.json` from `0.12.0-beta.3` to `0.12.0`, and generated the stable release artifacts with the explicit local cut date `2026-04-11`, which consumed the pending changelog fragments into `CHANGELOG.md`, `docs-site/changelog.md`, and `release/release-notes.md`.
+
+Also reintroduced the release-script fix on main: the old `changelog:build` package script still used `build && docs`, which can drop `--version/--date` on the first step. Added a focused regression test in `scripts/build-changelog.test.ts`, implemented `writeStableReleaseArtifacts` in `scripts/build-changelog.ts`, and switched `package.json` to `build-release` so release flags propagate correctly. Verification on the final tree passed for formatting, changelog lint/check, config example verification, typecheck, fast tests, env tests, build, docs tests/build, dist smoke, and remote main CI. The branch is release-ready pending commit, tag, and push.
+
diff --git a/changes/2026-04-09-prerelease-workflow.md b/changes/2026-04-09-prerelease-workflow.md
deleted file mode 100644
index 37941416..00000000
--- a/changes/2026-04-09-prerelease-workflow.md
+++ /dev/null
@@ -1,5 +0,0 @@
-type: internal
-area: release
-
-- Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
-- Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
diff --git a/changes/fix-overlay-subtitle-drop-routing.md b/changes/fix-overlay-subtitle-drop-routing.md
deleted file mode 100644
index 0711dee9..00000000
--- a/changes/fix-overlay-subtitle-drop-routing.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: overlay
-
-- Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
diff --git a/changes/fix-pr-49-coderabbit-review-follow-ups.md b/changes/fix-pr-49-coderabbit-review-follow-ups.md
deleted file mode 100644
index 320cb661..00000000
--- a/changes/fix-pr-49-coderabbit-review-follow-ups.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: overlay
-
-- Addressed the latest CodeRabbit follow-ups on PR #49, including generation-scoped Lua session binding names, stricter session command validation, session-help shortcut visibility, the numeric-selection key guard, stats-overlay startup classification, and safer session-binding persistence.
diff --git a/changes/fix-windows-coderabbit-review-follow-ups.md b/changes/fix-windows-coderabbit-review-follow-ups.md
deleted file mode 100644
index 46691491..00000000
--- a/changes/fix-windows-coderabbit-review-follow-ups.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: overlay
-
-- Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss.
diff --git a/changes/fix-windows-overlay-z-order.md b/changes/fix-windows-overlay-z-order.md
deleted file mode 100644
index d5b1f57e..00000000
--- a/changes/fix-windows-overlay-z-order.md
+++ /dev/null
@@ -1,11 +0,0 @@
-type: fixed
-area: overlay
-
-- Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus.
-- Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably.
-- Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click.
-- Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open.
-- Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line.
-- Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears.
-- Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles.
-- Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area.
diff --git a/changes/fix-windows-secondary-hover-titlebar.md b/changes/fix-windows-secondary-hover-titlebar.md
deleted file mode 100644
index 94890fba..00000000
--- a/changes/fix-windows-secondary-hover-titlebar.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: overlay
-
-- Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons.
diff --git a/changes/fix-yomitan-nested-popup-focus.md b/changes/fix-yomitan-nested-popup-focus.md
deleted file mode 100644
index 1a343cae..00000000
--- a/changes/fix-yomitan-nested-popup-focus.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: overlay
-
-- Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
diff --git a/changes/honor-configured-controller-shortcuts-and-modal-routing.md b/changes/honor-configured-controller-shortcuts-and-modal-routing.md
deleted file mode 100644
index 4e5d98d7..00000000
--- a/changes/honor-configured-controller-shortcuts-and-modal-routing.md
+++ /dev/null
@@ -1,7 +0,0 @@
-type: changed
-area: overlay
-
-- Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
-- Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
-- Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
-- Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery.
diff --git a/changes/immersion-tracker-timestamp-compat.md b/changes/immersion-tracker-timestamp-compat.md
deleted file mode 100644
index c1cffa9f..00000000
--- a/changes/immersion-tracker-timestamp-compat.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: stats
-
-- Fixed immersion-tracker timestamp handling under Bun/libsql so library rows, session timelines, and lifetime summaries keep real wall-clock millisecond values instead of truncating to invalid negative timestamps.
diff --git a/changes/lua-plugin-parser-compat.md b/changes/lua-plugin-parser-compat.md
deleted file mode 100644
index 70dc19ea..00000000
--- a/changes/lua-plugin-parser-compat.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: mpv-plugin
-
-- Fixed the mpv Lua plugin so hover and environment modules no longer use the `goto continue` pattern that can fail to parse on some user Lua runtimes.
diff --git a/changes/stats-dashboard-feedback-pass.md b/changes/stats-dashboard-feedback-pass.md
deleted file mode 100644
index 83bbbbcf..00000000
--- a/changes/stats-dashboard-feedback-pass.md
+++ /dev/null
@@ -1,11 +0,0 @@
-type: changed
-area: stats
-
-- Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
-- Trends add a 365-day range next to the existing 7d/30d/90d/all options.
-- Library detail view gets a delete-episode action that removes the video and all its sessions.
-- Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
-- Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
-- Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
-- Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
-- Session timeline no longer plots seek-forward/seek-backward markers — they were too noisy on sessions with lots of rewinds.
diff --git a/changes/stats-library-summary.md b/changes/stats-library-summary.md
deleted file mode 100644
index b20beabc..00000000
--- a/changes/stats-library-summary.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: changed
-area: stats
-
-- Replaced the "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector.
diff --git a/docs-site/changelog.md b/docs-site/changelog.md
index baf85e19..a95b4718 100644
--- a/docs-site/changelog.md
+++ b/docs-site/changelog.md
@@ -1,6 +1,49 @@
# Changelog
-## v0.11.2 (2026-04-07)
+## v0.12.0 (2026-04-11)
+
+**Changed**
+- Overlay: Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
+- Overlay: Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
+- Overlay: Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
+- Overlay: Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery.
+- Stats: Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
+- Stats: Trends add a 365-day range next to the existing 7d/30d/90d/all options.
+- Stats: Library detail view gets a delete-episode action that removes the video and all its sessions.
+- Stats: Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
+- Stats: Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
+- Stats: Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
+- Stats: Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
+- Stats: Session timeline no longer plots seek-forward/seek-backward markers — they were too noisy on sessions with lots of rewinds.
+- Stats: Replaced the "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector.
+
+**Fixed**
+- Overlay: Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
+- Overlay: Addressed the latest CodeRabbit follow-ups on PR #49, including generation-scoped Lua session binding names, stricter session command validation, session-help shortcut visibility, the numeric-selection key guard, stats-overlay startup classification, and safer session-binding persistence.
+- Overlay: Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss.
+- Overlay: Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus.
+- Overlay: Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably.
+- Overlay: Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click.
+- Overlay: Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open.
+- Overlay: Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line.
+- Overlay: Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears.
+- Overlay: Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles.
+- Overlay: Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area.
+- Overlay: Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons.
+- Overlay: Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
+- Stats: Fixed immersion-tracker timestamp handling under Bun/libsql so library rows, session timelines, and lifetime summaries keep real wall-clock millisecond values instead of truncating to invalid negative timestamps.
+- Mpv Plugin: Fixed the mpv Lua plugin so hover and environment modules no longer use the `goto continue` pattern that can fail to parse on some user Lua runtimes.
+
+**Internal**
+- Release: Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
+- Release: Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
+
+## Previous Versions
+
+
+v0.11.x
+
+v0.11.2 (2026-04-07)
**Changed**
- Launcher: Replaced the launcher-only fullscreen toggle with `mpv.launchMode` so SubMiner-managed mpv playback can start in normal, maximized, or fullscreen mode.
@@ -10,13 +53,13 @@
- Launcher: Local playback now promotes a single unlabeled external subtitle sidecar to the primary slot instead of leaving mpv's embedded English auto-selection in place.
- Release: Fixed Linux AppImage startup packaging so Chromium child relaunches can resolve the bundled `libffmpeg.so` instead of crash-looping on startup.
-## v0.11.1 (2026-04-04)
+v0.11.1 (2026-04-04)
**Fixed**
- Release: Linux packaged builds now expose the canonical `SubMiner` app identity to Electron's startup metadata so native Wayland compositors stop reporting the window class/app-id as lowercase `subminer`.
- Linux: Linux now restores the runtime options, Jimaku, and Subsync shortcuts after the Electron 39 regression by routing those actions through the overlay's mpv/IPC shortcut path.
-## v0.11.0 (2026-04-03)
+v0.11.0 (2026-04-03)
**Added**
- Overlay: Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
@@ -69,7 +112,7 @@
- Release: Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.
- Release: Updated Electron to 39.8.6 and pinned patched transitive build dependencies to clear the reported high-severity audit findings.
-## Previous Versions
+
v0.10.x
diff --git a/package.json b/package.json
index 294c2d5d..9239dbb7 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
- "version": "0.12.0-beta.3",
+ "version": "0.12.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -20,7 +20,7 @@
"dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
- "changelog:build": "bun run scripts/build-changelog.ts build && bun run changelog:docs",
+ "changelog:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs",
"changelog:lint": "bun run scripts/build-changelog.ts lint",
diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts
index 9c835870..f8d91bf2 100644
--- a/scripts/build-changelog.test.ts
+++ b/scripts/build-changelog.test.ts
@@ -139,6 +139,49 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
}
});
+test('writeStableReleaseArtifacts reuses the requested version and date for changelog, release notes, and docs-site output', async () => {
+ const { writeStableReleaseArtifacts } = await loadModule();
+ const workspace = createWorkspace('write-stable-release-artifacts');
+ const projectRoot = path.join(workspace, 'SubMiner');
+
+ fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
+ fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true });
+ fs.writeFileSync(
+ path.join(projectRoot, 'package.json'),
+ JSON.stringify({ name: 'subminer', version: '0.4.1' }, null, 2),
+ 'utf8',
+ );
+ fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
+ fs.writeFileSync(
+ path.join(projectRoot, 'changes', '001.md'),
+ ['type: fixed', 'area: release', '', '- Reused explicit stable release date.'].join('\n'),
+ 'utf8',
+ );
+
+ try {
+ const result = writeStableReleaseArtifacts({
+ cwd: projectRoot,
+ version: '0.4.1',
+ date: '2026-03-07',
+ });
+
+ assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
+ assert.equal(result.releaseNotesPath, path.join(projectRoot, 'release', 'release-notes.md'));
+ assert.equal(result.docsChangelogPath, path.join(projectRoot, 'docs-site', 'changelog.md'));
+
+ const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
+ const docsChangelog = fs.readFileSync(
+ path.join(projectRoot, 'docs-site', 'changelog.md'),
+ 'utf8',
+ );
+
+ assert.match(changelog, /## v0\.4\.1 \(2026-03-07\)/);
+ assert.match(docsChangelog, /## v0\.4\.1 \(2026-03-07\)/);
+ } finally {
+ fs.rmSync(workspace, { recursive: true, force: true });
+ }
+});
+
test('verifyChangelogReadyForRelease ignores README but rejects pending fragments and missing version sections', async () => {
const { verifyChangelogReadyForRelease } = await loadModule();
const workspace = createWorkspace('verify-release');
@@ -362,11 +405,11 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
- assert.match(prereleaseNotes, /## Highlights\n### Added\n- Overlay: Added prerelease coverage\./);
assert.match(
prereleaseNotes,
- /### Fixed\n- Launcher: Fixed prerelease packaging checks\./,
+ /## Highlights\n### Added\n- Overlay: Added prerelease coverage\./,
);
+ assert.match(prereleaseNotes, /### Fixed\n- Launcher: Fixed prerelease packaging checks\./);
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts
index c5fe4158..be843b58 100644
--- a/scripts/build-changelog.ts
+++ b/scripts/build-changelog.ts
@@ -430,6 +430,21 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
};
}
+export function writeStableReleaseArtifacts(options?: ChangelogOptions): {
+ deletedFragmentPaths: string[];
+ docsChangelogPath: string;
+ outputPaths: string[];
+ releaseNotesPath: string;
+} {
+ const changelogResult = writeChangelogArtifacts(options);
+ const docsChangelogPath = generateDocsChangelog(options);
+
+ return {
+ ...changelogResult,
+ docsChangelogPath,
+ };
+}
+
export function verifyChangelogFragments(options?: ChangelogOptions): void {
readChangeFragments(options?.cwd ?? process.cwd(), options?.deps);
}
@@ -726,6 +741,11 @@ function main(): void {
return;
}
+ if (command === 'build-release') {
+ writeStableReleaseArtifacts(options);
+ return;
+ }
+
if (command === 'check') {
verifyChangelogReadyForRelease(options);
return;
diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts
index 9df171d0..5047f5ca 100644
--- a/src/cli/args.test.ts
+++ b/src/cli/args.test.ts
@@ -107,11 +107,7 @@ test('parseArgs captures session action forwarding flags', () => {
});
test('parseArgs ignores non-positive numeric session action counts', () => {
- const args = parseArgs([
- '--copy-subtitle-count=0',
- '--mine-sentence-count',
- '-1',
- ]);
+ const args = parseArgs(['--copy-subtitle-count=0', '--mine-sentence-count', '-1']);
assert.equal(args.copySubtitleCount, undefined);
assert.equal(args.mineSentenceCount, undefined);
@@ -221,10 +217,7 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(toggleStatsOverlay), true);
assert.equal(shouldStartApp(toggleStatsOverlay), true);
- const cycleRuntimeOption = parseArgs([
- '--cycle-runtime-option',
- 'anki.autoUpdateNewCards:next',
- ]);
+ const cycleRuntimeOption = parseArgs(['--cycle-runtime-option', 'anki.autoUpdateNewCards:next']);
assert.equal(cycleRuntimeOption.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(cycleRuntimeOption.cycleRuntimeOptionDirection, 1);
assert.equal(hasExplicitCommand(cycleRuntimeOption), true);
diff --git a/src/cli/args.ts b/src/cli/args.ts
index 32a3d593..2df3e301 100644
--- a/src/cli/args.ts
+++ b/src/cli/args.ts
@@ -173,7 +173,10 @@ export function parseArgs(argv: string[]): CliArgs {
const separatorIndex = value.lastIndexOf(':');
if (separatorIndex <= 0 || separatorIndex === value.length - 1) return null;
const id = value.slice(0, separatorIndex).trim();
- const rawDirection = value.slice(separatorIndex + 1).trim().toLowerCase();
+ const rawDirection = value
+ .slice(separatorIndex + 1)
+ .trim()
+ .toLowerCase();
if (!id) return null;
if (rawDirection === 'next' || rawDirection === '1') {
return { id, direction: 1 };
diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts
index a98d359e..d1a225e6 100644
--- a/src/core/services/app-ready.test.ts
+++ b/src/core/services/app-ready.test.ts
@@ -75,9 +75,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
calls.indexOf('setVisibleOverlayVisible:true') < calls.indexOf('initializeOverlayRuntime'),
);
assert.ok(calls.includes('startBackgroundWarmups'));
- assert.ok(
- calls.includes('log:Runtime ready: immersion tracker startup requested.'),
- );
+ assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.'));
});
test('runAppReadyRuntime starts texthooker on startup when enabled in config', async () => {
diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts
index cdd61f44..85ba0168 100644
--- a/src/core/services/cli-command.ts
+++ b/src/core/services/cli-command.ts
@@ -277,12 +277,7 @@ export function handleCliCommand(
logLabel: string,
osdLabel: string,
): void => {
- runAsyncWithOsd(
- () => deps.dispatchSessionAction(request),
- deps,
- logLabel,
- osdLabel,
- );
+ runAsyncWithOsd(() => deps.dispatchSessionAction(request), deps, logLabel, osdLabel);
};
if (args.logLevel) {
diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts
index 6b258d7d..fcc18a7f 100644
--- a/src/core/services/immersion-tracker/__tests__/query.test.ts
+++ b/src/core/services/immersion-tracker/__tests__/query.test.ts
@@ -3840,16 +3840,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
- ).run(
- `${startedAtMs + activeMs}`,
- activeMs,
- activeMs,
- 10,
- tokens,
- cards,
- lookups,
- sessionId,
- );
+ ).run(`${startedAtMs + activeMs}`, activeMs, activeMs, 10, tokens, cards, lookups, sessionId);
}
for (const [day, active, tokens, cards] of [
@@ -3947,16 +3938,7 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
- ).run(
- `${startMs + 20 * 60_000}`,
- 20 * 60_000,
- 20 * 60_000,
- 5,
- 0,
- 0,
- 0,
- session.sessionId,
- );
+ ).run(`${startMs + 20 * 60_000}`, 20 * 60_000, 20 * 60_000, 5, 0, 0, 0, session.sessionId);
db.prepare(
`
diff --git a/src/core/services/immersion-tracker/query-trends.ts b/src/core/services/immersion-tracker/query-trends.ts
index f521886e..8dd4aecd 100644
--- a/src/core/services/immersion-tracker/query-trends.ts
+++ b/src/core/services/immersion-tracker/query-trends.ts
@@ -414,8 +414,7 @@ function buildLibrarySummary(
cards: acc.cards,
words: acc.words,
lookups: acc.lookups,
- lookupsPerHundred:
- acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
+ lookupsPerHundred: acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
firstWatched: acc.firstWatched,
lastWatched: acc.lastWatched,
});
diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts
index 2dde75fd..d07dc7a6 100644
--- a/src/core/services/immersion-tracker/storage-session.test.ts
+++ b/src/core/services/immersion-tracker/storage-session.test.ts
@@ -606,13 +606,15 @@ test('ensureSchema migrates session event timestamps to text and repairs libsql-
}>;
assert.equal(column.find((entry) => entry.name === 'ts_ms')?.type, 'TEXT');
- const row = db.prepare(
- `
+ const row = db
+ .prepare(
+ `
SELECT ts_ms AS tsMs, typeof(ts_ms) AS tsType, CREATED_DATE AS createdDate
FROM imm_session_events
WHERE event_id = 1
`,
- ).get() as {
+ )
+ .get() as {
tsMs: string;
tsType: string;
createdDate: string;
diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts
index f4510f1c..bb4e3e34 100644
--- a/src/core/services/immersion-tracker/storage.ts
+++ b/src/core/services/immersion-tracker/storage.ts
@@ -171,10 +171,12 @@ function hasColumn(db: DatabaseSync, tableName: string, columnName: string): boo
}
function getColumnType(db: DatabaseSync, tableName: string, columnName: string): string | null {
- const row = (db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
- name: string;
- type: string;
- }>).find((entry) => entry.name === columnName);
+ const row = (
+ db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
+ name: string;
+ type: string;
+ }>
+ ).find((entry) => entry.name === columnName);
return row?.type ?? null;
}
diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts
index 847607b6..049cadec 100644
--- a/src/core/services/ipc.test.ts
+++ b/src/core/services/ipc.test.ts
@@ -886,29 +886,47 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
await dispatchHandler!({}, { actionId: 'unknown-action' });
}, /Invalid session action payload/);
- await dispatchHandler!({}, {
- actionId: 'copySubtitleMultiple',
- payload: { count: 3 },
- });
- await dispatchHandler!({}, {
- actionId: 'cycleRuntimeOption',
- payload: {
- runtimeOptionId: 'anki.autoUpdateNewCards',
- direction: -1,
+ await dispatchHandler!(
+ {},
+ {
+ actionId: 'copySubtitleMultiple',
+ payload: { count: 3 },
},
- });
- await dispatchHandler!({}, {
- actionId: 'toggleSubtitleSidebar',
- });
- await dispatchHandler!({}, {
- actionId: 'openSessionHelp',
- });
- await dispatchHandler!({}, {
- actionId: 'openControllerSelect',
- });
- await dispatchHandler!({}, {
- actionId: 'openControllerDebug',
- });
+ );
+ await dispatchHandler!(
+ {},
+ {
+ actionId: 'cycleRuntimeOption',
+ payload: {
+ runtimeOptionId: 'anki.autoUpdateNewCards',
+ direction: -1,
+ },
+ },
+ );
+ await dispatchHandler!(
+ {},
+ {
+ actionId: 'toggleSubtitleSidebar',
+ },
+ );
+ await dispatchHandler!(
+ {},
+ {
+ actionId: 'openSessionHelp',
+ },
+ );
+ await dispatchHandler!(
+ {},
+ {
+ actionId: 'openControllerSelect',
+ },
+ );
+ await dispatchHandler!(
+ {},
+ {
+ actionId: 'openControllerDebug',
+ },
+ );
assert.deepEqual(dispatched, [
{
diff --git a/src/core/services/overlay-drop.test.ts b/src/core/services/overlay-drop.test.ts
index dfc765fa..e3e10522 100644
--- a/src/core/services/overlay-drop.test.ts
+++ b/src/core/services/overlay-drop.test.ts
@@ -45,11 +45,7 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates',
test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in order', () => {
const transfer = makeTransfer({
- files: [
- { path: '/subs/ep02.ass' },
- { path: '/subs/readme.txt' },
- { path: '/subs/ep03.SRT' },
- ],
+ files: [{ path: '/subs/ep02.ass' }, { path: '/subs/readme.txt' }, { path: '/subs/ep03.SRT' }],
});
const result = collectDroppedSubtitlePaths(transfer);
diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts
index f106783e..768dcc61 100644
--- a/src/core/services/overlay-visibility.ts
+++ b/src/core/services/overlay-visibility.ts
@@ -158,18 +158,24 @@ export function updateVisibleOverlayVisibility(args: {
setOverlayWindowOpacity(mainWindow, 0);
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
- scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
- ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
- : undefined);
+ scheduleWindowsOverlayReveal(
+ mainWindow,
+ shouldBindTrackedWindowsOverlay
+ ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
+ : undefined,
+ );
} else {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.show();
if (args.isWindowsPlatform) {
- scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay
- ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
- : undefined);
+ scheduleWindowsOverlayReveal(
+ mainWindow,
+ shouldBindTrackedWindowsOverlay
+ ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
+ : undefined,
+ );
}
}
}
diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts
index 41f07251..cceea9b1 100644
--- a/src/core/services/session-bindings.test.ts
+++ b/src/core/services/session-bindings.test.ts
@@ -54,9 +54,7 @@ test('compileSessionBindings merges shortcuts and keybindings into one canonical
code: binding.key.code,
modifiers: binding.key.modifiers,
target:
- binding.actionType === 'session-action'
- ? binding.actionId
- : binding.command.join(' '),
+ binding.actionType === 'session-action' ? binding.actionId : binding.command.join(' '),
})),
[
{
@@ -191,9 +189,10 @@ test('compileSessionBindings omits disabled bindings', () => {
});
assert.equal(result.warnings.length, 0);
- assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), [
- 'shortcuts.toggleVisibleOverlayGlobal',
- ]);
+ assert.deepEqual(
+ result.bindings.map((binding) => binding.sourcePath),
+ ['shortcuts.toggleVisibleOverlayGlobal'],
+ );
});
test('compileSessionBindings warns on unsupported shortcut and keybinding syntax', () => {
@@ -222,12 +221,16 @@ test('compileSessionBindings rejects malformed command arrays', () => {
platform: 'linux',
});
- assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
+ assert.deepEqual(
+ result.bindings.map((binding) => binding.sourcePath),
+ ['keybindings[0].key'],
+ );
assert.equal(result.bindings[0]?.actionType, 'mpv-command');
assert.deepEqual(result.bindings[0]?.command, ['show-text', 3000]);
- assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
- 'unsupported:keybindings[1].command',
- ]);
+ assert.deepEqual(
+ result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
+ ['unsupported:keybindings[1].command'],
+ );
});
test('compileSessionBindings rejects non-string command heads and extra args on special commands', () => {
@@ -241,10 +244,10 @@ test('compileSessionBindings rejects non-string command heads and extra args on
});
assert.deepEqual(result.bindings, []);
- assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
- 'unsupported:keybindings[0].command',
- 'unsupported:keybindings[1].command',
- ]);
+ assert.deepEqual(
+ result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
+ ['unsupported:keybindings[0].command', 'unsupported:keybindings[1].command'],
+ );
});
test('compileSessionBindings points unsupported command warnings at the command field', () => {
@@ -255,9 +258,10 @@ test('compileSessionBindings points unsupported command warnings at the command
});
assert.deepEqual(result.bindings, []);
- assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [
- 'unsupported:keybindings[0].command',
- ]);
+ assert.deepEqual(
+ result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
+ ['unsupported:keybindings[0].command'],
+ );
});
test('compileSessionBindings warns on deprecated toggleVisibleOverlayGlobal config', () => {
diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts
index 7b8272f4..d3956407 100644
--- a/src/core/services/session-bindings.ts
+++ b/src/core/services/session-bindings.ts
@@ -342,9 +342,7 @@ function getBindingFingerprint(binding: CompiledSessionBinding): string {
return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`;
}
-export function compileSessionBindings(
- input: CompileSessionBindingsInput,
-): {
+export function compileSessionBindings(input: CompileSessionBindingsInput): {
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
} {
diff --git a/src/main.ts b/src/main.ts
index 377444d2..dc45d5c3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -415,7 +415,10 @@ import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
-import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './core/services/session-bindings';
+import {
+ buildPluginSessionBindingsArtifact,
+ compileSessionBindings,
+} from './core/services/session-bindings';
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
import { createMainRuntimeRegistry } from './main/runtime/registry';
@@ -1933,9 +1936,7 @@ function getWindowsNativeWindowHandle(window: BrowserWindow): string {
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
const handle = window.getNativeWindowHandle();
- return handle.length >= 8
- ? Number(handle.readBigUInt64LE(0))
- : handle.readUInt32LE(0);
+ return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
}
function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | null): number | null {
@@ -1945,11 +1946,9 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu
try {
if (targetMpvSocketPath) {
- const windowTracker = appState.windowTracker as
- | {
- getTargetWindowHandle?: () => number | null;
- }
- | null;
+ const windowTracker = appState.windowTracker as {
+ getTargetWindowHandle?: () => number | null;
+ } | null;
const trackedHandle = windowTracker?.getTargetWindowHandle?.();
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
return trackedHandle;
@@ -2255,14 +2254,16 @@ function openOverlayHostedModalWithOsd(
unavailableMessage: string,
failureLogMessage: string,
): void {
- void openModal(createOverlayHostedModalOpenDeps()).then((opened) => {
- if (!opened) {
+ void openModal(createOverlayHostedModalOpenDeps())
+ .then((opened) => {
+ if (!opened) {
+ showMpvOsd(unavailableMessage);
+ }
+ })
+ .catch((error) => {
+ logger.error(failureLogMessage, error);
showMpvOsd(unavailableMessage);
- }
- }).catch((error) => {
- logger.error(failureLogMessage, error);
- showMpvOsd(unavailableMessage);
- });
+ });
}
function openRuntimeOptionsPalette(): void {
@@ -4932,7 +4933,8 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => requestAppQuit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
- dispatchSessionAction: (request: SessionActionDispatchRequest) => dispatchSessionAction(request),
+ dispatchSessionAction: (request: SessionActionDispatchRequest) =>
+ dispatchSessionAction(request),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
logInfo: (message: string) => logger.info(message),
@@ -5200,14 +5202,18 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
- if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
+ if (
+ targetWindowHwnd !== null &&
+ bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)
+ ) {
return;
}
const tracker = appState.windowTracker;
const mpvResult = tracker
? (() => {
try {
- const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
+ const win32 =
+ require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
diff --git a/src/main/boot/services.ts b/src/main/boot/services.ts
index f4f46582..24afbf5f 100644
--- a/src/main/boot/services.ts
+++ b/src/main/boot/services.ts
@@ -132,7 +132,10 @@ export function createMainBootServices<
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
- TOverlayManager extends { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null },
+ TOverlayManager extends {
+ getMainWindow: () => BrowserWindow | null;
+ getModalWindow: () => BrowserWindow | null;
+ },
TOverlayModalInputState extends OverlayModalInputStateShape,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts
index a3061009..a9a80035 100644
--- a/src/main/overlay-runtime.test.ts
+++ b/src/main/overlay-runtime.test.ts
@@ -180,13 +180,15 @@ function createMockWindow(): MockWindow & {
get: () => state.contentReady,
set: (value: boolean) => {
state.contentReady = value;
- (window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
- value;
+ (
+ window as typeof window & { __subminerOverlayContentReady?: boolean }
+ ).__subminerOverlayContentReady = value;
},
});
- (window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
- state.contentReady;
+ (
+ window as typeof window & { __subminerOverlayContentReady?: boolean }
+ ).__subminerOverlayContentReady = state.contentReady;
return window;
}
@@ -561,23 +563,26 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', ()
test('modal fallback reveal skips showing window when content is not ready', async () => {
const window = createMockWindow();
let scheduledReveal: (() => void) | null = null;
- const runtime = createOverlayModalRuntimeService({
- getMainWindow: () => null,
- getModalWindow: () => window as never,
- createModalWindow: () => {
- throw new Error('modal window should not be created when already present');
+ const runtime = createOverlayModalRuntimeService(
+ {
+ getMainWindow: () => null,
+ getModalWindow: () => window as never,
+ createModalWindow: () => {
+ throw new Error('modal window should not be created when already present');
+ },
+ getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
+ setModalWindowBounds: () => {},
},
- getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
- setModalWindowBounds: () => {},
- }, {
- scheduleRevealFallback: (callback) => {
- scheduledReveal = callback;
- return { scheduled: true } as never;
+ {
+ scheduleRevealFallback: (callback) => {
+ scheduledReveal = callback;
+ return { scheduled: true } as never;
+ },
+ clearRevealFallback: () => {
+ scheduledReveal = null;
+ },
},
- clearRevealFallback: () => {
- scheduledReveal = null;
- },
- });
+ );
window.loading = true;
window.url = '';
diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts
index 242e54ea..0bb60978 100644
--- a/src/main/overlay-runtime.ts
+++ b/src/main/overlay-runtime.ts
@@ -54,10 +54,7 @@ type RevealFallbackHandle = NonNullable void;
- scheduleRevealFallback?: (
- callback: () => void,
- delayMs: number,
- ) => RevealFallbackHandle;
+ scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
clearRevealFallback?: (timeout: RevealFallbackHandle) => void;
}
@@ -73,10 +70,7 @@ export function createOverlayModalRuntimeService(
let modalWindowPrimedForImmediateShow = false;
let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
- const scheduleRevealFallback = (
- callback: () => void,
- delayMs: number,
- ): RevealFallbackHandle =>
+ const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
(options.clearRevealFallback ?? globalThis.clearTimeout)(timeout);
diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts
index 5fa4fd0e..8d5727bd 100644
--- a/src/main/runtime/app-lifecycle-actions.test.ts
+++ b/src/main/runtime/app-lifecycle-actions.test.ts
@@ -16,7 +16,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
- clearWindowsVisibleOverlayForegroundPollLoop: () => calls.push('clear-windows-visible-overlay-poll'),
+ clearWindowsVisibleOverlayForegroundPollLoop: () =>
+ calls.push('clear-windows-visible-overlay-poll'),
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts
index 0f752943..89f53977 100644
--- a/src/main/runtime/config-hot-reload-handlers.ts
+++ b/src/main/runtime/config-hot-reload-handlers.ts
@@ -45,11 +45,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
statsToggleKey: config.stats.toggleKey,
platform:
- process.platform === 'darwin'
- ? 'darwin'
- : process.platform === 'win32'
- ? 'win32'
- : 'linux',
+ process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux',
rawConfig: config,
});
return {
diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts
index 4111024b..80ca0557 100644
--- a/src/main/runtime/first-run-setup-service.test.ts
+++ b/src/main/runtime/first-run-setup-service.test.ts
@@ -94,10 +94,7 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
});
test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => {
- assert.equal(
- shouldAutoOpenFirstRunSetup(makeArgs({ start: true, copySubtitleCount: 2 })),
- false,
- );
+ assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, copySubtitleCount: 2 })), false);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, mineSentenceCount: 1 })),
false,
diff --git a/src/main/runtime/immersion-startup.test.ts b/src/main/runtime/immersion-startup.test.ts
index 2388c829..79917202 100644
--- a/src/main/runtime/immersion-startup.test.ts
+++ b/src/main/runtime/immersion-startup.test.ts
@@ -194,7 +194,9 @@ test('createImmersionTrackerStartupHandler keeps tracker startup alive when mpv
),
);
assert.equal(
- calls.some((entry) => entry.startsWith('warn:Immersion tracker startup failed; disabling tracking.')),
+ calls.some((entry) =>
+ entry.startsWith('warn:Immersion tracker startup failed; disabling tracking.'),
+ ),
false,
);
});
diff --git a/src/main/runtime/immersion-startup.ts b/src/main/runtime/immersion-startup.ts
index bf533d2d..142eb23f 100644
--- a/src/main/runtime/immersion-startup.ts
+++ b/src/main/runtime/immersion-startup.ts
@@ -105,7 +105,10 @@ export function createImmersionTrackerStartupHandler(
try {
mpvClient.connect();
} catch (error) {
- deps.logWarn('MPV auto-connect failed during immersion tracker startup; continuing.', error);
+ deps.logWarn(
+ 'MPV auto-connect failed during immersion tracker startup; continuing.',
+ error,
+ );
}
}
deps.seedTrackerFromCurrentMedia();
diff --git a/src/preload.ts b/src/preload.ts
index 7bb846bb..b29be004 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -124,7 +124,9 @@ function createQueuedIpcListenerWithPayload(
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
-const onOpenControllerSelectEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerSelectOpen);
+const onOpenControllerSelectEvent = createQueuedIpcListener(
+ IPC_CHANNELS.event.controllerSelectOpen,
+);
const onOpenControllerDebugEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerDebugOpen);
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload(
diff --git a/src/prerelease-workflow.test.ts b/src/prerelease-workflow.test.ts
index 734c04bb..be0f3b74 100644
--- a/src/prerelease-workflow.test.ts
+++ b/src/prerelease-workflow.test.ts
@@ -80,7 +80,10 @@ test('prerelease workflow writes checksum entries using release asset basenames'
assert.match(prereleaseWorkflow, /: > release\/SHA256SUMS\.txt/);
assert.match(prereleaseWorkflow, /for file in "\$\{files\[@\]\}"; do/);
assert.match(prereleaseWorkflow, /\$\{file##\*\/\}/);
- assert.doesNotMatch(prereleaseWorkflow, /sha256sum "\$\{files\[@\]\}" > release\/SHA256SUMS\.txt/);
+ assert.doesNotMatch(
+ prereleaseWorkflow,
+ /sha256sum "\$\{files\[@\]\}" > release\/SHA256SUMS\.txt/,
+ );
});
test('prerelease workflow validates artifacts before publishing the release and only undrafts after upload', () => {
diff --git a/src/renderer/error-recovery.test.ts b/src/renderer/error-recovery.test.ts
index dabd10e0..99a7e553 100644
--- a/src/renderer/error-recovery.test.ts
+++ b/src/renderer/error-recovery.test.ts
@@ -364,7 +364,10 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => {
const root = {
querySelectorAll: (value: string) => {
selectors.push(value);
- if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) {
+ if (
+ value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
+ value === YOMITAN_POPUP_HOST_SELECTOR
+ ) {
return [];
}
return [hiddenFrame, visibleFrame];
diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts
index 63999d80..3d6caa0e 100644
--- a/src/renderer/handlers/keyboard.test.ts
+++ b/src/renderer/handlers/keyboard.test.ts
@@ -1063,7 +1063,9 @@ test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () =
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true });
- assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'triggerSubsync', payload: undefined }]);
+ assert.deepEqual(testGlobals.sessionActions, [
+ { actionId: 'triggerSubsync', payload: undefined },
+ ]);
} finally {
testGlobals.restore();
}
diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts
index ca99e31c..33a617a4 100644
--- a/src/renderer/handlers/keyboard.ts
+++ b/src/renderer/handlers/keyboard.ts
@@ -39,12 +39,10 @@ export function createKeyboardHandlers(
let pendingLookupRefreshAfterSubtitleSeek = false;
let resetSelectionToStartOnNextSubtitleSync = false;
let lookupScanFallbackTimer: ReturnType | null = null;
- let pendingNumericSelection:
- | {
- actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
- timeout: ReturnType | null;
- }
- | null = null;
+ let pendingNumericSelection: {
+ actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
+ timeout: ReturnType | null;
+ } | null = null;
const CHORD_MAP = new Map<
string,
diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts
index 5180eb73..7d193be9 100644
--- a/src/renderer/handlers/mouse.ts
+++ b/src/renderer/handlers/mouse.ts
@@ -73,11 +73,13 @@ export function createMouseHandlers(
syncOverlayMouseIgnoreState(ctx);
}
- function reconcilePopupInteraction(args: {
- assumeVisible?: boolean;
- reclaimFocus?: boolean;
- allowPause?: boolean;
- } = {}): boolean {
+ function reconcilePopupInteraction(
+ args: {
+ assumeVisible?: boolean;
+ reclaimFocus?: boolean;
+ allowPause?: boolean;
+ } = {},
+ ): boolean {
const popupVisible = syncPopupVisibilityState(args.assumeVisible === true);
if (!popupVisible) {
syncOverlayMouseIgnoreState(ctx);
diff --git a/src/renderer/modals/runtime-options.test.ts b/src/renderer/modals/runtime-options.test.ts
index a1d3a5de..9fc5219c 100644
--- a/src/renderer/modals/runtime-options.test.ts
+++ b/src/renderer/modals/runtime-options.test.ts
@@ -168,48 +168,54 @@ function withRuntimeOptionsModal(
test('openRuntimeOptionsModal shows loading shell before runtime options resolve', async () => {
const deferred = createDeferred();
- await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
- input.modal.openRuntimeOptionsModal();
+ await withRuntimeOptionsModal(
+ () => deferred.promise,
+ async (input) => {
+ input.modal.openRuntimeOptionsModal();
- assert.equal(input.state.runtimeOptionsModalOpen, true);
- assert.equal(input.overlayClassList.contains('interactive'), true);
- assert.equal(input.modalClassList.contains('hidden'), false);
- assert.equal(input.statusNode.textContent, 'Loading runtime options...');
- assert.deepEqual(input.syncCalls, ['sync']);
+ assert.equal(input.state.runtimeOptionsModalOpen, true);
+ assert.equal(input.overlayClassList.contains('interactive'), true);
+ assert.equal(input.modalClassList.contains('hidden'), false);
+ assert.equal(input.statusNode.textContent, 'Loading runtime options...');
+ assert.deepEqual(input.syncCalls, ['sync']);
- deferred.resolve([
- {
- id: 'anki.autoUpdateNewCards',
- label: 'Auto-update new cards',
- scope: 'ankiConnect',
- valueType: 'boolean',
- value: true,
- allowedValues: [true, false],
- requiresRestart: false,
- },
- ]);
- await flushAsyncWork();
+ deferred.resolve([
+ {
+ id: 'anki.autoUpdateNewCards',
+ label: 'Auto-update new cards',
+ scope: 'ankiConnect',
+ valueType: 'boolean',
+ value: true,
+ allowedValues: [true, false],
+ requiresRestart: false,
+ },
+ ]);
+ await flushAsyncWork();
- assert.equal(
- input.statusNode.textContent,
- 'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
- );
- assert.equal(input.statusNode.classList.contains('error'), false);
- });
+ assert.equal(
+ input.statusNode.textContent,
+ 'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
+ );
+ assert.equal(input.statusNode.classList.contains('error'), false);
+ },
+ );
});
test('openRuntimeOptionsModal keeps modal visible when loading fails', async () => {
const deferred = createDeferred();
- await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
- input.modal.openRuntimeOptionsModal();
- deferred.reject(new Error('boom'));
- await flushAsyncWork();
+ await withRuntimeOptionsModal(
+ () => deferred.promise,
+ async (input) => {
+ input.modal.openRuntimeOptionsModal();
+ deferred.reject(new Error('boom'));
+ await flushAsyncWork();
- assert.equal(input.state.runtimeOptionsModalOpen, true);
- assert.equal(input.overlayClassList.contains('interactive'), true);
- assert.equal(input.modalClassList.contains('hidden'), false);
- assert.equal(input.statusNode.textContent, 'Failed to load runtime options');
- assert.equal(input.statusNode.classList.contains('error'), true);
- });
+ assert.equal(input.state.runtimeOptionsModalOpen, true);
+ assert.equal(input.overlayClassList.contains('interactive'), true);
+ assert.equal(input.modalClassList.contains('hidden'), false);
+ assert.equal(input.statusNode.textContent, 'Failed to load runtime options');
+ assert.equal(input.statusNode.classList.contains('error'), true);
+ },
+ );
});
diff --git a/src/renderer/overlay-mouse-ignore.test.ts b/src/renderer/overlay-mouse-ignore.test.ts
index 2c6e1617..ee85db27 100644
--- a/src/renderer/overlay-mouse-ignore.test.ts
+++ b/src/renderer/overlay-mouse-ignore.test.ts
@@ -130,7 +130,8 @@ test('visible yomitan popup host keeps overlay interactive even when cached popu
},
document: {
querySelectorAll: (selector: string) =>
- selector === '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
+ selector ===
+ '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
},
diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts
index 6aeb0c93..2d8006b6 100644
--- a/src/renderer/yomitan-popup.ts
+++ b/src/renderer/yomitan-popup.ts
@@ -73,7 +73,10 @@ function queryPopupElements(
}
export function isYomitanPopupVisible(root: ParentNode | null | undefined = document): boolean {
- const visiblePopupHosts = queryPopupElements(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
+ const visiblePopupHosts = queryPopupElements(
+ root,
+ YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
+ );
if (visiblePopupHosts.length > 0) {
return true;
}
diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts
index 4fcb8c57..a7939f67 100644
--- a/src/shared/ipc/validators.ts
+++ b/src/shared/ipc/validators.ts
@@ -256,7 +256,9 @@ export function parseSessionActionDispatchRequest(
const payload = parseSessionActionPayload(value.actionId, value.payload);
if (payload === null) return null;
- return payload === undefined ? { actionId: value.actionId } : { actionId: value.actionId, payload };
+ return payload === undefined
+ ? { actionId: value.actionId }
+ : { actionId: value.actionId, payload };
}
export function parseMpvCommand(value: unknown): Array | null {
diff --git a/src/types/runtime.ts b/src/types/runtime.ts
index fa8f60a6..f68f1a8a 100644
--- a/src/types/runtime.ts
+++ b/src/types/runtime.ts
@@ -364,7 +364,10 @@ export interface ElectronAPI {
getKeybindings: () => Promise;
getSessionBindings: () => Promise;
getConfiguredShortcuts: () => Promise>;
- dispatchSessionAction: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise;
+ dispatchSessionAction: (
+ actionId: SessionActionId,
+ payload?: SessionActionPayload,
+ ) => Promise;
getStatsToggleKey: () => Promise;
getMarkWatchedKey: () => Promise;
markActiveVideoWatched: () => Promise;
diff --git a/src/types/session-bindings.ts b/src/types/session-bindings.ts
index 9b510bc8..16b32e15 100644
--- a/src/types/session-bindings.ts
+++ b/src/types/session-bindings.ts
@@ -62,9 +62,7 @@ export interface CompiledSessionActionBinding extends CompiledSessionBindingBase
payload?: SessionActionPayload;
}
-export type CompiledSessionBinding =
- | CompiledMpvCommandBinding
- | CompiledSessionActionBinding;
+export type CompiledSessionBinding = CompiledMpvCommandBinding | CompiledSessionActionBinding;
export interface PluginSessionBindingsArtifact {
version: 1;
diff --git a/src/window-trackers/mpv-socket-match.test.ts b/src/window-trackers/mpv-socket-match.test.ts
index bec350d8..e35e5eb6 100644
--- a/src/window-trackers/mpv-socket-match.test.ts
+++ b/src/window-trackers/mpv-socket-match.test.ts
@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { filterMpvPollResultBySocketPath, matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
+import {
+ filterMpvPollResultBySocketPath,
+ matchesMpvSocketPathInCommandLine,
+} from './mpv-socket-match';
import type { MpvPollResult } from './win32';
function createPollResult(commandLines: Array): MpvPollResult {
@@ -51,7 +54,10 @@ test('filterMpvPollResultBySocketPath keeps only matches for the requested socke
'\\\\.\\pipe\\subminer-b',
);
- assert.deepEqual(result.matches.map((match) => match.hwnd), [2]);
+ assert.deepEqual(
+ result.matches.map((match) => match.hwnd),
+ [2],
+ );
assert.equal(result.windowState, 'visible');
});
diff --git a/src/window-trackers/windows-helper.ts b/src/window-trackers/windows-helper.ts
index c71787ff..5a9ef40f 100644
--- a/src/window-trackers/windows-helper.ts
+++ b/src/window-trackers/windows-helper.ts
@@ -26,7 +26,8 @@ export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number
const poll = result ?? loadWin32().findMpvWindows();
const focused = poll.matches.find((match) => match.isForeground);
const best =
- focused ?? [...poll.matches].sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0];
+ focused ??
+ [...poll.matches].sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0];
return best?.hwnd ?? null;
}
diff --git a/src/window-trackers/windows-tracker.test.ts b/src/window-trackers/windows-tracker.test.ts
index da353a27..24afefb0 100644
--- a/src/window-trackers/windows-tracker.test.ts
+++ b/src/window-trackers/windows-tracker.test.ts
@@ -4,7 +4,9 @@ import { WindowsWindowTracker } from './windows-tracker';
import type { MpvPollResult } from './win32';
function mpvVisible(
- overrides: Partial = {},
+ overrides: Partial<
+ MpvPollResult & { x?: number; y?: number; width?: number; height?: number; focused?: boolean }
+ > = {},
): MpvPollResult {
return {
matches: [
diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts
index 4152aaa6..5b695c03 100644
--- a/src/window-trackers/windows-tracker.ts
+++ b/src/window-trackers/windows-tracker.ts
@@ -55,7 +55,8 @@ export class WindowsWindowTracker extends BaseWindowTracker {
constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
super();
this.targetMpvSocketPath = _targetMpvSocketPath?.trim() || null;
- this.pollMpvWindows = deps.pollMpvWindows ?? (() => defaultPollMpvWindows(this.targetMpvSocketPath));
+ this.pollMpvWindows =
+ deps.pollMpvWindows ?? (() => defaultPollMpvWindows(this.targetMpvSocketPath));
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500));
this.minimizedTrackingLossGraceMs = Math.max(