From b3060e1a302d3f8e98bd50a6df04e62d72535594 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 23:42:04 +0200 Subject: [PATCH 01/39] fix(ui): remove the previous bundle's style sheets when PodNotes loads Obsidian updates a plugin in place without reloading its windows, and Svelte only ever appends a component's sheet, so the sheets of the version before an update kept applying until a restart. PodNotes now removes its earlier pn- sheets from the main window and every popout at the start of onload, before registerView mounts the restored views. tests/e2e/update-styles.test.ts installs the 2.21.1 release over this build, plants stand-in stale sheets, then updates back the way Obsidian's updater does, with the view in the main window and in a popout. --- src/commands.test.ts | 1 + src/main.activateView.test.ts | 34 ++++- src/main.fileRenames.test.ts | 1 + src/main.ts | 3 + src/main.undo.test.ts | 1 + src/ui/removePreviousStyles.test.ts | 69 ++++++++++ src/ui/removePreviousStyles.ts | 15 +++ tests/e2e/journey/actions.ts | 34 +++++ tests/e2e/player-popout-save.test.ts | 42 +----- tests/e2e/update-styles.test.ts | 194 +++++++++++++++++++++++++++ 10 files changed, 352 insertions(+), 42 deletions(-) create mode 100644 src/ui/removePreviousStyles.test.ts create mode 100644 src/ui/removePreviousStyles.ts create mode 100644 tests/e2e/update-styles.test.ts diff --git a/src/commands.test.ts b/src/commands.test.ts index 80447c0f..2ec9a78e 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -108,6 +108,7 @@ function fakeWorkspace(notes: ReturnType["notes"]) { return {}; }), getLeavesOfType: (type: string) => (type === "markdown" ? [...open] : []), + iterateAllLeaves: vi.fn(), getActiveViewOfType: () => null, getRightLeaf: () => null, revealLeaf: vi.fn(), diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index 414451da..0d3315c7 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -291,7 +291,10 @@ describe("PodNotes onload wiring (#55)", () => { vi.restoreAllMocks(); }); - async function loadPlugin(loadedData: Record = {}) { + async function loadPlugin( + loadedData: Record = {}, + own: Partial> = {}, + ) { const activateSpy = vi .spyOn(PodNotes.prototype, "activateView") .mockResolvedValue(undefined); @@ -350,12 +353,14 @@ describe("PodNotes onload wiring (#55)", () => { on: vi.fn(() => ({})), offref: vi.fn(), getLeavesOfType: vi.fn(() => []), + iterateAllLeaves: vi.fn(), getRightLeaf: vi.fn(() => null), getActiveViewOfType: vi.fn(() => null), revealLeaf: vi.fn(), detachLeavesOfType: vi.fn(), }, }, + ...own, }); await plugin.onload(); @@ -364,6 +369,33 @@ describe("PodNotes onload wiring (#55)", () => { return { commands, ribbonCalls, activateSpy, plugin }; } + it("removes an earlier build's sheets before it loads anything, and keeps those its views append", async () => { + const sheet = (id: string) => + document.head.appendChild(Object.assign(document.createElement("style"), { id })); + const shown = () => [...document.head.querySelectorAll("style")].map((style) => style.id); + sheet("pn-earlier"); + const seen: Record = {}; + onTestFinished(() => document.head.replaceChildren()); + + await loadPlugin( + { schemaVersion: 2 }, + { + loadData: vi.fn(async () => { + seen.loadData = shown(); + return { schemaVersion: 2 }; + }), + // Obsidian mounts the restored views here, and each appends its sheet. + registerView: vi.fn(() => { + seen.registerView = shown(); + sheet("pn-mounted"); + }), + }, + ); + + expect(seen).toEqual({ loadData: [], registerView: [] }); + expect(shown()).toEqual(["pn-mounted"]); + }); + it("starts no save once the window fires pagehide", async () => { const { plugin } = await loadPlugin({ schemaVersion: 2 }); const domEvents = vi.mocked(plugin.registerDomEvent).mock.calls as unknown as Array< diff --git a/src/main.fileRenames.test.ts b/src/main.fileRenames.test.ts index 6e22b894..fc593d1b 100644 --- a/src/main.fileRenames.test.ts +++ b/src/main.fileRenames.test.ts @@ -103,6 +103,7 @@ async function loadPlugin(data: Record): Promise { onLayoutReady: vi.fn(), on: vi.fn(() => ({})), getLeavesOfType: vi.fn(() => []), + iterateAllLeaves: vi.fn(), getRightLeaf: vi.fn(() => null), getActiveViewOfType: vi.fn(() => null), revealLeaf: vi.fn(), diff --git a/src/main.ts b/src/main.ts index e6d37114..d313ffbf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab"; import { MainView } from "src/ui/PodcastView"; import { Navigation } from "src/ui/PodcastView/Navigation"; import { dismissUndoNotices } from "src/ui/undoNotice"; +import { removePreviousStyles } from "src/ui/removePreviousStyles"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; import type { IPodNotes } from "./types/IPodNotes"; import type PartialAppExtension from "./global"; @@ -127,6 +128,8 @@ export default class PodNotes extends Plugin implements IPodNotes { private persistenceUnknownFields: Record = {}; override async onload() { + // Before registerView, where the views Obsidian restores mount and append their sheets. + removePreviousStyles(this.app.workspace); this.isUnloaded = false; this.podcastViewMountEnabled = !this.isMobileRuntime(); this.registerDomEvent(window, "pagehide", () => { diff --git a/src/main.undo.test.ts b/src/main.undo.test.ts index 9e276e27..19537b6e 100644 --- a/src/main.undo.test.ts +++ b/src/main.undo.test.ts @@ -70,6 +70,7 @@ async function loadPlugin(secrets: obsidian.SecretStorage): Promise { on: vi.fn(() => ({})), offref: vi.fn(), getLeavesOfType: vi.fn(() => []), + iterateAllLeaves: vi.fn(), getActiveViewOfType: vi.fn(() => null), }, }, diff --git a/src/ui/removePreviousStyles.test.ts b/src/ui/removePreviousStyles.test.ts new file mode 100644 index 00000000..782400b9 --- /dev/null +++ b/src/ui/removePreviousStyles.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import type { Workspace } from "obsidian"; +import type { UserConfig } from "vite"; +import { afterEach, expect, test, vi } from "vitest"; +import { openPopout } from "../../tests/mocks/popout"; +import { removePreviousStyles } from "./removePreviousStyles"; + +vi.mock("@sveltejs/vite-plugin-svelte", () => ({ + svelte: (options: unknown) => ({ name: "svelte-options", options }), +})); + +type CssHash = (input: { hash: (css: string) => string; css: string }) => string; + +/** The id the production build gives the sheet of a component whose CSS hashes to `hash`. */ +async function builtSheetId(hash: string): Promise { + const { default: config } = await import("../../vite.config"); + const resolved = (config as (env: object) => UserConfig)({ + mode: "production", + command: "build", + }); + const svelte = resolved.plugins?.find( + (plugin) => plugin && "name" in plugin && plugin.name === "svelte-options", + ) as unknown as { options: { compilerOptions: { cssHash: CssHash } } }; + return svelte.options.compilerOptions.cssHash({ hash: () => hash, css: "" }); +} + +function addSheet(doc: Document, id: string): void { + const style = doc.createElement("style"); + style.id = id; + doc.head.append(style); +} + +const sheetIds = (doc: Document) => + [...doc.head.querySelectorAll("style")].map((style) => style.id); + +function workspaceWithLeavesIn(...docs: Document[]): Workspace { + return { + iterateAllLeaves: (callback: (leaf: unknown) => void) => { + for (const doc of docs) callback({ getContainer: () => ({ doc }) }); + }, + } as unknown as Workspace; +} + +afterEach(() => { + document.head.replaceChildren(); +}); + +test("removes the sheets of an earlier build from the main window and every popout, and no other sheet", async () => { + const popout = openPopout().win.document; + const earlier = await builtSheetId("earlier"); + for (const doc of [document, popout]) { + addSheet(doc, earlier); + addSheet(doc, "svelte-1x2y3z"); + addSheet(doc, "obsidian-theme"); + } + + removePreviousStyles(workspaceWithLeavesIn(document, popout, popout)); + + expect(sheetIds(document)).toEqual(["svelte-1x2y3z", "obsidian-theme"]); + expect(sheetIds(popout)).toEqual(["svelte-1x2y3z", "obsidian-theme"]); +}); + +test("removes the main window's sheets when no leaf is open", async () => { + addSheet(document, await builtSheetId("earlier")); + + removePreviousStyles(workspaceWithLeavesIn()); + + expect(sheetIds(document)).toEqual([]); +}); diff --git a/src/ui/removePreviousStyles.ts b/src/ui/removePreviousStyles.ts new file mode 100644 index 00000000..4986b2c8 --- /dev/null +++ b/src/ui/removePreviousStyles.ts @@ -0,0 +1,15 @@ +import type { Workspace } from "obsidian"; + +/** + * Removes the style sheets an earlier PodNotes bundle injected. Obsidian updates a plugin + * without reloading its windows, and Svelte only appends a component's sheet, so an old + * sheet would keep applying until a restart. Popouts are swept too: Obsidian mirrors the + * main window's sheets into them but never removes one Svelte appended there directly. + */ +export function removePreviousStyles(workspace: Workspace): void { + const docs = new Set([window.document]); + workspace.iterateAllLeaves((leaf) => docs.add(leaf.getContainer().doc)); + for (const doc of docs) { + for (const style of doc.head.querySelectorAll('style[id^="pn-"]')) style.remove(); + } +} diff --git a/tests/e2e/journey/actions.ts b/tests/e2e/journey/actions.ts index 3fdefb09..ee8d83a4 100644 --- a/tests/e2e/journey/actions.ts +++ b/tests/e2e/journey/actions.ts @@ -240,6 +240,40 @@ export async function closePopoutTab(obsidian: ObsidianClient, notePath: string) if (!closed) throw new Error(`The popout showing ${notePath} did not close.`); } +/** Moves the PodNotes view into a new popout window, as the tab menu's "Move to new window" does. */ +export async function moveViewToPopout(obsidian: ObsidianClient): Promise { + const moved = await obsidian.dev.evalJsonAsync(`(async () => { + const leaf = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + app.workspace.moveLeafToPopout(leaf); + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const moved = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + if (moved?.view.containerEl.ownerDocument !== document) return true; + } + return false; + })()`); + if (!moved) throw new Error("The PodNotes view did not move to a popout."); +} + +// Closes a popout left open, saves the layout so no reload reopens it, and reloads: +// closing a popout leaves Obsidian's layout observers looping in the main window for a +// while, and a fresh window keeps later tests clean. +export async function closePopoutAndReload(obsidian: ObsidianClient): Promise { + await obsidian.dev.evalJsonAsync(`(async () => { + require("@electron/remote").getCurrentWindow().show(); + const leaf = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + leaf?.detach(); + const windows = () => require("@electron/remote").BrowserWindow.getAllWindows().length; + for (let i = 0; i < 50 && (app.workspace.floatingSplit.children.length > 0 || windows() > 1); i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + app.workspace.requestSaveLayout.cancel(); + await app.workspace.saveLayout(); + return true; + })()`); + await reloadAfterPopout(obsidian); +} + export async function revealInFileExplorer( obsidian: ObsidianClient, filePath: string, diff --git a/tests/e2e/player-popout-save.test.ts b/tests/e2e/player-popout-save.test.ts index 31efb5ea..da591404 100644 --- a/tests/e2e/player-popout-save.test.ts +++ b/tests/e2e/player-popout-save.test.ts @@ -11,7 +11,7 @@ import { seedAudio, waitForPlaybackState, } from "./harness"; -import { reloadAfterPopout } from "./journey/actions"; +import { closePopoutAndReload, moveViewToPopout } from "./journey/actions"; import { jsLiteral, PLUGIN_ID, @@ -28,24 +28,6 @@ const PROGRESS_KEY = `local file::${EPISODE_TITLE}`; type Obsidian = Parameters[0]; type Order = "opened in a popout" | "moved to a popout while playing" | "in the main window"; -async function moveViewToPopout(obsidian: Obsidian): Promise { - expect( - await evalJsonAsync( - obsidian, - `(async () => { - const leaf = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; - app.workspace.moveLeafToPopout(leaf); - for (let i = 0; i < 50; i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); - const moved = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; - if (moved?.view.containerEl.ownerDocument !== document) return true; - } - return false; - })()`, - ), - ).toBe(true); -} - async function play(order: Order): Promise { const { obsidian, plugin, sandbox } = getContext(); const audioPath = await seedAudio(obsidian, sandbox); @@ -107,28 +89,6 @@ async function reloadObsidian(obsidian: Obsidian): Promise { await waitForPodNotesReady(obsidian); } -// Closes a popout left open, saves the layout so no reload reopens it, and reloads: -// closing a popout leaves Obsidian's layout observers looping in the main window for a -// while, and a fresh window keeps later tests clean. -async function closePopoutAndReload(obsidian: Obsidian): Promise { - await evalJsonAsync( - obsidian, - `(async () => { - require("@electron/remote").getCurrentWindow().show(); - const leaf = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; - leaf?.detach(); - const windows = () => require("@electron/remote").BrowserWindow.getAllWindows().length; - for (let i = 0; i < 50 && (app.workspace.floatingSplit.children.length > 0 || windows() > 1); i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - app.workspace.requestSaveLayout.cancel(); - await app.workspace.saveLayout(); - return true; - })()`, - ); - await reloadAfterPopout(obsidian); -} - // The audio lives in the main window, so a popout holding the player is only a view of it. for (const order of ["opened in a popout", "moved to a popout while playing"] as const) { test(`closing a popout with a player ${order} keeps the audio playing and data.json intact`, async () => { diff --git a/tests/e2e/update-styles.test.ts b/tests/e2e/update-styles.test.ts new file mode 100644 index 00000000..7c0ac128 --- /dev/null +++ b/tests/e2e/update-styles.test.ts @@ -0,0 +1,194 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { + createLocalEpisode, + createPodNotesE2EHarness, + type PodNotesData, + seedAudio, +} from "./harness"; +import { closePopoutAndReload, moveViewToPopout, showPodNotes } from "./journey/actions"; +import { click } from "./journey/input"; +import { ui } from "./journey/targets"; +import { eventually, isShown, waitUntilShown } from "./journey/wait"; +import { jsLiteral, PLUGIN_ID, RELOAD_OPTIONS, VIEW_TYPE, waitForPodNotesReady } from "./runtime"; + +const getContext = createPodNotesE2EHarness("update-styles"); + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const RELEASE = "2.21.1"; +const RELEASE_DIGESTS = { + "main.js": "c1dc3560ad689e2df0d055209fb4d2a428c2233a348deedb62b9d87a7b9406ed", + "manifest.json": "d58d070821e14b699fbc929db0d086232ebd13acf47804a28c1e3f35715508b0", +} as const; +type Artifact = keyof typeof RELEASE_DIGESTS; +type Bundle = Record; +const ARTIFACTS = Object.keys(RELEASE_DIGESTS) as Artifact[]; + +// Stands for an earlier PodNotes sheet whose rule still matches the new markup. +const STALE_RULE = ".pn-mini .pn-iconbtn { color: rgb(255, 0, 0) !important; }"; +const OTHER_PLUGIN_SHEET = "svelte-e2e-other"; + +type Where = "main" | "view"; +type ButtonStyle = Record<"color" | "stroke" | "fill" | "opacity" | "size" | "background", string>; + +const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); + +/** The released bundle from its GitHub release, cached in the git-ignored artifacts folder. */ +async function releasedBundle(): Promise { + const dir = path.join(ROOT, ".obsidian-e2e-artifacts/releases", RELEASE); + await mkdir(dir, { recursive: true }); + const bundle = {} as Bundle; + for (const name of ARTIFACTS) { + const file = path.join(dir, name); + let bytes = await readFile(file).catch(() => null); + if (!bytes || sha256(bytes) !== RELEASE_DIGESTS[name]) { + const url = `https://github.com/chhoumann/PodNotes/releases/download/${RELEASE}/${name}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`${url} answered ${response.status}.`); + bytes = Buffer.from(await response.arrayBuffer()); + expect(sha256(bytes), `the ${RELEASE} ${name} digest`).toBe(RELEASE_DIGESTS[name]); + await writeFile(file, bytes); + } + bundle[name] = bytes; + } + return bundle; +} + +async function pluginDir(): Promise { + const { obsidian } = getContext(); + const dir = await obsidian.dev.evalJson( + `app.plugins.manifests[${jsLiteral(PLUGIN_ID)}].dir`, + ); + return path.join(await obsidian.vaultPath(), dir); +} + +/** Writes `bundle` over the plugin's files and reloads it the way Obsidian's updater does. */ +async function install(dir: string, bundle: Bundle): Promise { + const { obsidian } = getContext(); + for (const name of ARTIFACTS) { + await rm(path.join(dir, name)); + await writeFile(path.join(dir, name), bundle[name]); + } + await obsidian.dev.evalJsonAsync(`(async () => { + const plugins = app.plugins; + await plugins.loadManifest(plugins.manifests[${jsLiteral(PLUGIN_ID)}].dir); + await plugins.disablePlugin(${jsLiteral(PLUGIN_ID)}); + await plugins.enablePlugin(${jsLiteral(PLUGIN_ID)}, true); + return true; + })()`); + await waitForPodNotesReady(obsidian); +} + +const viewDocument = `app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0].view.containerEl.ownerDocument`; +const documentOf = (where: Where) => (where === "main" ? "document" : viewDocument); + +async function plant(where: Where, id: string, css: string): Promise { + await getContext().obsidian.dev.evalJson(`(() => { + const doc = ${documentOf(where)}; + const style = doc.createElement("style"); + style.id = ${jsLiteral(id)}; + style.textContent = ${jsLiteral(css)}; + doc.head.append(style); + return true; + })()`); +} + +/** Ids of the PodNotes and test sheets in a document's head, in order, repeats kept. */ +function sheetIds(where: Where): Promise { + return getContext().obsidian.dev.evalJson( + `[...(${documentOf(where)}).head.querySelectorAll("style[id]")].map((style) => style.id).filter((id) => /^(pn-|svelte-e2e-)/.test(id))`, + ); +} + +/** The mini player's Play button as the view's window draws it. */ +function playButton(): Promise { + return getContext().obsidian.dev.evalJson(`(() => { + const doc = ${viewDocument}; + const button = doc.querySelector(".pn-mini button[aria-label=Play]"); + if (!button) return null; + const style = doc.defaultView.getComputedStyle(button); + const svg = doc.defaultView.getComputedStyle(button.querySelector("svg")); + return { + color: style.color, + stroke: svg.stroke, + fill: svg.fill, + opacity: style.opacity, + size: style.width + " " + style.height, + background: style.backgroundColor, + }; + })()`); +} + +/** Shows the mini player with a local episode in the main window, and reads its Play button. */ +async function showMiniPlayer(): Promise { + const { obsidian, plugin, sandbox } = getContext(); + const audioPath = await seedAudio(obsidian, sandbox); + await plugin.updateDataAndReload((data) => { + data.currentEpisode = createLocalEpisode("E2E Update Styles", audioPath); + }, RELOAD_OPTIONS); + await waitForPodNotesReady(obsidian); + await showPodNotes(obsidian); + if (await isShown(obsidian, ui.fullPlayer)) await click(obsidian, ui.collapsePlayer); + await waitUntilShown(obsidian, ui.miniPlayer); + const play = await playButton(); + expect(play, "the mini player's Play button").not.toBeNull(); + return play!; +} + +// A user runs the released version with the view where they left it, then updates. +test.each(["main window", "popout"] as const)( + "updating PodNotes in place drops the previous bundle's sheets with the view in the %s", + async (placement) => { + const { obsidian, plugin } = getContext(); + const released = await releasedBundle(); + const dir = await pluginDir(); + const links = {} as Record; + const current = {} as Bundle; + for (const name of ARTIFACTS) { + links[name] = await readlink(path.join(dir, name)); + current[name] = await readFile(path.resolve(dir, links[name])); + } + const fresh = await showMiniPlayer(); + try { + await install(dir, released); + if (placement === "popout") await moveViewToPopout(obsidian); + await plant("main", "pn-e2e-stale-main", STALE_RULE); + await plant("view", "pn-e2e-stale-view", STALE_RULE); + await plant("main", OTHER_PLUGIN_SHEET, ""); + await plant("view", OTHER_PLUGIN_SHEET, ""); + + await install(dir, current); + + const sheets = async () => { + const ids = [await sheetIds("main"), await sheetIds("view")]; + return { + stale: ids.flat().filter((id) => id.startsWith("pn-e2e-stale-")), + repeated: ids.flatMap((list) => + list.filter( + (id, index) => id.startsWith("pn-") && list.indexOf(id) !== index, + ), + ), + other: ids.map((list) => list.includes(OTHER_PLUGIN_SHEET)), + }; + }; + const clean = { stale: [], repeated: [], other: [true, true] }; + expect(await eventually(obsidian, sheets, clean)).toEqual(clean); + expect(await eventually(obsidian, playButton, fresh)).toEqual(fresh); + expect( + await obsidian.dev.evalJson(`(${viewDocument}) !== document`), + "the view's window", + ).toBe(placement === "popout"); + } finally { + for (const name of ARTIFACTS) { + await rm(path.join(dir, name)); + await symlink(links[name], path.join(dir, name)); + } + if (placement === "popout") await closePopoutAndReload(obsidian); + else await plugin.reload(RELOAD_OPTIONS); + } + }, + 180_000, +); From ab1f3a056f3c6ea3c442d155cf0c4c5d1445fa3b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 23:51:53 +0200 Subject: [PATCH 02/39] refactor(ui): share one button reset, one focus ring and one playing check - .pn-bare in the shell carries the reset of Obsidian's button skin that fifteen rules repeated, together with the shell's own .pn-back, .pn-iconbtn and .pn-navitem. Declarations equal to Obsidian's own button rule (border, cursor, font size) go. - One button:focus-visible rule replaces the list of thirteen selectors. - isPlaying(status) replaces four copies of the same derivation. - tests/tooling/componentStyles.test.ts fails when two components scope the same class, so a component's sheet only styles what it renders. - The queue list drops its unused pn-queue class; the e2e targets select it by its accessible name. --- src/ui/PodcastView/AddPodcastPage.svelte | 16 +--- src/ui/PodcastView/ChaptersPanel.svelte | 2 +- src/ui/PodcastView/Deck.svelte | 23 +++--- src/ui/PodcastView/EpisodeRow.svelte | 13 +--- src/ui/PodcastView/LibraryPage.svelte | 6 +- src/ui/PodcastView/MiniPlayer.svelte | 45 +++++------ src/ui/PodcastView/Panel.svelte | 2 - src/ui/PodcastView/Player.svelte | 16 ++-- src/ui/PodcastView/PlaylistsPage.svelte | 13 ++-- src/ui/PodcastView/PodcastView.svelte | 40 +++------- src/ui/PodcastView/QueueList.svelte | 2 +- src/ui/PodcastView/TranscriptPanel.svelte | 2 +- src/ui/PodcastView/UpNextPanel.svelte | 9 +-- src/ui/PodcastView/episodeRow.test.ts | 17 +++++ src/ui/PodcastView/episodeRow.ts | 6 ++ tests/e2e/journey-player.test.ts | 2 +- tests/e2e/journey/targets.ts | 5 +- tests/tooling/componentStyles.test.ts | 93 +++++++++++++++++++++++ 18 files changed, 184 insertions(+), 128 deletions(-) create mode 100644 tests/tooling/componentStyles.test.ts diff --git a/src/ui/PodcastView/AddPodcastPage.svelte b/src/ui/PodcastView/AddPodcastPage.svelte index ccd26f6a..92439099 100644 --- a/src/ui/PodcastView/AddPodcastPage.svelte +++ b/src/ui/PodcastView/AddPodcastPage.svelte @@ -43,12 +43,12 @@ {#snippet alternatives()}
- {:else} @@ -114,7 +112,7 @@ {#if current >= 0} @@ -55,15 +59,10 @@ width: 100%; height: auto; padding: var(--size-4-2); - border: 0; border-radius: var(--radius-s); - background: none; - box-shadow: none; color: var(--text-normal); - font-size: var(--font-ui-small); font-weight: inherit; text-align: left; - cursor: var(--cursor); } .pn-plrow:hover { diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 95c2ebf4..89b37af0 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -428,13 +428,8 @@ max-width: 70%; height: var(--pn-btn); padding: 0 var(--size-4-2) 0 var(--size-2-1); - border: 0; border-radius: var(--radius-s); - background: none; - box-shadow: none; color: var(--text-muted); - font-size: var(--font-ui-small); - cursor: var(--cursor); } .pn-back:hover { @@ -533,6 +528,16 @@ margin-top: var(--size-4-1); } + /* Undoes Obsidian's button skin, for a button drawn as its content. */ + .pn-bare, + .pn-back, + .pn-iconbtn, + .pn-navitem { + border: 0; + background: none; + box-shadow: none; + } + .pn-iconbtn { display: inline-flex; align-items: center; @@ -541,13 +546,9 @@ width: var(--pn-btn); height: var(--pn-btn); padding: 0; - border: 0; border-radius: var(--radius-s); - background: transparent; - box-shadow: none; color: var(--icon-color); opacity: var(--icon-opacity); - cursor: var(--cursor); } .pn-iconbtn:hover { @@ -574,11 +575,8 @@ gap: var(--size-4-2); height: var(--input-height); padding: 0 var(--size-4-3); - border: 0; border-radius: var(--button-radius); - font-size: var(--font-ui-small); white-space: nowrap; - cursor: var(--cursor); } .pn-btn.mod-cta { @@ -609,15 +607,11 @@ height: auto; min-height: var(--pn-row); padding: var(--size-4-1) var(--size-4-2); - border: 0; border-radius: var(--radius-s); - background: none; - box-shadow: none; color: var(--nav-item-color); font-size: var(--nav-item-size); font-weight: inherit; text-align: left; - cursor: var(--cursor); } .pn-navitem:hover { @@ -701,19 +695,7 @@ --icon-size: 18px; } - .pn-iconbtn:focus-visible, - .pn-btn:focus-visible, - .pn-ep-main:focus-visible, - .pn-gridcard:focus-visible, - .pn-navitem:focus-visible, - .pn-plrow:focus-visible, - .pn-back:focus-visible, - .pn-alt:focus-visible, - .pn-result:focus-visible, - .pn-railsearch:focus-visible, - .pn-mini-open:focus-visible, - .pn-textbtn:focus-visible, - .pn-player button:focus-visible { + button:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 1px; box-shadow: none; diff --git a/src/ui/PodcastView/QueueList.svelte b/src/ui/PodcastView/QueueList.svelte index 7b46f207..209ae5fb 100644 --- a/src/ui/PodcastView/QueueList.svelte +++ b/src/ui/PodcastView/QueueList.svelte @@ -119,7 +119,7 @@ {#if $queue.episodes.length > 0} -
+
{#each shown as episode, index (getEpisodeKey(episode))} - navigation.go({ kind: "queue" })}>Edit {/snippet} @@ -31,10 +33,7 @@ height: auto; padding: var(--size-2-2) var(--size-4-2); border-radius: var(--radius-s); - background: none; - box-shadow: none; color: var(--text-accent); - font-size: var(--font-ui-small); font-weight: var(--font-medium); } diff --git a/src/ui/PodcastView/episodeRow.test.ts b/src/ui/PodcastView/episodeRow.test.ts index f172b294..c92d1243 100644 --- a/src/ui/PodcastView/episodeRow.test.ts +++ b/src/ui/PodcastView/episodeRow.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "vitest"; +import type { PlayerStatus } from "src/player/PlayerController"; import type { Episode } from "src/types/Episode"; import type { LocalEpisode } from "src/types/LocalEpisode"; import { availabilityOf, episodeRowModel, formatMinutes, + isPlaying, rowKeys, secondsLeft, totalSeconds, @@ -44,6 +46,21 @@ const record = (time: number, duration: number, finished = false) => ({ const row = (changes: Partial = {}, of: Episode = episode) => episodeRowModel(of, { ...state, ...changes }); +test("counts a loading episode as playing, and no other status", () => { + const playing: Record = { + empty: false, + restored: false, + loading: true, + playing: true, + paused: false, + ended: false, + error: false, + }; + + for (const [status, expected] of Object.entries(playing)) + expect(isPlaying(status as PlayerStatus), status).toBe(expected); +}); + describe("an episode row", () => { test("shows the date and the duration of an episode not yet played", () => { expect(row()).toMatchObject({ diff --git a/src/ui/PodcastView/episodeRow.ts b/src/ui/PodcastView/episodeRow.ts index da69eda5..a85da163 100644 --- a/src/ui/PodcastView/episodeRow.ts +++ b/src/ui/PodcastView/episodeRow.ts @@ -1,3 +1,4 @@ +import type { PlayerStatus } from "src/player/PlayerController"; import { downloadedCopy, offlinePlayable } from "src/player/source"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import type { Episode } from "src/types/Episode"; @@ -7,6 +8,11 @@ import { getPlayedEpisode, type PlayedEpisodeMap } from "src/utility/episodeStat import { isLocalFile } from "src/utility/isLocalFile"; import { getEpisodeMediaType, isSameMediaSource } from "src/utility/mediaType"; +/** A loading episode plays once ready, so it already shows Pause. */ +export function isPlaying(status: PlayerStatus): boolean { + return status === "playing" || status === "loading"; +} + export type Downloads = Readonly>; /** diff --git a/tests/e2e/journey-player.test.ts b/tests/e2e/journey-player.test.ts index f00cad7f..cc2f9e1a 100644 --- a/tests/e2e/journey-player.test.ts +++ b/tests/e2e/journey-player.test.ts @@ -114,7 +114,7 @@ async function expectVideoFrame(when: string, minWidth: number): Promise { /** The titles in the player's Up next panel, in order. */ async function upNext(): Promise { return await getContext().obsidian.dev.evalJson( - `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-player .pn-queue .pn-ep-title`)})].map((el) => el.textContent.trim())`, + `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-player [role="list"][aria-label="Queue"] .pn-ep-title`)})].map((el) => el.textContent.trim())`, ); } diff --git a/tests/e2e/journey/targets.ts b/tests/e2e/journey/targets.ts index 349c9cc0..21f5c648 100644 --- a/tests/e2e/journey/targets.ts +++ b/tests/e2e/journey/targets.ts @@ -18,6 +18,7 @@ function byAttribute(name: string, selector: string, attribute: string, value: s }; } +const QUEUE = '[role="list"][aria-label="Queue"]'; export const PLAYER_VIEW = `.workspace-leaf-content[data-type="${VIEW_TYPE}"] .podcast-view`; function navItem(name: string, nav: string, label: string): UiTarget { @@ -194,11 +195,11 @@ export const ui = { offlineStrip: byCss("Offline strip", `${PLAYER_VIEW} .pn-strip[role="status"]`, "Offline"), queueRow: (title: string): UiTarget => ({ name: `queue row ${title}`, - elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-queue .pn-ep`)})].filter((el) => el.querySelector(".pn-ep-title")?.textContent.trim() === ${jsLiteral(title)})`, + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} ${QUEUE} .pn-ep`)})].filter((el) => el.querySelector(".pn-ep-title")?.textContent.trim() === ${jsLiteral(title)})`, }), queueGrip: (title: string): UiTarget => ({ name: `grip of ${title}`, - elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-queue button.pn-grip`)})].filter((el) => el.getAttribute("aria-label").startsWith(${jsLiteral(`Reorder ${title}, position `)}))`, + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} ${QUEUE} button.pn-grip`)})].filter((el) => el.getAttribute("aria-label").startsWith(${jsLiteral(`Reorder ${title}, position `)}))`, }), playlistRow: (name: string): UiTarget => ({ name: `playlist row ${name}`, diff --git a/tests/tooling/componentStyles.test.ts b/tests/tooling/componentStyles.test.ts new file mode 100644 index 00000000..1252d762 --- /dev/null +++ b/tests/tooling/componentStyles.test.ts @@ -0,0 +1,93 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { type AST, parse } from "svelte/compiler"; +import { describe, expect, test } from "vitest"; + +type Sources = Readonly>; +type Selector = AST.CSS.SimpleSelector | AST.CSS.RelativeSelector; + +const UI = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../src/ui"); + +function components(): Sources { + return Object.fromEntries( + readdirSync(UI, { recursive: true, encoding: "utf8" }) + .filter((file) => file.endsWith(".svelte")) + .map((file) => [file, readFileSync(path.join(UI, file), "utf8")]), + ); +} + +/** + * The view's classes a component's sheet scopes to itself. A class under `:global(...)`, + * or after a bare `:global`, is a reference to another component's element instead. + */ +function ownedClasses(file: string, source: string): Set { + const owned = new Set(); + const visitList = (list: AST.CSS.SelectorList, global: boolean): boolean => { + let opensGlobal = false; + for (const complex of list.children) { + let inGlobal = global; + for (const relative of complex.children) { + for (const simple of relative.selectors as Selector[]) { + if ( + simple.type === "ClassSelector" && + !inGlobal && + /^(pn-|podcast-view$)/.test(simple.name) + ) + owned.add(simple.name); + if (simple.type !== "PseudoClassSelector") continue; + if (simple.name === "global" && !simple.args) inGlobal = opensGlobal = true; + else if (simple.args) + visitList(simple.args, inGlobal || simple.name === "global"); + } + } + } + return opensGlobal; + }; + const visitBlock = (nodes: AST.CSS.Block["children"], global: boolean) => { + for (const node of nodes) { + if (node.type === "Rule") + visitBlock(node.block.children, visitList(node.prelude, global) || global); + else if (node.type === "Atrule" && node.block) visitBlock(node.block.children, global); + } + }; + const ast = parse(source, { filename: file, modern: true }); + if (ast.css) visitBlock(ast.css.children, false); + return owned; +} + +/** Classes that more than one component scopes to itself, with those components. */ +function sharedOwners(sources: Sources): Record { + const owners = new Map(); + for (const [file, source] of Object.entries(sources)) { + for (const name of ownedClasses(file, source)) + owners.set(name, [...(owners.get(name) ?? []), file]); + } + return Object.fromEntries([...owners].filter(([, files]) => files.length > 1)); +} + +describe("component style sheets", () => { + test("each of the view's classes is scoped by one component at most", () => { + expect(sharedOwners(components())).toEqual({}); + }); + + test("finds a class two components scope, also inside a media query or :is()", () => { + expect( + sharedOwners({ + "A.svelte": `
`, + "B.svelte": ``, + "C.svelte": ``, + }), + ).toEqual({ "pn-a": ["A.svelte", "B.svelte"], "pn-b": ["B.svelte", "C.svelte"] }); + }); + + test("lets other components refer to a class through :global", () => { + expect( + sharedOwners({ + "A.svelte": `
`, + "B.svelte": `
`, + "C.svelte": `
`, + }), + ).toEqual({}); + }); +}); From 6741da5d5d2138c6820937bbffa1a7d7eb5a4e92 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 00:05:54 +0200 Subject: [PATCH 03/39] fix(ui): polish fallback covers, the address banner, disabled controls and tooltips - A cover without artwork shows the first three lines of its name, at 10 px or more, so a long name keeps its beginning on a row's 40 px cover. - The feed banner keeps monospace for a host only; "Address not on this device" wraps in the UI font instead of truncating at 320 px. - Every disabled button uses Obsidian's 0.7 opacity: the 0.35 and 0.45 overrides go, and an unavailable row dims through its aria-disabled. - The mini player draws its cover with the rows' Cover and tileOf, so an episode without artwork shows its podcast's name as rows do. - No control has both an aria-label, which Obsidian shows as a tooltip, and a title; the tooling test enforces it. - The transcript's Follow chip no longer appears when a resize clamps the list's scroll position, and Find folds a dotted capital I without moving its marks. --- src/ui/PodcastView/Cover.svelte | 10 +- src/ui/PodcastView/Deck.svelte | 8 - src/ui/PodcastView/EpisodeRow.svelte | 12 +- src/ui/PodcastView/EpisodesPage.svelte | 16 +- src/ui/PodcastView/EpisodesPage.test.ts | 4 +- src/ui/PodcastView/LibraryPage.svelte | 1 - src/ui/PodcastView/ListPage.test.ts | 1 - src/ui/PodcastView/MiniPlayer.svelte | 31 +--- src/ui/PodcastView/Player.svelte | 7 - src/ui/PodcastView/PodcastView.shell.test.ts | 24 +++ src/ui/PodcastView/PodcastView.svelte | 12 +- src/ui/PodcastView/QueueList.test.ts | 1 - src/ui/PodcastView/QueueRow.svelte | 2 - src/ui/PodcastView/SearchField.svelte | 1 - src/ui/PodcastView/TranscriptPanel.svelte | 21 ++- src/ui/PodcastView/TranscriptPanel.test.ts | 63 ++++++-- src/ui/PodcastView/episodeRow.ts | 13 +- src/ui/PodcastView/index.notes.test.ts | 4 +- src/ui/PodcastView/transcript.ts | 8 + tests/e2e/view-polish.test.ts | 151 +++++++++++++++++++ tests/tooling/componentStyles.test.ts | 40 +++++ 21 files changed, 325 insertions(+), 105 deletions(-) create mode 100644 tests/e2e/view-polish.test.ts diff --git a/src/ui/PodcastView/Cover.svelte b/src/ui/PodcastView/Cover.svelte index f98f7249..6816ae70 100644 --- a/src/ui/PodcastView/Cover.svelte +++ b/src/ui/PodcastView/Cover.svelte @@ -52,9 +52,15 @@ container-type: inline-size; } - /* cqw is the cover's width. A percentage padding on the cover is its row's. */ + /* cqw is the cover's width. A percentage padding on the cover is its row's. Three + lines keep a long title's beginning, at a size a row's small cover can show. */ .pn-cover.is-text > :global(span) { + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; padding: 10cqw; - font-size: 12cqw; + font-size: max(10px, 12cqw); } diff --git a/src/ui/PodcastView/Deck.svelte b/src/ui/PodcastView/Deck.svelte index 9db28b86..d4a26177 100644 --- a/src/ui/PodcastView/Deck.svelte +++ b/src/ui/PodcastView/Deck.svelte @@ -138,7 +138,6 @@ type="button" class="pn-bare pn-skip" aria-label="Back {back} seconds" - title="Back {back} seconds" disabled={failed} onclick={() => api.skipBackward()} > @@ -148,7 +147,6 @@ type="button" class="pn-play" aria-label={playing ? "Pause" : "Play"} - title={playing ? "Pause" : "Play"} disabled={failed} onclick={() => player.toggle()} {@attach icon(playing ? "pause" : "play")} @@ -157,7 +155,6 @@ type="button" class="pn-bare pn-skip" aria-label="Forward {forward} seconds" - title="Forward {forward} seconds" disabled={failed} onclick={() => api.skipForward()} > @@ -168,7 +165,6 @@ type="button" class="pn-bare pn-vol" aria-label="Volume {Math.round($playerState.volume * 100)}%" - title="Volume" aria-expanded={volumeOpen} disabled={failed} bind:this={volumeButton} @@ -378,10 +374,6 @@ --icon-size: 20px; } - :is(.pn-rate, .pn-skip, .pn-vol, .pn-play):disabled { - opacity: 0.35; - } - .pn-pop { position: absolute; right: 0; diff --git a/src/ui/PodcastView/EpisodeRow.svelte b/src/ui/PodcastView/EpisodeRow.svelte index b3d2f2c3..9d9787fa 100644 --- a/src/ui/PodcastView/EpisodeRow.svelte +++ b/src/ui/PodcastView/EpisodeRow.svelte @@ -63,7 +63,6 @@ class="pn-ep" class:is-played={row.played} class:is-current={row.current} - class:is-unavailable={row.unavailable} class:is-queue={queue} class:is-dragging={dragging} role="listitem" @@ -74,7 +73,6 @@ class="pn-bare pn-ep-main" aria-label={row.label} aria-disabled={row.unavailable || undefined} - title={episode.title} onclick={onplay} oncontextmenu={menu} > @@ -102,9 +100,9 @@ {#if row.unavailable} {:else if row.downloaded} - + {/if} - {#if row.hasNote}{/if} + {#if row.hasNote}{/if} @@ -115,7 +113,6 @@ type="button" class="pn-iconbtn pn-ep-more" aria-label="More options for {episode.title}" - title="More options" aria-haspopup="menu" onclick={menu} oncontextmenu={menu} @@ -300,11 +297,6 @@ color: var(--text-accent); } - .pn-ep.is-unavailable :global(.pn-ep-main) { - opacity: 0.45; - cursor: default; - } - @keyframes pn-eq { 0%, 100% { diff --git a/src/ui/PodcastView/EpisodesPage.svelte b/src/ui/PodcastView/EpisodesPage.svelte index c38547cc..ca4793fa 100644 --- a/src/ui/PodcastView/EpisodesPage.svelte +++ b/src/ui/PodcastView/EpisodesPage.svelte @@ -48,10 +48,10 @@ const refresh = () => void feeds.load([key], "network"); /** Which server failed, without the address: a private feed's address is a secret. */ - function failedSource({ error, feed }: FeedState): string { - if (error === "unavailable") return "Address not on this device"; - if (isPrivateFeedPlaceholder(feed.url)) return "Private feed"; - return checkStringIsUrl(feed.url)?.host ?? ""; + function failedSource({ error, feed }: FeedState): { text: string; host?: boolean } { + if (error === "unavailable") return { text: "Address not on this device" }; + if (isPrivateFeedPlaceholder(feed.url)) return { text: "Private feed" }; + return { text: checkStringIsUrl(feed.url)?.host ?? "", host: true }; } @@ -66,11 +66,12 @@
{#if error} + {@const source = failedSource(feedState)} '; - return getComputedStyle(document.querySelector(".pn-play")!).transition; -} - -test("the play button presses without a transition when the user prefers reduced motion", () => { - expect([playTransition(false), playTransition(true)]).toEqual(["transform 120ms ease", "none"]); -}); diff --git a/src/ui/PodcastView/styles.test.ts b/src/ui/PodcastView/styles.test.ts new file mode 100644 index 00000000..09e52cfa --- /dev/null +++ b/src/ui/PodcastView/styles.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { describe, expect, test } from "vitest"; +import { componentCss, styled } from "../../../tests/mocks/componentCss"; + +const REDUCE = "(prefers-reduced-motion: reduce)"; + +/** `html` in the view, with `{}` standing for the component's own scoping class. */ +const inView = (component: string, html: string) => + `
${html.replaceAll("{}", `pn-${component}`)}
`; + +describe("reduced motion", () => { + test.each([ + [ + "the play button", + "Deck", + '
', + ".pn-play", + ["transform 120ms ease", ""], + ], + [ + "a podcast card", + "LibraryPage", + '
', + ".pn-gridcard", + ["transform 120ms ease", ""], + ], + [ + "a playing row's bars", + "EpisodeRow", + '
', + ".pn-eq i", + ["", "pn-EpisodeRow-pn-eq 1s ease-in-out infinite"], + ], + ])( + "%s stops moving when the user prefers reduced motion", + (_name, component, html, selector, moving) => { + const motion = (media: string[]) => { + const css = componentCss(["PodcastView", component], { media }); + const { transition, animation } = getComputedStyle( + styled(css, inView(component, html), selector), + ); + return [transition, animation]; + }; + + expect([motion([]), motion([REDUCE])]).toEqual([moving, ["none", "none"]]); + }, + ); +}); + +describe("focus ring", () => { + test("draws the video's play button ring inside the frame that clips it, and others outside", () => { + const css = componentCss(["PodcastView", "Player"], { states: ["focus-visible"] }); + styled( + css, + inView( + "Player", + '
', + ), + ".pn-vplay", + ); + const ring = (selector: string) => { + const { outline, outlineOffset } = getComputedStyle(document.querySelector(selector)!); + return [outline, outlineOffset]; + }; + + expect([ring(".pn-vplay"), ring(".pn-iconbtn")]).toEqual([ + ["2px solid var(--interactive-accent)", "-2px"], + ["2px solid var(--interactive-accent)", "1px"], + ]); + }); +}); diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index f86eaf01..6aa198dc 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -191,6 +191,48 @@ describe("sections", () => { ]); }); + test("each field is named after its row, without an aria-label Obsidian would show as a tooltip", () => { + const { tab } = open(); + const fields = [ + ...tab.containerEl.querySelectorAll( + ".setting-item-control :is(input, select, textarea)", + ), + ]; + const rowOf = (field: Element) => + field.closest(".setting-item")?.querySelector(".setting-item-name")?.textContent; + + expect(fields.map(rowOf)).toEqual([ + "Skip back (s)", + "Skip forward (s)", + "Default speed", + "Queue episodes I switch away from", + "Previous track", + "Next track", + "Timestamp template", + "Timestamp offset (s)", + "Episode note path", + "Episode note template", + "Podcast note path", + "Podcast note template", + "OpenAI API key", + "Transcript path", + "Transcript template", + "Speaker diarization", + "Diarization provider", + "Deepgram API key", + "Speaker label format", + "Download path", + "Export OPML", + "Latest episodes per podcast", + "Include API keys", + "Export settings", + ]); + for (const field of fields) { + expect(field).toHaveAccessibleName(rowOf(field)); + expect(field).not.toHaveAttribute("aria-label"); + } + }); + test("each row renders under its own name, and the speaker rows apply only with diarization", () => { const { row, toggle, select } = open(); const disabled = (name: string) => row(name).classList.contains("is-disabled"); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index f0a4f3e0..a1fce512 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -87,10 +87,26 @@ const TYPING_SAVE_DELAY_MS = 500; /** Search finds rows, not section headings. */ const HEADSET_ALIASES = ["headset", "lock screen", "media keys"]; +let settingNames = 0; + +/** + * Obsidian names no row's field after the row, and shows an aria-label as a tooltip, so + * each field is labelled by the row's name. + */ const group = (heading: string, items: SettingDefinitionRender[]): SettingDefinitionGroup => ({ type: "group", heading, - items, + items: items.map((item) => ({ + ...item, + render: (setting, settingGroup) => { + const end = item.render(setting, settingGroup); + const { nameEl, controlEl } = setting; + if (!nameEl.id) nameEl.id = `podnotes-setting-${++settingNames}`; + for (const field of controlEl.querySelectorAll("input, select, textarea")) + field.setAttribute("aria-labelledby", nameEl.id); + return end; + }, + })), }); function isPlainRecord(value: unknown): value is Record { diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 26d888a0..706a4c9e 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -369,7 +369,7 @@ describe("transcript journeys", () => { ); }, 120_000); - test("on a phone, Save, the Find field, Follow and Up next's Edit are 44 px tall", async () => { + test("on a phone, Save, the Find field, Follow and Up next's Edit are 44 px tall, and Edit 44 px wide", async () => { const context = getContext(); const { obsidian } = context; const phone = { width: 390, height: 844 }; @@ -421,7 +421,11 @@ describe("transcript journeys", () => { await click(obsidian, ui.playerPanel("Up next")); await waitUntilShown(obsidian, panelButton("Edit")); heights.edit = await height(panelButton("Edit")); + const editWidth = await obsidian.dev.evalJson( + `(${panelButton("Edit").elements})[0].getBoundingClientRect().width`, + ); expect(heights).toEqual({ save: 44, follow: 44, find: 44, edit: 44 }); + expect(editWidth).toBeGreaterThanOrEqual(44); }, 120_000); }); diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index 1019bdfc..4f18f1e0 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -65,6 +65,43 @@ async function settingValue(obsidian: ObsidianClient, name: string): Promise { + test("names each field after its row", async () => { + const { obsidian } = getContext(); + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + const fields = await obsidian.dev.evalJson>(`(() => + [...document.querySelectorAll(".modal.mod-settings .setting-item-control :is(input, select, textarea):not([aria-hidden])")].map((field) => ({ + row: field.closest(".setting-item").querySelector(".setting-item-name").textContent, + name: field.getAttribute("aria-label") ?? document.getElementById(field.getAttribute("aria-labelledby"))?.textContent ?? "", + })))()`); + + expect(fields.map(({ row }) => row)).toEqual([ + "Skip back (s)", + "Skip forward (s)", + "Default speed", + "Queue episodes I switch away from", + "Previous track", + "Next track", + "Timestamp template", + "Timestamp offset (s)", + "Episode note path", + "Episode note template", + "Podcast note path", + "Podcast note template", + "Transcript path", + "Transcript template", + "Speaker diarization", + "Diarization provider", + "Speaker label format", + "Download path", + "Export OPML", + "Latest episodes per podcast", + "Include API keys", + "Export settings", + ]); + expect(fields.filter(({ row, name }) => name !== row)).toEqual([]); + }); + test("search finds a row of each section by name, and none of the removed rows", async () => { const { obsidian } = getContext(); await openPodNotesSettings(obsidian); diff --git a/tests/mocks/componentCss.ts b/tests/mocks/componentCss.ts new file mode 100644 index 00000000..440ceeda --- /dev/null +++ b/tests/mocks/componentCss.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import postcss, { type AtRule, type ChildNode, type Container } from "postcss"; +import { compile } from "svelte/compiler"; + +const COMPONENTS = path.resolve(import.meta.dirname, "../../src/ui/PodcastView"); + +/** + * The CSS of the view's components as jsdom cascades it. Each component is scoped by the + * class `pn-`, so markup takes that class where its own sheet scopes an element. + * Nesting is flattened, which jsdom lacks, and sheets keep the order given: shell first, as + * it mounts first. jsdom applies no media query but `all`, so `media` names the queries that + * match, as the user's device or setting would make them. `states` turns pseudo-classes such + * as `:focus-visible` into classes a test can set. + */ +export function componentCss( + files: readonly string[], + { media = [], states = [] }: { media?: string[]; states?: string[] } = {}, +): string { + const out = postcss.root(); + const visit = (node: ChildNode, selectors: string[] | null, within: Container) => { + if (node.type === "rule") { + const own = selectors + ? selectors.flatMap((outer) => + node.selectors.map((inner) => + inner.includes("&") + ? inner.replaceAll("&", outer) + : `${outer} ${inner}`, + ), + ) + : node.selectors; + const declarations = node.nodes.filter((child) => child.type === "decl"); + if (declarations.length > 0) + within.append( + postcss.rule({ selectors: own, nodes: declarations.map((d) => d.clone()) }), + ); + for (const child of node.nodes) if (child.type !== "decl") visit(child, own, within); + } else if (node.type === "atrule" && node.name === "media") { + const query = media.includes(node.params) ? "all" : node.params; + const copy = postcss.atRule({ name: "media", params: query }); + within.append(copy); + for (const child of (node as AtRule).nodes ?? []) visit(child, selectors, copy); + } else if (node.type === "atrule") within.append(node.clone()); + }; + for (const file of files) { + const source = readFileSync(path.join(COMPONENTS, `${file}.svelte`), "utf8"); + const { css } = compile(source, { css: "external", cssHash: () => `pn-${file}` }); + postcss.parse(css?.code ?? "").each((node) => visit(node, null, out)); + } + let text = out.toString(); + for (const state of states) text = text.replaceAll(`:${state}`, `.${state}`); + return text; +} + +/** Puts `css` and `html` in the test document, and returns the element `selector` finds. */ +export function styled(css: string, html: string, selector: string): HTMLElement { + const style = document.createElement("style"); + style.textContent = css; + document.head.replaceChildren(style); + document.body.innerHTML = html; + return document.querySelector(selector)!; +} From f24fbace9d5580447f3e9ee756948c5927e52f9d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 00:37:54 +0200 Subject: [PATCH 05/39] fix(ui): give the phone player room, and fix hover and menu Escape on phones - On a phone the deck keeps the desktop's 12 px gaps and 8 px top padding, so the stage, and the artwork that fills its height, grow by 20 px under Obsidian's header and navbar. A panel already fills the stage. - Hover on the shell's controls, rows and the deck applies only on a device that can hover, so a tapped control does not stay lit on a phone. - A menu's closing Escape is handled, and focus returns to its control, as the menu starts to hide; on a phone the hide callback runs 150 ms later, when the Escape is gone. --- src/ui/PodcastView/Deck.svelte | 32 +++++++------- src/ui/PodcastView/EpisodeRow.svelte | 14 ++++--- src/ui/PodcastView/Panel.svelte | 8 ++-- src/ui/PodcastView/PlaylistsPage.svelte | 6 ++- src/ui/PodcastView/PodcastView.svelte | 37 ++++++++-------- src/ui/PodcastView/QueueRow.svelte | 7 +++- src/ui/PodcastView/menus.test.ts | 36 ++++++++++++++++ src/ui/PodcastView/menus.ts | 24 ++++++----- src/ui/PodcastView/styles.test.ts | 51 ++++++++++++++++++++++ tests/e2e/journey-transcript.test.ts | 56 ++++++++++++++++++++++++- tests/e2e/journey/actions.ts | 29 +++++++++++++ tests/mocks/obsidian.ts | 5 ++- 12 files changed, 246 insertions(+), 59 deletions(-) diff --git a/src/ui/PodcastView/Deck.svelte b/src/ui/PodcastView/Deck.svelte index 73f545e1..1982a4fd 100644 --- a/src/ui/PodcastView/Deck.svelte +++ b/src/ui/PodcastView/Deck.svelte @@ -248,10 +248,21 @@ vertical-align: top; } - button.pn-np-pod:hover, - .pn-chapnow:hover { - background: var(--background-modifier-hover); - color: var(--text-normal); + @media (hover: hover) { + button.pn-np-pod:hover, + .pn-chapnow:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); + } + + .pn-rate:hover:not(:disabled) span { + border-color: var(--background-modifier-border-focus); + color: var(--text-normal); + } + + :is(.pn-skip, .pn-vol):hover { + background: var(--background-modifier-hover); + } } .pn-scrubwrap { @@ -310,11 +321,6 @@ font-variant-numeric: tabular-nums; } - .pn-rate:hover:not(:disabled) span { - border-color: var(--background-modifier-border-focus); - color: var(--text-normal); - } - :is(.pn-skip, .pn-vol) { position: relative; width: var(--pn-skip); @@ -322,10 +328,6 @@ border-radius: 50%; } - :is(.pn-skip, .pn-vol):hover { - background: var(--background-modifier-hover); - } - .pn-skip { --icon-size: 26px; --icon-stroke: 1.6px; @@ -425,9 +427,9 @@ } } + /* Obsidian's header and navbar take a phone's height, so the deck gives the stage room. */ :global(.is-phone) .pn-deck { - gap: var(--size-4-4); - padding-bottom: var(--size-4-2); + padding-block: var(--size-4-2); } :global(.is-phone) .pn-deck :global { diff --git a/src/ui/PodcastView/EpisodeRow.svelte b/src/ui/PodcastView/EpisodeRow.svelte index 58f3b661..b196e2e3 100644 --- a/src/ui/PodcastView/EpisodeRow.svelte +++ b/src/ui/PodcastView/EpisodeRow.svelte @@ -128,8 +128,14 @@ border-radius: var(--radius-s); } - .pn-ep:hover { - background: var(--background-modifier-hover); + @media (hover: hover) { + .pn-ep:hover { + background: var(--background-modifier-hover); + } + + .pn-ep:hover :global(button.pn-ep-more) { + opacity: 1; + } } .pn-ep.is-queue { @@ -284,10 +290,6 @@ } } - .pn-ep:hover :global(button.pn-ep-more) { - opacity: 1; - } - .pn-ep.is-played :global(.pn-ep-title) { color: var(--text-muted); font-weight: var(--font-normal); diff --git a/src/ui/PodcastView/Panel.svelte b/src/ui/PodcastView/Panel.svelte index 9f56bda9..5b6be593 100644 --- a/src/ui/PodcastView/Panel.svelte +++ b/src/ui/PodcastView/Panel.svelte @@ -96,9 +96,11 @@ white-space: normal; } - button.pn-row:not(.is-now):hover { - background: var(--background-modifier-hover); - color: var(--text-normal); + @media (hover: hover) { + button.pn-row:not(.is-now):hover { + background: var(--background-modifier-hover); + color: var(--text-normal); + } } .pn-row.is-now { diff --git a/src/ui/PodcastView/PlaylistsPage.svelte b/src/ui/PodcastView/PlaylistsPage.svelte index 6c17a8f2..87b13f36 100644 --- a/src/ui/PodcastView/PlaylistsPage.svelte +++ b/src/ui/PodcastView/PlaylistsPage.svelte @@ -65,8 +65,10 @@ text-align: left; } - .pn-plrow:hover { - background: var(--background-modifier-hover); + @media (hover: hover) { + .pn-plrow:hover { + background: var(--background-modifier-hover); + } } .pn-plicon { diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index c70d3ad6..d9fc938f 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -423,11 +423,6 @@ color: var(--text-muted); } - .pn-back:hover { - background: var(--background-modifier-hover); - color: var(--text-normal); - } - .pn-back-icon { display: flex; flex: none; @@ -550,10 +545,25 @@ opacity: var(--icon-opacity); } - .pn-iconbtn:hover { - background: var(--background-modifier-hover); - color: var(--text-normal); - opacity: var(--icon-opacity-hover); + /* A touch screen keeps a tapped control hovered until the next tap elsewhere. */ + @media (hover: hover) { + :is(.pn-back, .pn-iconbtn):hover { + background: var(--background-modifier-hover); + color: var(--text-normal); + } + + .pn-iconbtn:hover { + opacity: var(--icon-opacity-hover); + } + + .pn-btn.mod-cta:hover { + background: var(--interactive-accent-hover); + } + + .pn-navitem:hover { + background: var(--nav-item-background-hover); + color: var(--nav-item-color-hover); + } } .pn-iconbtn:disabled { @@ -583,10 +593,6 @@ box-shadow: none; } - .pn-btn.mod-cta:hover { - background: var(--interactive-accent-hover); - } - .pn-nav { display: flex; flex-direction: column; @@ -612,11 +618,6 @@ text-align: left; } - .pn-navitem:hover { - background: var(--nav-item-background-hover); - color: var(--nav-item-color-hover); - } - .pn-navitem.is-active { background: var(--background-modifier-border); color: var(--nav-item-color-active); diff --git a/src/ui/PodcastView/QueueRow.svelte b/src/ui/PodcastView/QueueRow.svelte index d044ee57..5c5e8e12 100644 --- a/src/ui/PodcastView/QueueRow.svelte +++ b/src/ui/PodcastView/QueueRow.svelte @@ -90,8 +90,13 @@ opacity: 0.55; } - :global(.pn-ep:hover) button.pn-ep-x, button.pn-iconbtn.pn-ep-x:focus-visible { opacity: 1; } + + @media (hover: hover) { + :global(.pn-ep:hover) button.pn-ep-x { + opacity: 1; + } + } diff --git a/src/ui/PodcastView/menus.test.ts b/src/ui/PodcastView/menus.test.ts index d1a44a53..411babe1 100644 --- a/src/ui/PodcastView/menus.test.ts +++ b/src/ui/PodcastView/menus.test.ts @@ -623,6 +623,42 @@ describe("a menu", () => { expect(escape(), "an Escape once the menu is gone").toBe(false); }); + test("on a phone, where it hides after 150 ms, the Escape that closes it is handled and focus is back at once", () => { + const menus = obsidian.Menu as unknown as typeof FakeMenu; + vi.useFakeTimers(); + menus.hideDelayMs = 150; + onTestFinished(() => { + menus.hideDelayMs = 0; + vi.useRealTimers(); + }); + const keymap = (event: KeyboardEvent) => { + if (event.key === "Escape") menus.shown?.hide(); + }; + window.addEventListener("keydown", keymap, true); + onTestFinished(() => window.removeEventListener("keydown", keymap, true)); + const escape = () => { + const event = new KeyboardEvent("keydown", { key: "Escape", cancelable: true }); + document.body.dispatchEvent(event); + return event.defaultPrevented; + }; + const note = document.body.appendChild(document.createElement("textarea")); + onTestFinished(() => note.remove()); + + showMenu([{ title: "Item", onClick: () => {} }], at); + note.focus(); + expect(escape(), "the Escape that closes the menu").toBe(true); + expect(trigger, "focus back before the animation ends").toHaveFocus(); + note.focus(); + vi.advanceTimersByTime(150); + expect(note, "the animation's end moves no focus").toHaveFocus(); + expect(escape(), "an Escape once the menu is gone").toBe(false); + + showMenu([{ title: "Open", onClick: () => note.focus() }], at); + menus.shown!.pick("Open"); + vi.advanceTimersByTime(150); + expect(note, "a picked item keeps the focus it set").toHaveFocus(); + }); + describe("gives focus back to the control that opened it", () => { const shown = () => (obsidian.Menu as unknown as typeof FakeMenu).shown!; let note: HTMLTextAreaElement; diff --git a/src/ui/PodcastView/menus.ts b/src/ui/PodcastView/menus.ts index b5395b75..7425982f 100644 --- a/src/ui/PodcastView/menus.ts +++ b/src/ui/PodcastView/menus.ts @@ -58,8 +58,20 @@ export function anchorOf(event: MouseEvent): MenuAnchor { * keeps the focus it sets. Obsidian runs an item before it hides the menu. */ export function showMenu(entries: readonly MenuEntry[], at: MenuAnchor): void { - const menu = new Menu(); let picked = false; + const menu = new (class extends Menu { + // Obsidian's menu passes on the Escape that closes it, and Obsidian sends focus from a + // sidebar to the main area on an Escape nothing handled. This one counts as handled. + // On a phone the menu hides after a 150 ms animation, when the event is long gone, so + // this runs as the hide starts, inside that keydown. + override hide() { + const closing = at.el.win.event; + if (closing?.type === "keydown" && "key" in closing && closing.key === "Escape") + closing.preventDefault(); + if (!picked && at.el.isConnected) at.el.focus(); + return super.hide(); + } + })(); for (const entry of entries) { if (entry === "separator") { menu.addSeparator(); @@ -77,16 +89,6 @@ export function showMenu(entries: readonly MenuEntry[], at: MenuAnchor): void { if (entry.disabled) item.setDisabled(true); }); } - menu.onHide(() => { - // Obsidian's menu passes on the Escape that closes it, and Obsidian sends focus from - // a sidebar to the main area on an Escape nothing handled. This one counts as handled. - // It works because the menu hides synchronously inside that keydown. On a phone the - // hide is animated, so the event is gone by then and this does nothing. - const closing = at.el.win.event; - if (closing?.type === "keydown" && "key" in closing && closing.key === "Escape") - closing.preventDefault(); - if (!picked && at.el.isConnected) at.el.focus(); - }); menu.showAtPosition(at, at.doc); } diff --git a/src/ui/PodcastView/styles.test.ts b/src/ui/PodcastView/styles.test.ts index 09e52cfa..26b84343 100644 --- a/src/ui/PodcastView/styles.test.ts +++ b/src/ui/PodcastView/styles.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import postcss from "postcss"; import { describe, expect, test } from "vitest"; import { componentCss, styled } from "../../../tests/mocks/componentCss"; @@ -69,3 +70,53 @@ describe("focus ring", () => { ]); }); }); + +describe("hover", () => { + const TOUCHED = ["PodcastView", "EpisodeRow", "QueueRow", "Panel", "PlaylistsPage", "Deck"]; + const HOVER = "(hover: hover)"; + + test("only a device that can hover lights up the view's controls and rows", () => { + const unguarded: string[] = []; + postcss.parse(componentCss(TOUCHED)).walkRules((rule) => { + const media = rule.parent?.type === "atrule" ? rule.parent : undefined; + if (rule.selector.includes(":hover") && media?.params !== HOVER) + unguarded.push(rule.selector); + }); + + expect(unguarded).toEqual([]); + }); + + // % in each markup is where a hovered element takes the `hover` class. + test.each([ + [ + "Back", + "PodcastView", + '', + ".pn-back", + "rgba(0, 0, 0, 0)", + ], + ["a row", "EpisodeRow", '
', ".pn-ep", ""], + [ + "the skip button", + "Deck", + '
', + ".pn-skip", + "", + ], + ])( + "%s keeps its resting background after a tap", + (_name, component, html, selector, resting) => { + const background = (hovered: boolean, media: string[] = []) => { + const css = componentCss(["PodcastView", component], { media, states: ["hover"] }); + const markup = inView(component, html.replace("%", hovered ? " hover" : "")); + return getComputedStyle(styled(css, markup, selector)).background; + }; + + expect([background(false), background(true), background(true, [HOVER])]).toEqual([ + resting, + resting, + "var(--background-modifier-hover)", + ]); + }, + ); +}); diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 706a4c9e..66f26a6a 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -4,6 +4,7 @@ import { createPodNotesE2EHarness } from "./harness"; import { dismissNotices, runCommand, + placePodNotes, setMobileEmulation, setOffline, showAddPodcast, @@ -144,6 +145,36 @@ async function takeNote(): Promise { return text.replace(/^Date: .+$/m, "Date: "); } +type Box = Readonly<{ top: number; bottom: number; width: number; height: number }>; +type PhoneGeometry = Readonly<{ + stage: Box; + art: Box | null; + panel: Box | null; + list: Box | null; + controls: number; + navbar: number; +}>; + +/** Where the full player lays out its stage and deck on the phone, against Obsidian's navbar. */ +function phoneGeometry(): Promise { + return getContext().obsidian.dev.evalJson(`(() => { + const player = document.querySelector(${jsLiteral(`${PLAYER_VIEW} section.pn-player`)}); + const box = (el) => { + if (!el) return null; + const { top, bottom, width, height } = el.getBoundingClientRect(); + return { top, bottom, width, height }; + }; + return { + stage: box(player.querySelector(".pn-stage")), + art: box(player.querySelector(".pn-np-art")), + panel: box(player.querySelector(".pn-stage .pn-panel")), + list: box(player.querySelector(".pn-stage .pn-panel-scroll")), + controls: Math.max(...[...player.querySelectorAll(".pn-deck button")].map((button) => button.getBoundingClientRect().bottom)), + navbar: document.querySelector(".mobile-navbar").getBoundingClientRect().top, + }; + })()`); +} + describe("transcript journeys", () => { test("switching episodes clears Find and shows every line of the incoming transcript", async () => { const context = getContext(); @@ -369,7 +400,7 @@ describe("transcript journeys", () => { ); }, 120_000); - test("on a phone, Save, the Find field, Follow and Up next's Edit are 44 px tall, and Edit 44 px wide", async () => { + test("on a phone, the stage gets the room the deck can spare, and Save, the Find field, Follow and Edit are 44 px", async () => { const context = getContext(); const { obsidian } = context; const phone = { width: 390, height: 844 }; @@ -394,15 +425,17 @@ describe("transcript journeys", () => { current: storedEpisode(server, FEED, CAPTIONED), queue: [storedEpisode(server, FEED, PLAIN)], }); - await showPodNotes(obsidian); + await placePodNotes(obsidian, "main area"); if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); await waitUntilShown(obsidian, ui.fullPlayer); + const geometry = await phoneGeometry(); await click(obsidian, ui.playerPanel("Transcript")); await eventually( obsidian, async () => (await shownTranscript()).lines.length, LINES.length, ); + const transcriptPanel = await phoneGeometry(); const height = (target: UiTarget) => obsidian.dev.evalJson( `Math.round((${target.elements})[0]?.getBoundingClientRect().height ?? 0)`, @@ -421,11 +454,30 @@ describe("transcript journeys", () => { await click(obsidian, ui.playerPanel("Up next")); await waitUntilShown(obsidian, panelButton("Edit")); heights.edit = await height(panelButton("Edit")); + const upNextPanel = await phoneGeometry(); const editWidth = await obsidian.dev.evalJson( `(${panelButton("Edit").elements})[0].getBoundingClientRect().width`, ); expect(heights).toEqual({ save: 44, follow: 44, find: 44, edit: 44 }); expect(editWidth).toBeGreaterThanOrEqual(44); + expect(geometry.stage.height, "the stage under Obsidian's header").toBeGreaterThanOrEqual( + 275, + ); + expect(geometry.art, "square artwork that fills the stage's height").toEqual({ + top: geometry.stage.top + 4, + bottom: geometry.stage.bottom - 4, + width: geometry.stage.height - 8, + height: geometry.stage.height - 8, + }); + for (const { controls, navbar } of [geometry, transcriptPanel, upNextPanel]) + expect(controls, "the deck above Obsidian's navbar").toBeLessThanOrEqual(navbar); + for (const { stage, panel, list } of [transcriptPanel, upNextPanel]) { + expect([panel?.top, panel?.bottom], "a panel fills the stage").toEqual([ + stage.top, + stage.bottom, + ]); + expect(list?.bottom, "its list fills the stage under its head").toBe(stage.bottom); + } }, 120_000); }); diff --git a/tests/e2e/journey/actions.ts b/tests/e2e/journey/actions.ts index ee8d83a4..df332085 100644 --- a/tests/e2e/journey/actions.ts +++ b/tests/e2e/journey/actions.ts @@ -42,6 +42,35 @@ export async function showPodNotes(obsidian: ObsidianClient): Promise { await waitUntilShown(obsidian, ui.podcastViewport); } +/** + * Shows PodNotes in one leaf in the right sidebar, `width` px wide, or in the main area, + * wherever an earlier test or tool left it, and closes the leaves elsewhere. + */ +export async function placePodNotes( + obsidian: ObsidianClient, + place: { sidebar: number } | "main area", +): Promise { + const sidebar = place !== "main area"; + await obsidian.dev.evalJsonAsync(`(async () => { + const { workspace } = app; + const inSidebar = (leaf) => leaf.getRoot() === workspace.rightSplit; + for (const leaf of workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})) + if (inSidebar(leaf) !== ${sidebar}) leaf.detach(); + if (workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)}).length === 0) { + let leaf = ${sidebar} ? workspace.getRightLeaf(false) : undefined; + workspace.iterateRootLeaves((candidate) => { + if (!${sidebar} && (!leaf || candidate.view.getViewType() === "empty")) leaf = candidate; + }); + await leaf.setViewState({ type: ${jsLiteral(VIEW_TYPE)} }); + } + return true; + })()`); + await showPodNotes(obsidian); + if (sidebar) { + await obsidian.dev.evalJson(`app.workspace.rightSplit.setSize(${place.sidebar}), true`); + } +} + /** Goes to the Library as a user does: closes the full player, then Back until the Library shows. */ export async function showLibrary(obsidian: ObsidianClient): Promise { if (await isShown(obsidian, ui.fullPlayer)) await click(obsidian, ui.collapsePlayer); diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index d60d02d6..66551edb 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -726,6 +726,8 @@ export class MenuItem { export class Menu { /** Test only: the menu on screen, until it hides. */ static shown: Menu | null = null; + /** Test only: as on a phone, where Obsidian runs the hide callback after an animation. */ + static hideDelayMs = 0; items: Array = []; private hideCallback: (() => unknown) | null = null; @@ -759,7 +761,8 @@ export class Menu { if (Menu.shown === this) Menu.shown = null; const callback = this.hideCallback; this.hideCallback = null; - callback?.(); + if (Menu.hideDelayMs > 0) setTimeout(() => callback?.(), Menu.hideDelayMs); + else callback?.(); return this; } From 92d665ac36527b80099d2832c7542137826536b4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 01:00:35 +0200 Subject: [PATCH 06/39] refactor(ui): draw the full player's artwork with Cover, as rows and the mini player do The full player kept its own image, failure state and tile. It now uses the rows' Cover and tileOf: a remote episode without artwork, or whose artwork fails, shows its podcast's name as rows and the grid do, and a local file its tile. This saves 111 gzip bytes. --- src/ui/PodcastView/Player.svelte | 25 +++++++++---------------- src/ui/PodcastView/Player.test.ts | 26 ++++++++++++++------------ tests/e2e/journey-transcript.test.ts | 2 +- 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/src/ui/PodcastView/Player.svelte b/src/ui/PodcastView/Player.svelte index d90d7f28..bcc3248d 100644 --- a/src/ui/PodcastView/Player.svelte +++ b/src/ui/PodcastView/Player.svelte @@ -6,8 +6,9 @@ import { cachedChapters, loadChapters } from "./chapters"; import ChaptersPanel from "./ChaptersPanel.svelte"; import { useServices } from "./context"; + import Cover from "./Cover.svelte"; import Deck from "./Deck.svelte"; - import { isPlaying } from "./episodeRow"; + import { isPlaying, tileOf } from "./episodeRow"; import type { Layout } from "./layout"; import { anchorOf, episodeMenu, showMenu } from "./menus"; import TranscriptPanel from "./TranscriptPanel.svelte"; @@ -43,7 +44,6 @@ let chapters: readonly Chapter[] = $state([]); /** The panel the user chose. None shows the artwork at compact widths. */ let panel = $state(); - let failedArtworkUrl = $state(""); const episode = $derived($playerState.episode as Episode); const wide = $derived(layout === "wide"); @@ -240,16 +240,12 @@ {@attach icon("maximize")} >
- {:else if episode.artworkUrl && episode.artworkUrl !== failedArtworkUrl} - (failedArtworkUrl = episode.artworkUrl ?? "")} + title={episode.podcastName} + tile={tileOf(episode)} /> - {:else} - {/if} (obsidian.Menu as unknown as typeof FakeMenu).shown!; describe("the full player", () => { - test("shows the artwork, and a file tile for a local file or artwork that fails to load (#394)", async () => { + test("shows the artwork, and its podcast's name as rows do when the artwork fails to load (#394)", async () => { const { player } = await openPlayer(); - const art = player.querySelector("img.pn-np-art")!; + const art = player.querySelector(".pn-stage img")!; expect(art).toHaveAttribute("src", episode.artworkUrl); await fireEvent.error(art); expect(player.querySelector("img")).toBeNull(); - expect(player.querySelector(".pn-np-art.pn-filetile")).toHaveAttribute( - "data-icon", - "music", - ); + expect(player.querySelector(".pn-stage")).toHaveTextContent(episode.podcastName); + + services.player.play({ ...queued("No artwork"), artworkUrl: undefined }); + await tick(); + expect(player.querySelector(".pn-stage [data-icon]"), "no tile").toBeNull(); + expect(player.querySelector(".pn-stage")).toHaveTextContent(episode.podcastName); }); test("a local file shows a file tile, and its folder links to Local files", async () => { @@ -118,7 +120,7 @@ describe("the full player", () => { const { player } = await openPlayer(local); expect(player.querySelector("img")).toBeNull(); - expect(player.querySelector(".pn-np-art.pn-filetile")).toHaveAttribute( + expect(player.querySelector(".pn-stage > [data-icon]")).toHaveAttribute( "data-icon", "music", ); @@ -360,7 +362,7 @@ describe("the full player", () => { await tick(); expect(player.querySelector(".pn-video")).toBeNull(); - expect(player.querySelector("img.pn-np-art")).not.toBeNull(); + expect(player.querySelector(".pn-stage img")).not.toBeNull(); }); }); @@ -371,15 +373,15 @@ describe("the player's panels", () => { test("at compact width, a panel replaces the artwork until it is chosen again", async () => { const { player } = await openPlayer(withChapters); await vi.waitFor(() => expect(switchButton("Chapters")).toBeInTheDocument()); - expect(player.querySelector(".pn-np-art")).not.toBeNull(); + expect(player.querySelector(".pn-stage > .pn-cover")).not.toBeNull(); await fireEvent.click(switchButton("Up next")); expect(switchButton("Up next")).toHaveAttribute("aria-pressed", "true"); - expect(player.querySelector(".pn-np-art")).toBeNull(); + expect(player.querySelector(".pn-stage > .pn-cover")).toBeNull(); expect(within(player).getByText("Queue is empty")).toBeInTheDocument(); await fireEvent.click(switchButton("Up next")); - expect(player.querySelector(".pn-np-art")).not.toBeNull(); + expect(player.querySelector(".pn-stage > .pn-cover")).not.toBeNull(); }); test("an episode without chapters offers its Transcript and Up next, no Chapters", async () => { @@ -492,7 +494,7 @@ describe("the player's panels", () => { await vi.waitFor(() => expect(switchButton("Chapters")).toBeInTheDocument()); expect(switchButton("Chapters")).toHaveAttribute("aria-pressed", "false"); - expect(player.querySelector(".pn-np-art")).not.toBeNull(); + expect(player.querySelector(".pn-stage > .pn-cover")).not.toBeNull(); }); test("the current chapter is named under the scrubber, opens Chapters, and is marked there", async () => { diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 66f26a6a..7f988be4 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -166,7 +166,7 @@ function phoneGeometry(): Promise { }; return { stage: box(player.querySelector(".pn-stage")), - art: box(player.querySelector(".pn-np-art")), + art: box(player.querySelector(".pn-stage > .pn-cover")), panel: box(player.querySelector(".pn-stage .pn-panel")), list: box(player.querySelector(".pn-stage .pn-panel-scroll")), controls: Math.max(...[...player.querySelectorAll(".pn-deck button")].map((button) => button.getBoundingClientRect().bottom)), From cb594dfeacdfe0965baa40a98950801c93630fa0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 01:00:47 +0200 Subject: [PATCH 07/39] refactor(ui): keep master's wrapping feed banner detail #482 made the banner's detail wrap, so "Address not on this device" no longer truncates at 320 px. The split that set a sentence in the UI font and only a host in monospace cost 52 gzip bytes for a font change, so it goes back to master's single rule; the e2e keeps the no-truncation check. --- src/ui/PodcastView/EpisodesPage.svelte | 14 +++++--------- tests/e2e/view-polish.test.ts | 17 +++++------------ 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/ui/PodcastView/EpisodesPage.svelte b/src/ui/PodcastView/EpisodesPage.svelte index e346b2fe..5591c9bf 100644 --- a/src/ui/PodcastView/EpisodesPage.svelte +++ b/src/ui/PodcastView/EpisodesPage.svelte @@ -48,10 +48,10 @@ const refresh = () => void feeds.load([key], "network"); /** Which server failed, without the address: a private feed's address is a secret. */ - function failedSource({ error, feed }: FeedState): { text: string; host?: boolean } { - if (error === "unavailable") return { text: "Address not on this device" }; - if (isPrivateFeedPlaceholder(feed.url)) return { text: "Private feed" }; - return { text: checkStringIsUrl(feed.url)?.host ?? "", host: true }; + function failedSource({ error, feed }: FeedState): string { + if (error === "unavailable") return "Address not on this device"; + if (isPrivateFeedPlaceholder(feed.url)) return "Private feed"; + return checkStringIsUrl(feed.url)?.host ?? ""; } @@ -66,12 +66,11 @@ {#if error} - {@const source = failedSource(feedState)} {:else} @@ -407,11 +417,6 @@ --icon-size: 16px; } - /* Leaving fullscreen is Escape's job. */ - .pn-video:fullscreen .pn-fs { - display: none; - } - .pn-np-right { min-height: 0; display: flex; diff --git a/src/ui/PodcastView/Player.test.ts b/src/ui/PodcastView/Player.test.ts index b36bc7a7..3f6fabc9 100644 --- a/src/ui/PodcastView/Player.test.ts +++ b/src/ui/PodcastView/Player.test.ts @@ -340,6 +340,70 @@ describe("the full player", () => { ).toBeInTheDocument(); }); + test("in fullscreen, an Escape only leaves it, and the frame's button leaves it too", async () => { + const { player } = await openPlayer({ ...episode, mediaType: "video" }); + const frame = player.querySelector(".pn-video")!; + let fullscreenElement: Element | null = null; + const change = (to: Element | null) => { + fullscreenElement = to; + document.dispatchEvent(new Event("fullscreenchange")); + }; + const exit = vi.fn(async () => change(null)); + Object.defineProperties(document, { + fullscreenElement: { configurable: true, get: () => fullscreenElement }, + exitFullscreen: { configurable: true, value: exit }, + }); + onTestFinished(() => { + for (const key of ["fullscreenElement", "exitFullscreen"]) + delete (document as unknown as Record)[key]; + }); + frame.requestFullscreen = vi.fn(async () => change(frame)); + const expanded = () => services.navigation.current.expanded; + + await fireEvent.click(button("Fullscreen")); + expect(button("Exit fullscreen")).toBeInTheDocument(); + press("Escape"); + await tick(); + expect(exit, "the first Escape leaves fullscreen").toHaveBeenCalledOnce(); + expect(expanded(), "and leaves the player open").toBe(true); + + await fireEvent.click(button("Fullscreen")); + await fireEvent.click(button("Exit fullscreen")); + expect(exit).toHaveBeenCalledTimes(2); + expect(button("Fullscreen")).toBeInTheDocument(); + press("Escape"); + await tick(); + expect(expanded(), "an Escape after fullscreen").toBe(false); + }); + + test("an Escape closes the player after the next episode, audio, took fullscreen's frame away", async () => { + const { player } = await openPlayer({ ...episode, mediaType: "video" }); + let fullscreenElement: Element | null = null; + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => fullscreenElement, + }); + onTestFinished(() => { + delete (document as unknown as Record).fullscreenElement; + }); + fullscreenElement = player.querySelector(".pn-video"); + document.dispatchEvent(new Event("fullscreenchange")); + + services.player.play({ + ...episode, + title: "Audio next", + streamUrl: "https://pod.test/next.mp3", + }); + await tick(); + fullscreenElement = null; + document.dispatchEvent(new Event("fullscreenchange")); + await tick(); + press("Escape"); + await tick(); + + expect(services.navigation.current.expanded).toBe(false); + }); + test("the video frame plays and pauses on its own, as it does in fullscreen", async () => { const { player } = await openPlayer({ ...episode, mediaType: "video" }); const frame = within(player.querySelector(".pn-video")!); diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 027fcd87..88c42d1f 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -3,6 +3,7 @@ import type { Episode } from "src/types/Episode"; import { formatDuration } from "src/utility/formatDuration"; import { isLocalFile } from "src/utility/isLocalFile"; + import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; import { getEpisodeMediaType } from "src/utility/mediaType"; import { icon } from "../obsidian/icon"; import { useServices } from "./context"; @@ -32,7 +33,8 @@ position: number; onseek(time: number): void; } = $props(); - const { feeds, online, settings, saveTranscriptNote, transcribeFromAudio } = useServices(); + const { app, feeds, online, settings, rowHasNote, saveTranscriptNote, transcribeFromAudio } = + useServices(); let load = $state.raw({ kind: "loading" }); let list: HTMLElement | undefined = $state(); @@ -57,6 +59,14 @@ .filter(({ line }) => !needle || foldCase(line.text).includes(needle)), ); const audio = $derived(getEpisodeMediaType(episode) === "audio"); + // The note saved before, which the command would refuse to save again. A note made, moved or + // deleted since changes rowHasNote, and the transcript path is a setting. + const note = $derived.by(() => { + void $rowHasNote; + return app.vault.getFileByPath( + getEpisodeTranscriptPath(episode, $settings.transcript.path), + ); + }); const provider = $derived.by(() => { const { diarization } = $settings.transcript; return diarization?.enabled && diarization.provider === "deepgram" ? "Deepgram" : "OpenAI"; @@ -258,13 +268,16 @@ onclick={() => (finding = true)} {@attach icon("search")} > + {@const label = note ? "Open note" : "Save as note"} {/if} diff --git a/src/ui/PodcastView/TranscriptPanel.test.ts b/src/ui/PodcastView/TranscriptPanel.test.ts index ed154f3f..21eb05a1 100644 --- a/src/ui/PodcastView/TranscriptPanel.test.ts +++ b/src/ui/PodcastView/TranscriptPanel.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { fireEvent, render, screen, within } from "@testing-library/svelte"; -import { SecretStorage } from "obsidian"; +import { SecretStorage, TFile } from "obsidian"; import { tick } from "svelte"; import { get } from "svelte/store"; import { @@ -354,7 +354,12 @@ describe("a published transcript", () => { }); test("Save as note hands the note to the transcribe command and writes nothing itself", async () => { - const vault = { create: vi.fn(), modify: vi.fn(), process: vi.fn() }; + const vault = { + getFileByPath: () => null, + create: vi.fn(), + modify: vi.fn(), + process: vi.fn(), + }; Object.assign(services.app, { vault }); await openTranscript(episodeOf([VTT_LINK]), 0); await transcript(); @@ -373,6 +378,45 @@ describe("a published transcript", () => { expect(button("Save as note")).toBeDisabled(); }); + test("opens the note saved before, also offline, instead of saving it again, as long as it is there", async () => { + const { settings } = get(plugin); + settings.transcript.path = "Transcripts/{{title}}.md"; + plugin.set(get(plugin)); + const notes = new Map(); + const note = (folder: string, { title }: Episode) => + Object.assign(new TFile(), { path: `${folder}/${title}.md` }); + Object.assign(services.app, { + vault: { getFileByPath: (path: string) => notes.get(path) ?? null }, + }); + const openFile = vi.fn((_file: TFile) => {}); + Object.assign(services.app.workspace, { getLeaf: vi.fn(() => ({ openFile })) }); + const played = episodeOf([VTT_LINK]); + const saved = note("Transcripts", played); + notes.set(saved.path, saved); + await openTranscript(played, 0); + await transcript(); + services.online.set(false); + await tick(); + + expect(button("Open note")).toBeEnabled(); + await fireEvent.click(button("Open note")); + expect(openFile).toHaveBeenCalledExactlyOnceWith(saved); + expect(services.saveTranscriptNote).not.toHaveBeenCalled(); + + notes.clear(); + services.rowHasNote.set(() => false); + await tick(); + expect(button("Save as note"), "deleted, and offline").toBeDisabled(); + + const moved = note("Elsewhere", played); + notes.set(moved.path, moved); + settings.transcript.path = "Elsewhere/{{title}}.md"; + plugin.set(get(plugin)); + await tick(); + await fireEvent.click(button("Open note")); + expect(openFile).toHaveBeenLastCalledWith(moved); + }); + test("Save as note saves a video's feed transcript too, which needs no audio", async () => { await openTranscript(episodeOf([VTT_LINK], { mediaType: "video" }), 0); await transcript(); diff --git a/src/ui/PodcastView/menus.test.ts b/src/ui/PodcastView/menus.test.ts index 411babe1..0b9634cf 100644 --- a/src/ui/PodcastView/menus.test.ts +++ b/src/ui/PodcastView/menus.test.ts @@ -473,6 +473,18 @@ describe("a podcast's menu, by how its feed loaded", () => { expect(titles(podcastMenu(key, services))).toEqual(items); }); + test("Set feed address waits for a connection, which checks the address", () => { + const setFeedAddress = (podcast: string) => + podcastMenu(podcast, services).find( + (entry) => entry !== "separator" && entry.title === "Set feed address", + ); + + services.online.set(false); + expect(setFeedAddress("Patron")).toMatchObject({ disabled: true }); + services.online.set(true); + expect(setFeedAddress("Patron")).toMatchObject({ disabled: false }); + }); + test("Edit feed URL asks for the failed feed's address", () => { const setFeedAddress = vi.spyOn(services.actions, "setFeedAddress").mockResolvedValue(); diff --git a/src/ui/PodcastView/menus.ts b/src/ui/PodcastView/menus.ts index 7425982f..6fbef7f4 100644 --- a/src/ui/PodcastView/menus.ts +++ b/src/ui/PodcastView/menus.ts @@ -272,15 +272,17 @@ export function episodeMenu( } export function podcastMenu(key: FeedKey, services: ShellServices): MenuEntry[] { - const { library, feeds, actions, navigation } = services; + const { library, feeds, actions, navigation, online } = services; const feed = get(library.savedFeeds)[key]; if (!feed) return []; const note = getFeedNote(feed); const error = get(feeds).get(key)?.error; const isPublic = publicFeedUrl(feed) !== undefined; + // An address is checked by loading its feed. const address = (title: string): MenuEntry => ({ title, icon: "rss", + disabled: !get(online), onClick: () => void actions.setFeedAddress(key), }); return [ diff --git a/tests/e2e/journey-player.test.ts b/tests/e2e/journey-player.test.ts index cc2f9e1a..1b9889bc 100644 --- a/tests/e2e/journey-player.test.ts +++ b/tests/e2e/journey-player.test.ts @@ -133,47 +133,42 @@ const focused = (target: UiTarget) => ); describe("player journeys", () => { - test("the fullscreen button belongs to the video stage and hides while that stage is fullscreen", async () => { + test("in a video's fullscreen, the first Escape only leaves it, and its own button leaves it too", async () => { const { obsidian } = getContext(); await openPlayer([], () => ({ mediaType: "video" })); await click(obsidian, ui.playbackToggle); await waitForPlayback(obsidian, (state) => state.playing, "video loaded"); - const fullscreen: UiTarget = { - name: "video fullscreen", - elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-video button.pn-fs`)})]`, - }; - expect(await isShown(obsidian, fullscreen)).toBe(true); + const control = (label: string): UiTarget => ({ + name: `video ${label}`, + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-video button.pn-fs[aria-label="${label}"]`)})]`, + }); + const frameIsFullscreen = () => + obsidian.dev.evalJson( + `document.fullscreenElement === document.querySelector(${jsLiteral(`${PLAYER_VIEW} .pn-video`)})`, + ); onTestFinished(async () => { await obsidian.dev.evalJsonAsync( "(async () => { if (document.fullscreenElement) await document.exitFullscreen(); return true; })()", ); }); - await click(obsidian, fullscreen); - expect( - await eventually( - obsidian, - () => - obsidian.dev.evalJson( - `document.fullscreenElement === document.querySelector(${jsLiteral(`${PLAYER_VIEW} .pn-video`)})`, - ), - true, - ), - ).toBe(true); - expect( - await obsidian.dev.evalJson( - `getComputedStyle((${fullscreen.elements})[0]).display`, - ), - ).toBe("none"); - + await click(obsidian, control("Fullscreen")); + expect(await eventually(obsidian, frameIsFullscreen, true)).toBe(true); + await waitUntilShown(obsidian, control("Exit fullscreen")); + await pressKey(obsidian, "Escape"); + expect(await eventually(obsidian, frameIsFullscreen, false)).toBe(false); + expect(await holdsFor(obsidian, () => isShown(obsidian, ui.fullPlayer), true)).toBe(true); + + await click(obsidian, control("Fullscreen")); + expect(await eventually(obsidian, frameIsFullscreen, true)).toBe(true); + await click(obsidian, control("Exit fullscreen")); + expect(await eventually(obsidian, frameIsFullscreen, false)).toBe(false); + await waitUntilShown(obsidian, control("Fullscreen")); await pressKey(obsidian, "Escape"); expect( - await eventually( - obsidian, - () => obsidian.dev.evalJson("document.fullscreenElement === null"), - true, - ), - ).toBe(true); + await eventually(obsidian, () => isShown(obsidian, ui.fullPlayer), false), + "the next Escape closes the player", + ).toBe(false); }); test.each(["theme-light", "theme-dark"])( diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 7f988be4..97f7c7b6 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -27,6 +27,7 @@ import { storedEpisode, waitForLibrary, waitForPlayback, + writeVaultFile, } from "./journey/library"; import { jsLiteral, PLUGIN_ID } from "./runtime"; @@ -293,6 +294,39 @@ describe("transcript journeys", () => { expect(fromPanel).toBe(fromCommand); }, 120_000); + test("J-TR-04 a transcript saved before opens from the panel, offline too, unchanged and with no download", async () => { + const { obsidian, sandbox } = getContext(); + const server = await openTranscript(CAPTIONED); + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); + const saved = "# Memory Palaces\n\nSaved before, by hand.\n"; + await writeVaultFile(obsidian, sandbox.path(NOTE_PATH), saved); + onTestFinished(() => + obsidian.dev.evalJsonAsync(`(async () => { + await app.vault.delete(app.vault.getFileByPath(${jsLiteral(sandbox.path(NOTE_PATH))})); + return true; + })()`), + ); + await waitUntilShown(obsidian, panelButton("Open note")); + const requests = server.requests().length; + await setOffline(obsidian, true); + onTestFinished(() => setOffline(obsidian, false)); + + await click(obsidian, panelButton("Open note")); + + expect( + await eventually( + obsidian, + () => + obsidian.dev.evalJson( + "app.workspace.getActiveFile()?.path ?? null", + ), + sandbox.path(NOTE_PATH), + ), + ).toBe(sandbox.path(NOTE_PATH)); + expect(await readVaultText(obsidian, sandbox.path(NOTE_PATH))).toBe(saved); + expect(server.requests().length, "no request").toBe(requests); + }); + test("an episode whose feed publishes no transcript offers the paid transcription", async () => { const { obsidian } = getContext(); await openTranscript(PLAIN); diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 66551edb..9ae17be2 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -124,6 +124,8 @@ export class Workspace { } export class App { + /** No file: a test that needs one puts its own vault in. */ + vault = { getFileByPath: (_path: string): TFile | null => null }; secretStorage = new SecretStorage(); scope = new Scope(); workspace = new Workspace(); From 9fccd8d76c06ddb15e16c5538cf73a881267ded6 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 01:01:48 +0200 Subject: [PATCH 09/39] test(e2e): place the transcript journeys' view instead of assuming the right sidebar The journeys opened PodNotes wherever the vault's layout left it. After the verify skill's screens.mjs had moved the view to the main area or sized the window, the transcript panel was shorter, the click helper scrolled line 7 into view, and the Follow chip that scroll shows covered it. Each journey now puts PodNotes in the right sidebar 820 px wide, widening the window if it has to. --- tests/e2e/journey-transcript.test.ts | 6 +++--- tests/e2e/journey/actions.ts | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 97f7c7b6..4f4eb440 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -112,6 +112,7 @@ async function serve(): Promise { return server; } +/** The full player wide, so every line shows and none needs a scroll that stops following. */ async function openTranscript(episode: FixtureEpisode): Promise { const context = getContext(); const server = await serve(); @@ -119,7 +120,7 @@ async function openTranscript(episode: FixtureEpisode): Promise { subscriptions: [FEED], current: storedEpisode(server, FEED, episode), }); - await showPodNotes(context.obsidian); + await placePodNotes(context.obsidian, { sidebar: 820 }); if (!(await isShown(context.obsidian, ui.fullPlayer))) await click(context.obsidian, ui.openPlayer); await waitUntilShown(context.obsidian, ui.fullPlayer); @@ -194,9 +195,8 @@ describe("transcript journeys", () => { current: storedEpisode(server, feed, CAPTIONED), queue: [storedEpisode(server, feed, incoming)], }); - await showPodNotes(obsidian); + await placePodNotes(obsidian, { sidebar: 820 }); if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); - await obsidian.dev.evalJson("app.workspace.rightSplit.setSize(820), true"); await click(obsidian, ui.playerPanel("Transcript")); expect( await eventually(obsidian, async () => (await shownTranscript()).lines, LINES), diff --git a/tests/e2e/journey/actions.ts b/tests/e2e/journey/actions.ts index df332085..b3b98b48 100644 --- a/tests/e2e/journey/actions.ts +++ b/tests/e2e/journey/actions.ts @@ -66,9 +66,22 @@ export async function placePodNotes( return true; })()`); await showPodNotes(obsidian); - if (sidebar) { - await obsidian.dev.evalJson(`app.workspace.rightSplit.setSize(${place.sidebar}), true`); - } + if (!sidebar) return; + // A tool such as the verify skill's screens.mjs can leave the window too small for it. + await obsidian.dev.evalJson(`(() => { + const win = require("@electron/remote").getCurrentWindow(); + const [width, height] = win.getContentSize(); + win.setContentSize(Math.max(width, ${place.sidebar + 400}), Math.max(height, 760)); + app.workspace.rightSplit.setSize(${place.sidebar}); + return true; + })()`); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `Math.round(document.querySelector(${jsLiteral(`.workspace-leaf-content[data-type="${VIEW_TYPE}"] .podcast-view`)})?.getBoundingClientRect().width ?? 0) === ${place.sidebar}`, + ), + { timeoutMs: 5_000, intervalMs: 100, message: `PodNotes ${place.sidebar} px wide` }, + ); } /** Goes to the Library as a user does: closes the full player, then Back until the Library shows. */ From edf4875d4aad942b483d2ba902d3101d9e95b3d4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 01:03:03 +0200 Subject: [PATCH 10/39] test(e2e): a private feed with its address here lists its cached episodes offline Beside #482's journey for a private feed whose address is missing: one whose address is on this device, opened offline after its feed was cached, lists the cached episodes and shows no feed banner and no missing address. --- tests/e2e/private-feeds.test.ts | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/e2e/private-feeds.test.ts b/tests/e2e/private-feeds.test.ts index 862d036f..7ecdb9d8 100644 --- a/tests/e2e/private-feeds.test.ts +++ b/tests/e2e/private-feeds.test.ts @@ -16,6 +16,7 @@ import { click, typeInto } from "./journey/input"; import { ui } from "./journey/targets"; import { eventually, + holdsFor, isDisabled, isShown, notices, @@ -24,6 +25,7 @@ import { } from "./journey/wait"; import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; import { + ageFeedCache, feedCacheHolds, feedCacheText, readLibrary, @@ -100,6 +102,60 @@ test("a private feed first opened offline explains that its address is not on th ).toBeFalsy(); }); +test("a private feed whose address is on this device, opened offline hours later, lists the episodes it cached and no missing address", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const show: FixtureFeed = { + title: "Private Kept", + slug: "private-kept", + query: "?auth=KEPT_SENTINEL", + artwork: false, + episodes: [{ title: "Kept episode", seconds: 6, pubDate: pubDate(0) }], + }; + const server = await startFixtureServer([show]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server); + const id = "podnotes-feed-url-48200000-0000-4000-8000-000000000001"; + await obsidian.dev.evalJson( + `app.secretStorage.setSecret(${jsLiteral(id)}, ${jsLiteral(server.feedUrl(show))}), true`, + ); + onTestFinished(async () => { + await obsidian.dev.evalJson(`app.secretStorage.setSecret(${jsLiteral(id)}, ""), true`); + await setOffline(obsidian, false); + }); + await plugin.updateDataAndReload>((data) => { + data.savedFeeds = { + [show.title]: { + title: show.title, + artworkUrl: "", + url: `podnotes-private-feed:${encodeURIComponent(show.title)}`, + urlSecretId: id, + }, + }; + }, RELOAD_OPTIONS); + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(show.title)); + await waitUntilShown(obsidian, ui.episode("Kept episode")); + await obsidian.waitFor(() => feedCacheHolds(obsidian, "Kept episode"), { + timeoutMs: 10_000, + message: "the feed cached", + }); + await closePodNotes(obsidian); + await ageFeedCache(obsidian, 7 * 60 * 60 * 1000); + server.disconnect(); + await setOffline(obsidian, true); + await plugin.reload(RELOAD_OPTIONS); + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(show.title)); + await waitUntilShown(obsidian, ui.offlineStrip); + + expect(await isShown(obsidian, ui.episode("Kept episode"))).toBe(true); + expect(await holdsFor(obsidian, () => isShown(obsidian, ui.feedError), false)).toBe(false); + expect(await shownText(obsidian)).not.toContain("Address not on this device"); +}); + test("protects a Supercast subscription's address in data, cache keys and note links, then accepts it under the same reference", async () => { const context = getContext(); const { obsidian, plugin, sandbox } = context; From 4098137c0ddfd316afd3714252f68946d46cee9e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 01:43:11 +0200 Subject: [PATCH 11/39] refactor(ui): anchor each component's styles on its root Svelte writes its scoping class into every selector written at a sheet's top level, but only once for rules nested in a root-anchored `.root :global { ... }` block. Each component now keeps its root's own declarations and its state, width and platform variants (`&.is-wide`, `.is-phone &`) in that one block. The flattened rules select the same elements with the same specificity, except two that rise by one class and change nothing: the wide header's `.pn-head` and `.pn-head-title` rules, which nothing else styles, and the queue's row-hover rule for Remove, whose only rival in between is its own focus rule with the same opacity. --- src/ui/PodcastView/AddPodcastPage.svelte | 14 +- src/ui/PodcastView/Cover.svelte | 48 +++---- src/ui/PodcastView/Deck.svelte | 62 +++++---- src/ui/PodcastView/EpisodeRow.svelte | 148 +++++++++++----------- src/ui/PodcastView/EpisodesPage.svelte | 60 ++++----- src/ui/PodcastView/LibraryPage.svelte | 20 ++- src/ui/PodcastView/MiniPlayer.svelte | 50 ++++---- src/ui/PodcastView/Panel.svelte | 38 +++--- src/ui/PodcastView/Player.svelte | 50 ++++---- src/ui/PodcastView/PodcastView.svelte | 132 ++++++++++--------- src/ui/PodcastView/QueueRow.svelte | 36 +++--- src/ui/PodcastView/Scrubber.svelte | 46 +++---- src/ui/PodcastView/SearchField.svelte | 64 +++++----- src/ui/PodcastView/TranscriptPanel.svelte | 24 ++-- 14 files changed, 377 insertions(+), 415 deletions(-) diff --git a/src/ui/PodcastView/AddPodcastPage.svelte b/src/ui/PodcastView/AddPodcastPage.svelte index 92439099..561b32d3 100644 --- a/src/ui/PodcastView/AddPodcastPage.svelte +++ b/src/ui/PodcastView/AddPodcastPage.svelte @@ -113,7 +113,7 @@ {/if} diff --git a/src/ui/PodcastView/Cover.svelte b/src/ui/PodcastView/Cover.svelte index 6816ae70..9ac209e6 100644 --- a/src/ui/PodcastView/Cover.svelte +++ b/src/ui/PodcastView/Cover.svelte @@ -30,37 +30,37 @@ {/if} diff --git a/src/ui/PodcastView/Deck.svelte b/src/ui/PodcastView/Deck.svelte index 1982a4fd..426c1e12 100644 --- a/src/ui/PodcastView/Deck.svelte +++ b/src/ui/PodcastView/Deck.svelte @@ -200,15 +200,13 @@ diff --git a/src/ui/PodcastView/EpisodeRow.svelte b/src/ui/PodcastView/EpisodeRow.svelte index b196e2e3..15d2e3cb 100644 --- a/src/ui/PodcastView/EpisodeRow.svelte +++ b/src/ui/PodcastView/EpisodeRow.svelte @@ -122,35 +122,33 @@ diff --git a/src/ui/PodcastView/EpisodesPage.svelte b/src/ui/PodcastView/EpisodesPage.svelte index 5591c9bf..395d9840 100644 --- a/src/ui/PodcastView/EpisodesPage.svelte +++ b/src/ui/PodcastView/EpisodesPage.svelte @@ -120,14 +120,12 @@ {/if} diff --git a/src/ui/PodcastView/LibraryPage.svelte b/src/ui/PodcastView/LibraryPage.svelte index b4360e6d..87087440 100644 --- a/src/ui/PodcastView/LibraryPage.svelte +++ b/src/ui/PodcastView/LibraryPage.svelte @@ -49,13 +49,11 @@ diff --git a/src/ui/PodcastView/MiniPlayer.svelte b/src/ui/PodcastView/MiniPlayer.svelte index fa804da7..3a79ab4d 100644 --- a/src/ui/PodcastView/MiniPlayer.svelte +++ b/src/ui/PodcastView/MiniPlayer.svelte @@ -88,7 +88,7 @@ diff --git a/src/ui/PodcastView/Panel.svelte b/src/ui/PodcastView/Panel.svelte index 5b6be593..2f23d374 100644 --- a/src/ui/PodcastView/Panel.svelte +++ b/src/ui/PodcastView/Panel.svelte @@ -33,15 +33,13 @@ diff --git a/src/ui/PodcastView/Player.svelte b/src/ui/PodcastView/Player.svelte index 64a4b0a7..4e35bfd8 100644 --- a/src/ui/PodcastView/Player.svelte +++ b/src/ui/PodcastView/Player.svelte @@ -285,16 +285,14 @@ diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index d9fc938f..9f9673d3 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -245,25 +245,6 @@ diff --git a/src/ui/PodcastView/Scrubber.svelte b/src/ui/PodcastView/Scrubber.svelte index 24119dcb..48676f4c 100644 --- a/src/ui/PodcastView/Scrubber.svelte +++ b/src/ui/PodcastView/Scrubber.svelte @@ -94,27 +94,25 @@ diff --git a/src/ui/PodcastView/SearchField.svelte b/src/ui/PodcastView/SearchField.svelte index 44ca640e..56f9ab72 100644 --- a/src/ui/PodcastView/SearchField.svelte +++ b/src/ui/PodcastView/SearchField.svelte @@ -64,13 +64,11 @@ diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 88c42d1f..6acd9453 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -284,13 +284,11 @@ {/snippet} diff --git a/src/ui/PodcastView/styles.test.ts b/src/ui/PodcastView/styles.test.ts index 26b84343..cf70f5d9 100644 --- a/src/ui/PodcastView/styles.test.ts +++ b/src/ui/PodcastView/styles.test.ts @@ -30,7 +30,7 @@ describe("reduced motion", () => { "EpisodeRow", '
', ".pn-eq i", - ["", "pn-EpisodeRow-pn-eq 1s ease-in-out infinite"], + ["", "pn-eq 1s ease-in-out infinite"], ], ])( "%s stops moving when the user prefers reduced motion", From 1f835b3170ed5ab9e4d7d210cbea4ccaa3380f3e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 01:43:42 +0200 Subject: [PATCH 13/39] refactor(ui): style a chapter's name with Panel's rows ChaptersPanel's sheet held one rule, the weight of a chapter's name. Panel already styles its rows and their time, so it styles the name beside it, and ChaptersPanel no longer ships a sheet of its own. --- src/ui/PodcastView/ChaptersPanel.svelte | 8 +------- src/ui/PodcastView/Panel.svelte | 4 ++++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/ui/PodcastView/ChaptersPanel.svelte b/src/ui/PodcastView/ChaptersPanel.svelte index 2b159967..ff516482 100644 --- a/src/ui/PodcastView/ChaptersPanel.svelte +++ b/src/ui/PodcastView/ChaptersPanel.svelte @@ -31,7 +31,7 @@
  • {/each} - - diff --git a/src/ui/PodcastView/Panel.svelte b/src/ui/PodcastView/Panel.svelte index 2f23d374..a08a819e 100644 --- a/src/ui/PodcastView/Panel.svelte +++ b/src/ui/PodcastView/Panel.svelte @@ -117,6 +117,10 @@ color: var(--text-accent); } + .pn-row .nm { + font-weight: var(--font-medium); + } + .is-wide & { .pn-panel-scroll { padding: var(--size-4-3) var(--size-4-4) var(--size-4-4); From 28b17890dc66ea47ad8ad64605386ec8b8df7c29 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 01:47:12 +0200 Subject: [PATCH 14/39] fix(ui): open a saved transcript note when the feed transcript is missing With a transcript note saved before, the Transcript panel offered Open note only once the feed's transcript loaded. When it did not load (an offline restart) the panel showed only Retry, and when the feed has none (the note came from a paid transcription) it offered to transcribe again. Both now offer Open note, in place of the paid transcription, through the same button as the panel's head, which keeps its label outside the head. --- src/ui/PodcastView/TranscriptPanel.svelte | 34 ++++++++++------- src/ui/PodcastView/TranscriptPanel.test.ts | 44 ++++++++++++++++++++++ src/ui/PodcastView/styles.test.ts | 18 +++++++++ 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 6acd9453..d270585b 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -172,6 +172,7 @@ Transcript didn't load + {#if note}{@render saveNote()}{/if} {:else if load.kind === "none"}
    @@ -181,7 +182,9 @@ ? "No transcript yet" : "No transcript in this feed"} - {#if audio} + {#if note} + {@render saveNote()} + {:else if audio} - {@const label = note ? "Open note" : "Save as note"} - + {@render saveNote()} {/if} {/snippet} +{#snippet saveNote()} + {@const label = note ? "Open note" : "Save as note"} + +{/snippet} + diff --git a/src/ui/PodcastView/styles.test.ts b/src/ui/PodcastView/styles.test.ts index 9c364fcb..7a0ae414 100644 --- a/src/ui/PodcastView/styles.test.ts +++ b/src/ui/PodcastView/styles.test.ts @@ -1,9 +1,13 @@ // @vitest-environment jsdom -import postcss from "postcss"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import postcss, { type AtRule, type Node } from "postcss"; +import { compile } from "svelte/compiler"; import { describe, expect, test } from "vitest"; import { componentCss, styled } from "../../../tests/mocks/componentCss"; const REDUCE = "(prefers-reduced-motion: reduce)"; +const UI = path.resolve(import.meta.dirname, ".."); /** `html` in the view, with `{}` standing for the component's own scoping class. */ const inView = (component: string, html: string) => @@ -72,16 +76,21 @@ describe("focus ring", () => { }); describe("hover", () => { - const TOUCHED = ["PodcastView", "EpisodeRow", "QueueRow", "Panel", "PlaylistsPage", "Deck"]; const HOVER = "(hover: hover)"; - test("only a device that can hover lights up the view's controls and rows", () => { + test("only a device that can hover lights up anything in the view", () => { const unguarded: string[] = []; - postcss.parse(componentCss(TOUCHED)).walkRules((rule) => { - const media = rule.parent?.type === "atrule" ? rule.parent : undefined; - if (rule.selector.includes(":hover") && media?.params !== HOVER) - unguarded.push(rule.selector); - }); + for (const file of readdirSync(UI, { recursive: true, encoding: "utf8" })) { + if (!file.endsWith(".svelte")) continue; + const { css } = compile(readFileSync(path.join(UI, file), "utf8"), { css: "external" }); + postcss.parse(css?.code ?? "").walkRules((rule) => { + let guarded = false; + for (let at: Node | undefined = rule.parent; at; at = at.parent) + if (at.type === "atrule" && (at as AtRule).params === HOVER) guarded = true; + if (rule.selector.includes(":hover") && !guarded) + unguarded.push(`${file} ${rule.selector}`); + }); + } expect(unguarded).toEqual([]); }); @@ -103,6 +112,13 @@ describe("hover", () => { ".pn-skip", "", ], + [ + "the panel switch that is on", + "Player", + '
    ', + ".pn-sw", + "var(--background-modifier-border)", + ], ])( "%s keeps its resting background after a tap", (_name, component, html, selector, resting) => { From 7c7226047894cf5384fca118c914a39dd1f5ef19 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 01:55:08 +0200 Subject: [PATCH 17/39] fix(ui): clip a text cover's name after its third line The clamped name had a 10cqw padding inside its overflow clip, so the line after the third painted into that padding: on the 93 px card of a 320 px view, four lines had their middle inside the clip. A margin puts the clip edge under the third line and keeps the name where it was. The e2e test now counts those lines. --- src/ui/PodcastView/Cover.svelte | 7 ++++--- tests/e2e/view-polish.test.ts | 28 ++++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/ui/PodcastView/Cover.svelte b/src/ui/PodcastView/Cover.svelte index 9ac209e6..ec1ec7c5 100644 --- a/src/ui/PodcastView/Cover.svelte +++ b/src/ui/PodcastView/Cover.svelte @@ -50,15 +50,16 @@ overflow-wrap: anywhere; container-type: inline-size; - /* cqw is the cover's width. A percentage padding on the cover is its row's. Three - lines keep a long title's beginning, at a size a row's small cover can show. */ + /* cqw is the cover's width. Three lines keep a long title's beginning, at a size a + row's small cover can show. A padding would show the fourth line in it, above the + clip edge; a margin keeps that edge under the third. */ > span { display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; - padding: 10cqw; + margin: 10cqw; font-size: max(10px, 12cqw); } } diff --git a/tests/e2e/view-polish.test.ts b/tests/e2e/view-polish.test.ts index fb8d8147..a4156624 100644 --- a/tests/e2e/view-polish.test.ts +++ b/tests/e2e/view-polish.test.ts @@ -36,9 +36,10 @@ type CoverText = Readonly<{ firstInside: boolean; bottomGap: number; fontSize: number; + lines: number; }>; -/** Where a text cover draws its title's first letter and its last line. */ +/** Where a text cover draws its title's first letter and its last line, and how many lines show. */ function coverText(selector: string): Promise { return getContext().obsidian.dev.evalJson(`(() => [...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} ${selector} .pn-cover.is-text`)})].map((cover) => { @@ -48,16 +49,25 @@ function coverText(selector: string): Promise { range.setStart(text.firstChild, 0); range.setEnd(text.firstChild, 1); const first = range.getBoundingClientRect(); + range.selectNodeContents(text); + const shown = text.getBoundingClientRect(); + const bottom = Math.min(shown.bottom, box.bottom); + const lines = new Set( + [...range.getClientRects()] + .map((line) => Math.round((line.top + line.bottom) / 2)) + .filter((middle) => middle > shown.top && middle < bottom), + ); return { size: Math.round(box.width), firstInside: first.top >= box.top && first.bottom <= box.bottom && first.left >= box.left, bottomGap: Math.round(box.bottom - text.getBoundingClientRect().bottom), fontSize: parseFloat(getComputedStyle(text).fontSize), + lines: lines.size, }; }))()`); } -test("at 320 px, a podcast without artwork shows the start of its long name on its card and rows", async () => { +test("at 320 px, a podcast without artwork shows the start of its long name in three lines on its card and rows", async () => { const context = getContext(); const server = await startFixtureServer([LONG]); onTestFinished(() => server.stop()); @@ -67,13 +77,23 @@ test("at 320 px, a podcast without artwork shows the start of its long name on i await showLibrary(context.obsidian); const [card] = await coverText(".pn-gridcard"); - expect(card).toMatchObject({ firstInside: true, bottomGap: 0 }); + expect(card).toMatchObject({ + firstInside: true, + bottomGap: Math.round(card.size / 10), + lines: 3, + }); expect(card.fontSize).toBeGreaterThanOrEqual(10); await showList(context.obsidian, "Latest episodes"); await waitUntilShown(context.obsidian, ui.episode("First Steps")); const [row] = await coverText(".pn-ep-main"); - expect(row).toMatchObject({ size: 40, firstInside: true, bottomGap: 0, fontSize: 10 }); + expect(row).toMatchObject({ + size: 40, + firstInside: true, + bottomGap: 4, + fontSize: 10, + lines: 3, + }); }); type Look = Readonly<{ opacity: number; dimmedBy: number }>; From a824008e929143d8cf55c0142c2c92073aec5aca Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 01:57:20 +0200 Subject: [PATCH 18/39] test(e2e): on a phone, the Escape that closes a row menu keeps focus on its button A phone hides Obsidian's menu after a 150 ms animation, and Obsidian sends focus from a sidebar to the main area on an Escape nothing handled. The test opens a row's menu in the right drawer, where PodNotes opens on a phone, presses Escape with trusted input, and reads focus in the task after the keydown and 200 ms later: on the row's More button both times. --- tests/e2e/view-polish.test.ts | 83 +++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/tests/e2e/view-polish.test.ts b/tests/e2e/view-polish.test.ts index a4156624..c3563a29 100644 --- a/tests/e2e/view-polish.test.ts +++ b/tests/e2e/view-polish.test.ts @@ -1,12 +1,18 @@ import { expect, onTestFinished, test } from "vitest"; import { createPodNotesE2EHarness } from "./harness"; -import { setOffline, showLibrary, showList, showPodNotes } from "./journey/actions"; -import { click } from "./journey/input"; +import { + setMobileEmulation, + setOffline, + showLibrary, + showList, + showPodNotes, +} from "./journey/actions"; +import { cdp, click, pressKey } from "./journey/input"; import { PLAYER_VIEW, ui } from "./journey/targets"; -import { eventually, waitUntilShown } from "./journey/wait"; +import { eventually, isShown, waitUntilShown } from "./journey/wait"; import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; import { seedLibrary } from "./journey/library"; -import { jsLiteral, RELOAD_OPTIONS } from "./runtime"; +import { jsLiteral, RELOAD_OPTIONS, VIEW_TYPE } from "./runtime"; const getContext = createPodNotesE2EHarness("view-polish"); @@ -162,3 +168,72 @@ test("a feed address missing from this device wraps in the banner, and offline e await showList(obsidian, "Latest episodes"); expect(await look('.pn-ep-main[aria-disabled="true"]')).toEqual({ opacity: 0.7, dimmedBy: 1 }); }); + +test("on a phone, the Escape that closes a row's menu in the right drawer leaves focus on the row's More button", async () => { + const context = getContext(); + const { obsidian } = context; + const phone = { width: 390, height: 844 }; + await cdp(obsidian, "Emulation.setDeviceMetricsOverride", { + ...phone, + deviceScaleFactor: 1, + mobile: true, + screenWidth: phone.width, + screenHeight: phone.height, + }); + onTestFinished(async () => { + await cdp(obsidian, "Emulation.clearDeviceMetricsOverride", {}); + }); + await setMobileEmulation(obsidian, true); + const server = await startFixtureServer([LONG]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [LONG] }); + // Where PodNotes opens on a phone. Obsidian sends focus from a sidebar to the main area + // on an Escape nothing handled. + await obsidian.dev.evalJson(`app.workspace.detachLeavesOfType(${jsLiteral(VIEW_TYPE)}), true`); + await showPodNotes(obsidian); + expect( + await obsidian.dev.evalJson( + `Boolean(document.querySelector(${jsLiteral(PLAYER_VIEW)})?.closest(".workspace-drawer.mod-right"))`, + ), + "in the right drawer", + ).toBe(true); + await showList(obsidian, "Latest episodes"); + await click(obsidian, ui.episodeMenu("First Steps")); + await waitUntilShown(obsidian, ui.menuItem("Add to queue")); + // Obsidian gives a menu the keys a timer tick after it shows. + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + '(app.keymap.getWindowStack(activeWindow).scope.keys ?? []).some((key) => key.key === "ArrowDown")', + ), + { timeoutMs: 5_000, intervalMs: 50, message: "the menu to take the keys" }, + ); + // Where focus is in the task after the Escape, and 200 ms later, past the menu's 150 ms hide. + await obsidian.dev.evalJson(`(() => { + const more = (${ui.episodeMenu("First Steps").elements})[0]; + const view = document.querySelector(${jsLiteral(PLAYER_VIEW)}); + const at = () => ({ onMore: document.activeElement === more, inView: view.contains(document.activeElement) }); + window.pnEscapeFocus = []; + activeWindow.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + setTimeout(() => window.pnEscapeFocus.push(at())); + setTimeout(() => window.pnEscapeFocus.push(at()), 200); + }, { capture: true, once: true }); + return true; + })()`); + + await pressKey(obsidian, "Escape"); + + expect( + await eventually(obsidian, () => obsidian.dev.evalJson("window.pnEscapeFocus"), [ + { onMore: true, inView: true }, + { onMore: true, inView: true }, + ]), + ).toEqual([ + { onMore: true, inView: true }, + { onMore: true, inView: true }, + ]); + expect( + await eventually(obsidian, () => isShown(obsidian, ui.menuItem("Add to queue")), false), + ).toBe(false); +}); From 1e12314b9e7e4d5a89ff1f0203f35d753d711d4c Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 02:03:16 +0200 Subject: [PATCH 19/39] test(e2e): restore the plugin's links after an update test that failed part way The update-styles test swaps PodNotes' artifact symlinks for files and puts them back in its finally. An install that failed after removing main.js made that finally throw ENOENT before relinking it, which left the vault's PodNotes without a bundle. It now removes with force. --- tests/e2e/update-styles.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/update-styles.test.ts b/tests/e2e/update-styles.test.ts index 7c0ac128..885100af 100644 --- a/tests/e2e/update-styles.test.ts +++ b/tests/e2e/update-styles.test.ts @@ -182,8 +182,9 @@ test.each(["main window", "popout"] as const)( "the view's window", ).toBe(placement === "popout"); } finally { + // An install that failed part way may have removed an artifact already. for (const name of ARTIFACTS) { - await rm(path.join(dir, name)); + await rm(path.join(dir, name), { force: true }); await symlink(links[name], path.join(dir, name)); } if (placement === "popout") await closePopoutAndReload(obsidian); From 62a2119c497744cbc59f59280509f549f68eda3b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 02:06:22 +0200 Subject: [PATCH 20/39] refactor(ui): drop declarations that restate Obsidian's defaults Every element the button rules style is a button, and Obsidian's `button` rule already sets the same display, alignment, height, radius, font size, cursor and white-space. No other Obsidian rule that reaches a button in the view sets those properties, so the cascade resolves to the same values. Obsidian also hides a search field's cancel button. --- src/ui/PodcastView/AddPodcastPage.svelte | 3 --- src/ui/PodcastView/MiniPlayer.svelte | 1 - src/ui/PodcastView/PlaylistsPage.svelte | 1 - src/ui/PodcastView/PodcastView.svelte | 12 ------------ src/ui/PodcastView/Rail.svelte | 4 ---- src/ui/PodcastView/SearchField.svelte | 4 ---- src/ui/PodcastView/TranscriptPanel.svelte | 2 -- 7 files changed, 27 deletions(-) diff --git a/src/ui/PodcastView/AddPodcastPage.svelte b/src/ui/PodcastView/AddPodcastPage.svelte index 864dbec2..57789fbb 100644 --- a/src/ui/PodcastView/AddPodcastPage.svelte +++ b/src/ui/PodcastView/AddPodcastPage.svelte @@ -167,8 +167,6 @@ margin-top: var(--size-4-2); .pn-alt { - display: inline-flex; - align-items: center; gap: var(--size-4-2); height: var(--pn-btn); padding: 0 var(--size-4-2); @@ -194,7 +192,6 @@ /* Obsidian's buttons keep their text on one line; this one wraps its title. */ .pn-result { display: flex; - align-items: center; gap: var(--size-4-3); width: 100%; height: auto; diff --git a/src/ui/PodcastView/MiniPlayer.svelte b/src/ui/PodcastView/MiniPlayer.svelte index 6374de2a..a5d12124 100644 --- a/src/ui/PodcastView/MiniPlayer.svelte +++ b/src/ui/PodcastView/MiniPlayer.svelte @@ -116,7 +116,6 @@ flex: 1; min-width: 0; display: flex; - align-items: center; gap: var(--size-4-3); height: auto; padding: var(--size-4-1); diff --git a/src/ui/PodcastView/PlaylistsPage.svelte b/src/ui/PodcastView/PlaylistsPage.svelte index 87b13f36..0e2f87ee 100644 --- a/src/ui/PodcastView/PlaylistsPage.svelte +++ b/src/ui/PodcastView/PlaylistsPage.svelte @@ -54,7 +54,6 @@ .pn-playlists :global { .pn-plrow { display: flex; - align-items: center; gap: var(--size-4-3); width: 100%; height: auto; diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 9f9673d3..97718f9a 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -410,8 +410,6 @@ } .pn-back { - display: inline-flex; - align-items: center; gap: var(--size-2-1); min-width: 0; max-width: 70%; @@ -529,9 +527,6 @@ } .pn-iconbtn { - display: inline-flex; - align-items: center; - justify-content: center; flex: none; width: var(--pn-btn); height: var(--pn-btn); @@ -573,14 +568,8 @@ } .pn-btn { - display: inline-flex; - align-items: center; - justify-content: center; gap: var(--size-4-2); - height: var(--input-height); padding: 0 var(--size-4-3); - border-radius: var(--button-radius); - white-space: nowrap; } .pn-btn.mod-cta { @@ -601,7 +590,6 @@ .pn-navitem { display: flex; - align-items: center; gap: var(--size-4-2); width: 100%; height: auto; diff --git a/src/ui/PodcastView/Rail.svelte b/src/ui/PodcastView/Rail.svelte index 748ecbdc..36fbf735 100644 --- a/src/ui/PodcastView/Rail.svelte +++ b/src/ui/PodcastView/Rail.svelte @@ -53,21 +53,17 @@ .pn-railsearch { display: flex; - align-items: center; justify-content: flex-start; gap: var(--size-4-2); flex: none; width: 100%; - height: var(--input-height); padding: 0 var(--size-4-2); border: 1px solid var(--background-modifier-border); border-radius: var(--input-radius); background: var(--background-modifier-form-field); box-shadow: none; color: var(--text-muted); - font-size: var(--font-ui-small); text-align: left; - cursor: var(--cursor); } @media (hover: hover) { diff --git a/src/ui/PodcastView/SearchField.svelte b/src/ui/PodcastView/SearchField.svelte index 56f9ab72..039f1958 100644 --- a/src/ui/PodcastView/SearchField.svelte +++ b/src/ui/PodcastView/SearchField.svelte @@ -90,10 +90,6 @@ color: var(--text-muted); } - .pn-input::-webkit-search-cancel-button { - display: none; - } - button.pn-clear { position: absolute; right: 2px; diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 43fd2ed9..417a32d9 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -363,8 +363,6 @@ } .pn-tx-follow { - display: inline-flex; - align-items: center; gap: 6px; height: 28px; padding: 0 var(--size-4-3); From c4650f9130afd03963e1875a30b77eb9056f2ca7 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 02:08:19 +0200 Subject: [PATCH 21/39] refactor(ui): draw a transcript that failed or is missing as one empty state The two states shared their frame, icon and note button, and differ in the icon, the sentence and whether Retry or the paid transcription shows. --- src/ui/PodcastView/TranscriptPanel.svelte | 31 ++++++++++++---------- src/ui/PodcastView/TranscriptPanel.test.ts | 9 ++++++- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 417a32d9..a050f681 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -167,24 +167,27 @@ > {#if load.kind === "loading"}
    Loading transcript
    - {:else if load.kind === "error"} - - {:else if load.kind === "none"} -
    - + {:else if load.kind !== "ready"} + {@const failed = load.kind === "error"} +
    + {isLocalFile(episode) - ? "No transcript yet" - : "No transcript in this feed"}{failed + ? "Transcript didn't load" + : isLocalFile(episode) + ? "No transcript yet" + : "No transcript in this feed"} + {#if failed} + + {/if} {#if note} {@render saveNote()} - {:else if audio} + {:else if !failed && audio} {/each} diff --git a/src/ui/PodcastView/PodcastView.shell.test.ts b/src/ui/PodcastView/PodcastView.shell.test.ts index fbbf90f2..aea8dfb4 100644 --- a/src/ui/PodcastView/PodcastView.shell.test.ts +++ b/src/ui/PodcastView/PodcastView.shell.test.ts @@ -170,7 +170,7 @@ describe("library", () => { name, episodes, }); - const flair = (name: RegExp) => button(name).querySelector(".flair")?.textContent; + const flair = (name: RegExp) => button(name).querySelector(".pn-flair")?.textContent; test("is the one the view's services hold", async () => { const { library } = services; @@ -281,7 +281,7 @@ describe("list pages", () => { expect(screen.getByRole("heading", { name: "Walks" }).nextElementSibling).toHaveTextContent( "1", ); - expect(document.querySelectorAll(".flair")).toHaveLength(1); + expect(document.querySelectorAll(".pn-flair")).toHaveLength(1); }); test("a playlist of the user's deletes from its head and leaves for Playlists; Favorites has no menu", async () => { diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 97718f9a..4dc33a39 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -136,7 +136,7 @@ {:else} {#if head.title}

    {head.title}

    - {#if head.flair}{head.flair}{/if} + {#if head.flair}{head.flair}{/if} {/if} {/if} @@ -215,7 +215,7 @@ {#if head.pageTitle}

    {head.pageTitle}

    - {#if head.flair}{head.flair}{/if} + {#if head.flair}{head.flair}{/if}
    {/if} {#if page.kind === "library"} @@ -399,7 +399,7 @@ text-overflow: ellipsis; } - .pn-head .flair { + .pn-head .pn-flair { align-self: center; margin-left: var(--size-4-2); padding-top: 3px; @@ -623,21 +623,15 @@ text-overflow: ellipsis; } - /* Obsidian styles .flair as an uppercase tag; here it is plain muted text. */ - .flair { + .pn-flair { flex: none; - margin-inline-start: 0; - padding: 0; - background: none; - letter-spacing: normal; - text-transform: none; color: var(--text-muted); font-size: var(--font-ui-small); white-space: nowrap; font-variant-numeric: tabular-nums; } - .pn-navitem .flair { + .pn-navitem .pn-flair { font-size: var(--font-ui-smaller); } diff --git a/src/ui/PodcastView/Rail.svelte b/src/ui/PodcastView/Rail.svelte index 36fbf735..0c60ae05 100644 --- a/src/ui/PodcastView/Rail.svelte +++ b/src/ui/PodcastView/Rail.svelte @@ -26,7 +26,7 @@ > {root.label} - {#if counts[root.kind]}{counts[root.kind]}{/if} + {#if counts[root.kind]}{counts[root.kind]}{/if} {/each}
    From 7b5598608ec20b4e76e1cfb8c3967918893bd466 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 02:15:29 +0200 Subject: [PATCH 23/39] test(e2e): offline, a private feed shows no failure from before it went offline Offline, a load is not attempted, so the journey that restarts offline records no failure, and showing every recorded failure offline passed it. The journey now fails a refresh online first, and checks that going offline hides that failure behind the Offline strip. --- tests/e2e/private-feeds.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/e2e/private-feeds.test.ts b/tests/e2e/private-feeds.test.ts index 7ecdb9d8..136b15b5 100644 --- a/tests/e2e/private-feeds.test.ts +++ b/tests/e2e/private-feeds.test.ts @@ -102,7 +102,7 @@ test("a private feed first opened offline explains that its address is not on th ).toBeFalsy(); }); -test("a private feed whose address is on this device, opened offline hours later, lists the episodes it cached and no missing address", async () => { +test("a private feed whose address is on this device, opened offline hours later, lists the episodes it cached and no missing address or earlier failure", async () => { const context = getContext(); const { obsidian, plugin } = context; const show: FixtureFeed = { @@ -141,10 +141,17 @@ test("a private feed whose address is on this device, opened offline hours later timeoutMs: 10_000, message: "the feed cached", }); - await closePodNotes(obsidian); - await ageFeedCache(obsidian, 7 * 60 * 60 * 1000); server.disconnect(); + await click(obsidian, ui.refreshEpisodes); + await waitUntilShown(obsidian, ui.feedError); await setOffline(obsidian, true); + await waitUntilShown(obsidian, ui.offlineStrip); + expect( + await holdsFor(obsidian, () => isShown(obsidian, ui.feedError), false), + "offline, the Offline strip says why the last load failed", + ).toBe(false); + await closePodNotes(obsidian); + await ageFeedCache(obsidian, 7 * 60 * 60 * 1000); await plugin.reload(RELOAD_OPTIONS); await showPodNotes(obsidian); await showLibrary(obsidian); From 6ee5a08c760e9b2f39d8e652673de181b351c3dc Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 02:33:27 +0200 Subject: [PATCH 24/39] fix(ui): name the Local files page as it is spelled in its menu item The episode menu said "Remove from Local Files" while the page is "Local files". The stored playlist keeps its name. --- src/ui/PodcastView/menus.test.ts | 2 +- src/ui/PodcastView/menus.ts | 2 +- tests/e2e/local-files-runtime.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ui/PodcastView/menus.test.ts b/src/ui/PodcastView/menus.test.ts index 0b9634cf..2b4649db 100644 --- a/src/ui/PodcastView/menus.test.ts +++ b/src/ui/PodcastView/menus.test.ts @@ -204,7 +204,7 @@ describe("an episode's menu", () => { expect(titles(menu(local))).not.toContain("Download"); downloadedEpisodes.addEpisode(local, "Recordings/Interview.wav", 4); - expect(titles(menu(local))).toContain("Remove from Local Files"); + expect(titles(menu(local))).toContain("Remove from Local files"); }); test("Favorites asks for the state the menu showed, so a second click changes nothing", () => { diff --git a/src/ui/PodcastView/menus.ts b/src/ui/PodcastView/menus.ts index 6fbef7f4..52606fa8 100644 --- a/src/ui/PodcastView/menus.ts +++ b/src/ui/PodcastView/menus.ts @@ -213,7 +213,7 @@ export function episodeMenu( // Removing a local file forgets it and never trashes it. entries.push( item( - "Remove from Local Files", + "Remove from Local files", "trash", () => void removeDownloadedEpisode(episode), ), diff --git a/tests/e2e/local-files-runtime.test.ts b/tests/e2e/local-files-runtime.test.ts index 1d8b4fcd..d18f5bef 100644 --- a/tests/e2e/local-files-runtime.test.ts +++ b/tests/e2e/local-files-runtime.test.ts @@ -64,7 +64,7 @@ describe("local files runtime", () => { ); await showList(obsidian, "Local files"); await clickInPodNotesView(obsidian, `[aria-label="More options for ${title}"]`); - const removalMenu = await clickMenuItem(obsidian, "Remove from Local Files"); + const removalMenu = await clickMenuItem(obsidian, "Remove from Local files"); expect(removalMenu).not.toContain("Remove download"); await plugin.waitForData( (data) => !inDownloads(data) && !inLocalFiles(data), @@ -205,7 +205,7 @@ describe("local files runtime", () => { ); await clickInPodNotesView(obsidian, `[aria-label="More options for ${legacy}"]`); - await clickMenuItem(obsidian, "Remove from Local Files"); + await clickMenuItem(obsidian, "Remove from Local files"); await plugin.waitForData( (data) => !inDownloads(data, "local file", legacy), WAIT_OPTS, From 5bd6ad4826d9b557381526863e38f3d9b5ff3c07 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 02:34:13 +0200 Subject: [PATCH 25/39] fix(ui): say that a second download to a shared path fails Without {{title}} in the download path, episodes resolve to the same file. The download code then refuses the second one, with "A file already exists" when another episode's file is there and "A download is already in progress" while the first is still writing, so nothing is overwritten. The warning said downloads overwrite each other. --- src/ui/settings/PodNotesSettingsTab.test.ts | 2 +- src/ui/settings/PodNotesSettingsTab.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index 5211d8c4..7a2fb5f4 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -520,7 +520,7 @@ describe("text fields", () => { retype(path, "PodNotes/{{podcast}}"); expect(warning()).toBe( - "Without {{title}}, episodes share one file and downloads overwrite each other. Add it, for example PodNotes/{{podcast}}/{{title}}.", + "Without {{title}}, episodes share a file name and a second download to it fails. Add it, for example PodNotes/{{podcast}}/{{title}}.", ); type(path, "/{{title}}"); expect(warning()).toBeUndefined(); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index b8b9c643..03088835 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -387,7 +387,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { setting.setErrorMessage( /\{\{title(:[^}]*)?\}\}/i.test(value) ? null - : "Without {{title}}, episodes share one file and downloads overwrite each other. Add it, for example PodNotes/{{podcast}}/{{title}}.", + : "Without {{title}}, episodes share a file name and a second download to it fails. Add it, for example PodNotes/{{podcast}}/{{title}}.", ); }, }), From 71bd0e6d4e47d22eb9c4c30116faac38fe897606 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 02:38:11 +0200 Subject: [PATCH 26/39] fix(ui): fold the transcript's Save into its icon wherever the head is narrow The label folded away on the view's compact desktop layout, so in a phone's right drawer, about 330 px wide, "Save as note" ran past the panel's edge. The panel head is now a size container, and Save folds when the head has less than the 340 px the phone's label needs: in a 320 px sidebar and in the drawer, not in a 390 px phone's main area. The CSS mock applies a container query the way it applies a media query. --- src/ui/PodcastView/TranscriptPanel.svelte | 21 ++++--- src/ui/PodcastView/styles.test.ts | 8 ++- tests/e2e/journey-transcript.test.ts | 71 +++++++++++++++++++++++ tests/mocks/componentCss.ts | 8 +-- 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index a050f681..410bc1ba 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -384,14 +384,19 @@ } } - .is-compact:not(.is-phone) & .pn-panel-head { - .pn-tx-save { - width: 28px; - padding: 0; - } - - .pn-tx-save span:last-child { - display: none; + /* Save keeps its label where the head has room for it, wherever the panel is. */ + .pn-panel-head { + container-type: inline-size; + + @container (width < 340px) { + .pn-tx-save { + width: 28px; + padding: 0; + } + + .pn-tx-save span:last-child { + display: none; + } } } diff --git a/src/ui/PodcastView/styles.test.ts b/src/ui/PodcastView/styles.test.ts index 7a0ae414..b8e07d6d 100644 --- a/src/ui/PodcastView/styles.test.ts +++ b/src/ui/PodcastView/styles.test.ts @@ -138,13 +138,15 @@ describe("hover", () => { }); describe("the transcript's note button", () => { - test("shows only its icon in a compact panel's head, and its label in an empty panel", () => { - const css = componentCss(["PodcastView", "TranscriptPanel"]); + test("shows only its icon in a narrow panel's head, and its label in an empty panel", () => { + const css = componentCss(["PodcastView", "TranscriptPanel"], { + media: ["(width < 340px)"], + }); const button = ''; styled( css, - `
    ${button}
    ${button}
    `, + `
    ${button}
    ${button}
    `, ".pn-tx", ); const label = (place: string) => diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 4f4eb440..90475501 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -177,6 +177,32 @@ function phoneGeometry(): Promise { })()`); } +type TranscriptHead = Readonly<{ + panel: number; + saveInside: boolean; + label: boolean; + headFits: boolean; +}>; + +/** How the transcript panel's head fits the panel: Save inside it, with its label or without. */ +function transcriptHead(): Promise { + return getContext().obsidian.dev.evalJson(`(() => { + const panel = document.querySelector(${jsLiteral(`${PLAYER_VIEW} section.pn-player .pn-panel`)}); + const head = panel.querySelector(".pn-panel-head"); + const save = head.querySelector(".pn-tx-save"); + const label = save.lastElementChild; + const shown = label.getClientRects().length > 0; + const box = panel.getBoundingClientRect(); + const right = Math.max(save.getBoundingClientRect().right, shown ? label.getBoundingClientRect().right : 0); + return { + panel: Math.round(box.width), + saveInside: right <= box.right + 0.5, + label: shown, + headFits: head.scrollWidth <= head.clientWidth, + }; + })()`); +} + describe("transcript journeys", () => { test("switching episodes clears Find and shows every line of the incoming transcript", async () => { const context = getContext(); @@ -514,4 +540,49 @@ describe("transcript journeys", () => { expect(list?.bottom, "its list fills the stage under its head").toBe(stage.bottom); } }, 120_000); + test("the panel's head fits Save, without its label in a 320 px sidebar and a phone's drawer, with it in a phone's main area", async () => { + const context = getContext(); + const { obsidian } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, CAPTIONED), + }); + const openPanel = async () => { + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await waitUntilShown(obsidian, ui.fullPlayer); + await click(obsidian, ui.playerPanel("Transcript")); + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); + return await transcriptHead(); + }; + await placePodNotes(obsidian, { sidebar: 320 }); + const sidebar = await openPanel(); + + const phone = { width: 390, height: 844 }; + await cdp(obsidian, "Emulation.setDeviceMetricsOverride", { + ...phone, + deviceScaleFactor: 1, + mobile: true, + screenWidth: phone.width, + screenHeight: phone.height, + }); + onTestFinished(async () => { + await cdp(obsidian, "Emulation.clearDeviceMetricsOverride", {}); + }); + await setMobileEmulation(obsidian, true); + await obsidian.dev.evalJson( + `app.workspace.detachLeavesOfType("podcast_player_view"), true`, + ); + await showPodNotes(obsidian); + const drawer = await openPanel(); + await placePodNotes(obsidian, "main area"); + const mainArea = await openPanel(); + + const narrow = { saveInside: true, label: false, headFits: true }; + expect({ sidebar, drawer, mainArea }).toMatchObject({ + sidebar: narrow, + drawer: narrow, + mainArea: { saveInside: true, label: true, headFits: true }, + }); + }, 120_000); }); diff --git a/tests/mocks/componentCss.ts b/tests/mocks/componentCss.ts index 440ceeda..2e090fa9 100644 --- a/tests/mocks/componentCss.ts +++ b/tests/mocks/componentCss.ts @@ -9,9 +9,9 @@ const COMPONENTS = path.resolve(import.meta.dirname, "../../src/ui/PodcastView") * The CSS of the view's components as jsdom cascades it. Each component is scoped by the * class `pn-`, so markup takes that class where its own sheet scopes an element. * Nesting is flattened, which jsdom lacks, and sheets keep the order given: shell first, as - * it mounts first. jsdom applies no media query but `all`, so `media` names the queries that - * match, as the user's device or setting would make them. `states` turns pseudo-classes such - * as `:focus-visible` into classes a test can set. + * it mounts first. jsdom applies no media or container query but `all`, so `media` names the + * queries that match, as the user's device, setting or the container's size would make them. + * `states` turns pseudo-classes such as `:focus-visible` into classes a test can set. */ export function componentCss( files: readonly string[], @@ -35,7 +35,7 @@ export function componentCss( postcss.rule({ selectors: own, nodes: declarations.map((d) => d.clone()) }), ); for (const child of node.nodes) if (child.type !== "decl") visit(child, own, within); - } else if (node.type === "atrule" && node.name === "media") { + } else if (node.type === "atrule" && (node.name === "media" || node.name === "container")) { const query = media.includes(node.params) ? "all" : node.params; const copy = postcss.atRule({ name: "media", params: query }); within.append(copy); From 0b1da27572a13e52cc559acdf2788d3ab99827a8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 02:44:47 +0200 Subject: [PATCH 27/39] fix(ui): follow a transcript line with the line before it in full view Following kept the line playing now a third of the way down the list. In a phone's right drawer, where the list is 248 px tall, that left the line before it 7 px above the list and under its faded top edge. The list declares that 12 px edge as scroll padding, and following now puts the line a third down or under the whole line before it and that edge, whichever is lower, but no lower than half way, so a long line before it does not push it out of view. The e2e helpers for the phone layout and the right drawer serve the transcript journeys' three phone tests. --- src/ui/PodcastView/Panel.svelte | 2 + src/ui/PodcastView/TranscriptPanel.svelte | 11 +- src/ui/PodcastView/TranscriptPanel.test.ts | 33 +++-- tests/e2e/journey-transcript.test.ts | 134 +++++++++++++++------ 4 files changed, 131 insertions(+), 49 deletions(-) diff --git a/src/ui/PodcastView/Panel.svelte b/src/ui/PodcastView/Panel.svelte index a08a819e..066a2bac 100644 --- a/src/ui/PodcastView/Panel.svelte +++ b/src/ui/PodcastView/Panel.svelte @@ -60,6 +60,8 @@ flex-direction: column; overflow-y: auto; padding: var(--size-4-2) 0 var(--size-4-4); + /* A line in the faded top edge is not in view. */ + scroll-padding-top: 12px; mask-image: linear-gradient( to bottom, transparent, diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 410bc1ba..75351917 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -116,12 +116,19 @@ ); } - /** Not scrollIntoView, which would also scroll the view and an editor around it. */ + /** + * A third of the way down, under the whole line before it, and in the upper half when that + * line is long. Not scrollIntoView, which would also scroll the view and an editor around it. + */ function reveal(at: number) { const row = list?.children[at]; if (!list || !row) return; const top = row.getBoundingClientRect().top - list.getBoundingClientRect().top; - list.scrollTop += top - list.clientHeight / 3; + const before = + (row.previousElementSibling?.getBoundingClientRect().height ?? 0) + + (parseFloat(list.win.getComputedStyle(list).scrollPaddingTop) || 0); + const height = list.clientHeight; + list.scrollTop += top - Math.min(Math.max(height / 3, before), height / 2); ownScrollTop = list.scrollTop; } diff --git a/src/ui/PodcastView/TranscriptPanel.test.ts b/src/ui/PodcastView/TranscriptPanel.test.ts index 6403ad58..74e4af11 100644 --- a/src/ui/PodcastView/TranscriptPanel.test.ts +++ b/src/ui/PodcastView/TranscriptPanel.test.ts @@ -177,13 +177,16 @@ const press = (key: string, target: Element = document.activeElement ?? document }; /** - * Lays the list's rows out 40 px apart in a list 120 px tall, as the browser would: its - * scroll position stays within the rows, and a change to it fires `scroll` soon after. - * Returns a resize to `height`, which clamps the scroll position to the rows that are left. + * Lays the list's rows out in a list 120 px tall, as the browser would, each 40 px tall or as + * `tall` sets it: its scroll position stays within the rows, and a change to it fires `scroll` + * soon after. Returns a resize to `height`, which clamps the scroll position to the rows left. */ -function layOut(list: HTMLElement): (height: number) => void { +function layOut(list: HTMLElement, tall: Record = {}): (height: number) => void { let top = 0; let height = 120; + const rowHeight = (index: number) => tall[index] ?? 40; + const rowTop = (index: number) => + Array.from({ length: index }, (_, at) => rowHeight(at)).reduce((sum, h) => sum + h, 0); const scrollTo = (value: number) => { const next = Math.max(0, Math.min(value, list.scrollHeight - height)); if (next === top) return; @@ -192,14 +195,16 @@ function layOut(list: HTMLElement): (height: number) => void { }; Object.defineProperties(list, { clientHeight: { configurable: true, get: () => height }, - scrollHeight: { configurable: true, get: () => list.children.length * 40 }, + scrollHeight: { configurable: true, get: () => rowTop(list.children.length) }, scrollTop: { configurable: true, get: () => top, set: scrollTo }, }); vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( this: Element, ) { - const y = this === list ? 0 : [...list.children].indexOf(this) * 40 - top; - return { top: y, bottom: y + 40, left: 0, right: 300 } as DOMRect; + const index = [...list.children].indexOf(this); + const y = this === list ? 0 : rowTop(index) - top; + const h = this === list ? height : rowHeight(index); + return { top: y, bottom: y + h, height: h, left: 0, right: 300 } as DOMRect; }); return (to) => { height = to; @@ -274,6 +279,20 @@ describe("a published transcript", () => { expect(get(services.player.state).status).toBe("paused"); }); + test("follows a line under the whole line before it, and in the upper half after a long one", async () => { + await openTranscript(episodeOf([VTT_LINK]), 0); + const list = await transcript(); + layOut(list, { 2: 55, 5: 200 }); + + await at(19); + await tick(); + expect(list.scrollTop, "row 3 at 135, under row 2 from its top").toBe(80); + + await at(37); + await tick(); + expect(list.scrollTop, "row 6 at 415, half way down").toBe(355); + }); + test("keeps following when a taller panel pulls the list back, and stops when the reader scrolls", async () => { await openTranscript(episodeOf([VTT_LINK]), 0); const list = await transcript(); diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 90475501..3632ac5a 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -29,7 +29,7 @@ import { waitForPlayback, writeVaultFile, } from "./journey/library"; -import { jsLiteral, PLUGIN_ID } from "./runtime"; +import { jsLiteral, PLUGIN_ID, VIEW_TYPE } from "./runtime"; const getContext = createPodNotesE2EHarness("journey-transcript"); @@ -203,6 +203,59 @@ function transcriptHead(): Promise { })()`); } +type Followed = Readonly<{ line: string; clear: boolean; rows: number[] }>; + +/** + * The line playing now, and whether it and the line before it show whole: below the list's + * faded top 12 px and above its faded bottom 20 px. + */ +function followed(): Promise { + return getContext().obsidian.dev.evalJson(`(() => { + const list = ${TRANSCRIPT_LIST}; + const top = list.getBoundingClientRect().top; + const now = list.querySelector("[aria-current='true']").closest("li"); + const [before, after] = [now.previousElementSibling, now].map((row) => row.getBoundingClientRect()); + return { + line: now.lastElementChild.lastElementChild.textContent.trim(), + clear: before.top - top > 11.5 && after.bottom - top < list.clientHeight - 19.5, + rows: [before.top - top, after.top - top, after.bottom - top, list.clientHeight].map(Math.round), + }; + })()`); +} + +/** Obsidian's phone layout on a 390 x 844 screen, until the test ends. */ +async function asPhone(): Promise { + const { obsidian } = getContext(); + await cdp(obsidian, "Emulation.setDeviceMetricsOverride", { + width: 390, + height: 844, + deviceScaleFactor: 1, + mobile: true, + screenWidth: 390, + screenHeight: 844, + }); + onTestFinished(async () => { + await cdp(obsidian, "Emulation.clearDeviceMetricsOverride", {}); + }); + await setMobileEmulation(obsidian, true); +} + +/** PodNotes where it opens on a phone, the right drawer. */ +async function inRightDrawer(): Promise { + const { obsidian } = getContext(); + await obsidian.dev.evalJson(`app.workspace.detachLeavesOfType(${jsLiteral(VIEW_TYPE)}), true`); + await showPodNotes(obsidian); +} + +/** The full player's Transcript panel, once it lists every line. */ +async function openTranscriptPanel(): Promise { + const { obsidian } = getContext(); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await waitUntilShown(obsidian, ui.fullPlayer); + await click(obsidian, ui.playerPanel("Transcript")); + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); +} + describe("transcript journeys", () => { test("switching episodes clears Find and shows every line of the incoming transcript", async () => { const context = getContext(); @@ -463,18 +516,7 @@ describe("transcript journeys", () => { test("on a phone, the stage gets the room the deck can spare, and Save, the Find field, Follow and Edit are 44 px", async () => { const context = getContext(); const { obsidian } = context; - const phone = { width: 390, height: 844 }; - await cdp(obsidian, "Emulation.setDeviceMetricsOverride", { - ...phone, - deviceScaleFactor: 1, - mobile: true, - screenWidth: phone.width, - screenHeight: phone.height, - }); - onTestFinished(async () => { - await cdp(obsidian, "Emulation.clearDeviceMetricsOverride", {}); - }); - await setMobileEmulation(obsidian, true); + await asPhone(); expect( await obsidian.dev.evalJson('document.body.classList.contains("is-phone")'), "a phone", @@ -540,6 +582,7 @@ describe("transcript journeys", () => { expect(list?.bottom, "its list fills the stage under its head").toBe(stage.bottom); } }, 120_000); + test("the panel's head fits Save, without its label in a 320 px sidebar and a phone's drawer, with it in a phone's main area", async () => { const context = getContext(); const { obsidian } = context; @@ -548,35 +591,16 @@ describe("transcript journeys", () => { subscriptions: [FEED], current: storedEpisode(server, FEED, CAPTIONED), }); - const openPanel = async () => { - if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); - await waitUntilShown(obsidian, ui.fullPlayer); - await click(obsidian, ui.playerPanel("Transcript")); - await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); - return await transcriptHead(); - }; await placePodNotes(obsidian, { sidebar: 320 }); - const sidebar = await openPanel(); - - const phone = { width: 390, height: 844 }; - await cdp(obsidian, "Emulation.setDeviceMetricsOverride", { - ...phone, - deviceScaleFactor: 1, - mobile: true, - screenWidth: phone.width, - screenHeight: phone.height, - }); - onTestFinished(async () => { - await cdp(obsidian, "Emulation.clearDeviceMetricsOverride", {}); - }); - await setMobileEmulation(obsidian, true); - await obsidian.dev.evalJson( - `app.workspace.detachLeavesOfType("podcast_player_view"), true`, - ); - await showPodNotes(obsidian); - const drawer = await openPanel(); + await openTranscriptPanel(); + const sidebar = await transcriptHead(); + await asPhone(); + await inRightDrawer(); + await openTranscriptPanel(); + const drawer = await transcriptHead(); await placePodNotes(obsidian, "main area"); - const mainArea = await openPanel(); + await openTranscriptPanel(); + const mainArea = await transcriptHead(); const narrow = { saveInside: true, label: false, headFits: true }; expect({ sidebar, drawer, mainArea }).toMatchObject({ @@ -585,4 +609,34 @@ describe("transcript journeys", () => { mainArea: { saveInside: true, label: true, headFits: true }, }); }, 120_000); + + test("following shows the line playing now and the line before it whole, in a 320 px sidebar and a phone's drawer", async () => { + const context = getContext(); + const { obsidian } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, CAPTIONED), + progress: [ + { + episode: { podcastName: FEED.title, title: CAPTIONED.title }, + seconds: 33, + duration: 60, + finished: false, + }, + ], + }); + await placePodNotes(obsidian, { sidebar: 320 }); + await openTranscriptPanel(); + const sidebar = await followed(); + await asPhone(); + await inRightDrawer(); + await openTranscriptPanel(); + const drawer = await followed(); + + const line = LINES[5]; + expect([sidebar.line, drawer.line]).toEqual([line, line]); + expect(sidebar.clear, `sidebar rows ${sidebar.rows.join(", ")}`).toBe(true); + expect(drawer.clear, `drawer rows ${drawer.rows.join(", ")}`).toBe(true); + }, 120_000); }); From b41ff7d2ebd97d679f47e163537a7dd75edcf1c2 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 02:54:34 +0200 Subject: [PATCH 28/39] test(e2e): start each suite online, whatever the instance was left in view-polish timed out once waiting for a Latest row. That run reused an instance on which the verify skill's `screens.mjs offline on` had left the renderer offline through CDP: no feed loaded, the Latest page stayed empty, and the next run passed because the suite's offline test turns the network back on as it ends. Reproduced by the same commands, then a run of view-polish: "Timed out waiting for episode First Steps shown". The harness now brings the renderer online before the suite starts, and the reused-vault lifecycle test leaves it offline to check that. --- tests/e2e/harness-fixtures/reused-vault.fixture.ts | 3 ++- tests/e2e/harness-lifecycle.test.ts | 5 ++++- tests/e2e/harness.ts | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/e2e/harness-fixtures/reused-vault.fixture.ts b/tests/e2e/harness-fixtures/reused-vault.fixture.ts index efadf8c2..e2a8db26 100644 --- a/tests/e2e/harness-fixtures/reused-vault.fixture.ts +++ b/tests/e2e/harness-fixtures/reused-vault.fixture.ts @@ -3,8 +3,9 @@ import { createPodNotesE2EHarness } from "../harness"; const getContext = createPodNotesE2EHarness("harness-reused-vault"); -test("opens the collapsed left sidebar and loads only its own recorder, before PodNotes", async () => { +test("opens the collapsed left sidebar, is online and loads only its own recorder, before PodNotes", async () => { const { obsidian } = getContext(); + expect(await obsidian.dev.evalJson("navigator.onLine"), "online").toBe(true); const left = await obsidian.dev.evalJson<{ collapsed: boolean; size: number; diff --git a/tests/e2e/harness-lifecycle.test.ts b/tests/e2e/harness-lifecycle.test.ts index d516a85b..1dc256c8 100644 --- a/tests/e2e/harness-lifecycle.test.ts +++ b/tests/e2e/harness-lifecycle.test.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { createObsidianClient, resolveObsidianEnvOptions } from "obsidian-e2e"; import { acquireVaultRunLock } from "obsidian-e2e/vitest"; import { beforeAll, expect, test } from "vitest"; +import { setOffline } from "./journey/actions"; import { jsLiteral, runAllSteps } from "./runtime"; import { assertNoNewRuntimeErrors, @@ -303,7 +304,7 @@ test.each([ ); test.for([true, false])( - "recovers a stale recorder and collapsed sidebar with PodNotes initially enabled=%s", + "recovers a stale recorder, a collapsed sidebar and an offline renderer with PodNotes initially enabled=%s", { timeout: 120_000 }, async (initiallyEnabled, { signal }) => { const dir = await obsidianDir(); @@ -351,6 +352,7 @@ test.for([true, false])( await app.vault.adapter.promise; return true; })()`); + await setOffline(obsidian, true); return { config: await fs.readFile(path.join(dir, "community-plugins.json"), "utf8"), workspace: await fs.readFile(workspacePath), @@ -379,6 +381,7 @@ test.for([true, false])( } finally { await withVaultLock(() => runAllSteps( + () => setOffline(obsidian, false), () => obsidian.dev.evalJsonAsync(`(async () => { await app.plugins.disablePlugin(${jsLiteral(stale)}); diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 16f35e80..7295c284 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -20,6 +20,7 @@ import { assertSettingsOpenInMainWindow, assertSingleWindow, dismissNotices, + setOffline, } from "./journey/actions"; import { hasJourneySnapshot, @@ -86,6 +87,9 @@ export function createPodNotesE2EHarness(testName: string) { 'app.saveLocalStorage("secrets-encryption-warning-dismissed", "1"), true', ); await dismissNotices(suiteClient); + // The verify skill's `screens.mjs offline on` leaves the renderer offline through + // CDP, where no feed loads, and the instance stays up for the next run. + await setOffline(suiteClient, false); const resolver = await suiteClient.dev.evalJson( 'require("@electron/remote").app.commandLine.getSwitchValue("host-resolver-rules")', ); From 6b522ff996543b324023665380d902c4297171f0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 03:51:19 +0200 Subject: [PATCH 29/39] fix(ui): follow a line or chapter between the panel's faded edges Following put the transcript's line a third of the way down, lower to show the whole line before it, but no lower than half way. A tall line could then end in the faded bottom edge, the half-way cap clipped a line before it that would have fit, and a third of the way down could leave the line above the one before it cut under the top fade, as in a 320 px sidebar. Chapters kept the line a third of the way down and ignored both fades. One reveal beside Panel now serves both panels. It puts the row under as many whole rows as fit in the list's top third, starting where the top fade ends, so no row shows cut there, and under the whole row before it even when that one is taller. The row never reaches into the bottom fade and never starts under the top fade. Each fade's height is one custom property on the scroller, which the mask and the scroll padding use and reveal reads. --- src/ui/PodcastView/ChaptersPanel.svelte | 8 +-- src/ui/PodcastView/Panel.svelte | 38 ++++++++++-- src/ui/PodcastView/Player.test.ts | 19 ++++++ src/ui/PodcastView/TranscriptPanel.svelte | 23 ++----- src/ui/PodcastView/TranscriptPanel.test.ts | 72 ++++++++-------------- tests/e2e/journey-transcript.test.ts | 30 ++++++--- tests/mocks/panelLayout.ts | 49 +++++++++++++++ 7 files changed, 153 insertions(+), 86 deletions(-) create mode 100644 tests/mocks/panelLayout.ts diff --git a/src/ui/PodcastView/ChaptersPanel.svelte b/src/ui/PodcastView/ChaptersPanel.svelte index ff516482..0560e1dc 100644 --- a/src/ui/PodcastView/ChaptersPanel.svelte +++ b/src/ui/PodcastView/ChaptersPanel.svelte @@ -2,7 +2,7 @@ import type { Chapter } from "src/types/Chapter"; import { formatDuration } from "src/utility/formatDuration"; import { chapterAt } from "./chapters"; - import Panel from "./Panel.svelte"; + import Panel, { reveal } from "./Panel.svelte"; let { chapters, @@ -17,12 +17,8 @@ let list: HTMLElement | undefined = $state(); const current = $derived(chapterAt(chapters, position)); - // Scrolls only the panel: scrollIntoView would also scroll the view and an editor around it. $effect(() => { - const row = list?.children[current]; - if (!list || !row) return; - const top = row.getBoundingClientRect().top - list.getBoundingClientRect().top; - list.scrollTop += top - list.clientHeight / 3; + if (list) reveal(list, current); }); diff --git a/src/ui/PodcastView/Panel.svelte b/src/ui/PodcastView/Panel.svelte index 066a2bac..6f75c366 100644 --- a/src/ui/PodcastView/Panel.svelte +++ b/src/ui/PodcastView/Panel.svelte @@ -1,3 +1,31 @@ + +