From a6884b4503b6034608591777c71ccd50c0d2c243 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:20:16 +0200 Subject: [PATCH 1/5] test(ui): pin a podcast header's cover and search row on a phone Fails on master: under a rule that centers images, as on the phone in #506, the cover moves off the left edge, and the drawer's search row is drawn in background-primary instead of the drawer's color. --- tests/e2e/view-polish.test.ts | 77 +++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/e2e/view-polish.test.ts b/tests/e2e/view-polish.test.ts index 18b3621f..f49e5513 100644 --- a/tests/e2e/view-polish.test.ts +++ b/tests/e2e/view-polish.test.ts @@ -23,6 +23,12 @@ const LONG: FixtureFeed = { artwork: false, episodes: [{ title: "First Steps", seconds: 60, pubDate: pubDate(0) }], }; +const SHORT: FixtureFeed = { + title: "Short Show", + slug: "short-show", + artwork: true, + episodes: [{ title: "First Steps", seconds: 60, pubDate: pubDate(0) }], +}; const PRIVATE = "Missing Address"; const SECRET_ID = "podnotes-feed-url-4b0c7e1a-2f6d-4c3b-9a8e-5d1f0e2c7b9a"; @@ -222,3 +228,74 @@ test("on a phone, the Escape that closes a row's menu in the right drawer leaves await eventually(obsidian, () => isShown(obsidian, ui.menuItem("Add to queue")), false), ).toBe(false); }); + +test("on a phone, a podcast's header keeps its cover at the left under a theme that centers images, over a search row in the color of the page behind it", async () => { + const context = getContext(); + const { obsidian } = context; + await asPhone(obsidian); + const server = await startFixtureServer([SHORT]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [SHORT] }); + const theme = await obsidian.dev.evalJson(`(() => { + const style = document.head.appendChild(document.createElement("style")); + style.id = "theme-centered-images"; + style.textContent = "img { margin-inline: auto !important; }"; + return app.vault.getConfig("theme"); + })()`); + // The window may have reloaded out of mobile emulation already, without the rule. + onTestFinished(async () => { + await obsidian.dev.evalJson( + `(document.getElementById("theme-centered-images")?.remove(), app.changeTheme(${jsLiteral(theme)}), true)`, + ); + }); + const header = () => + obsidian.dev.evalJson(`(() => { + if (!document.getElementById("theme-centered-images")) throw new Error("The theme's rule is gone"); + const view = document.querySelector(${jsLiteral(PLAYER_VIEW)}); + const hero = view.querySelector(".pn-hero"); + const cover = hero.querySelector(".pn-cover").getBoundingClientRect(); + const filter = view.querySelector(".pn-filter"); + let page = filter.parentElement; + while (getComputedStyle(page).backgroundColor === "rgba(0, 0, 0, 0)") page = page.parentElement; + return { + coverFromEdge: Math.round(cover.left - hero.getBoundingClientRect().left), + titleFromCover: Math.round(hero.querySelector("h1").getBoundingClientRect().left - cover.right), + band: getComputedStyle(filter).backgroundColor, + page: getComputedStyle(page).backgroundColor, + }; + })()`); + + const headers: Record = {}; + for (const place of ["right drawer", "main area"] as const) { + await placePodNotes(obsidian, place); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(SHORT.title)); + await waitUntilShown(obsidian, ui.podcastPage(SHORT.title)); + for (const [name, id] of [ + ["dark", "obsidian"], + ["light", "moonstone"], + ]) { + await obsidian.dev.evalJson(`(app.changeTheme(${jsLiteral(id)}), true)`); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `document.body.classList.contains("theme-${name}")`, + ), + { timeoutMs: 5_000, intervalMs: 100, message: `the ${name} theme` }, + ); + headers[`${place}, ${name}`] = await header(); + } + } + + const atLeft = { coverFromEdge: 0, titleFromCover: 12 }; + expect(headers).toEqual({ + "right drawer, dark": { ...atLeft, band: "rgb(30, 30, 30)", page: "rgb(30, 30, 30)" }, + "right drawer, light": { + ...atLeft, + band: "rgb(255, 255, 255)", + page: "rgb(255, 255, 255)", + }, + "main area, dark": { ...atLeft, band: "rgb(0, 0, 0)", page: "rgb(0, 0, 0)" }, + "main area, light": { ...atLeft, band: "rgb(255, 255, 255)", page: "rgb(255, 255, 255)" }, + }); +}); From 26eba282642d948c1d1f5665e22cd38e7b073ead Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:22:20 +0200 Subject: [PATCH 2/5] fix(ui): keep a podcast header's cover at the left on phones The hero's text block didn't take the rest of its row, so a theme or snippet that gives images auto inline margins centered the cover in the leftover space and pushed the title to the right edge. The text now takes the row, as in every other cover row. In a mobile drawer, the view's page color is the drawer's own --mobile-sidebar-background, so the sticky search row no longer shows as a darker or lighter band. --- src/ui/PodcastView/EpisodesPage.svelte | 3 +++ src/ui/PodcastView/PodcastView.svelte | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ui/PodcastView/EpisodesPage.svelte b/src/ui/PodcastView/EpisodesPage.svelte index 395d9840..d4a11e58 100644 --- a/src/ui/PodcastView/EpisodesPage.svelte +++ b/src/ui/PodcastView/EpisodesPage.svelte @@ -131,7 +131,10 @@ border-radius: 8px; } + /* The text takes the rest of the row, so no space is left for a theme's auto + margins on images to move the cover into. */ > div { + flex: 1; min-width: 0; } diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 96faae67..e270cefc 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -311,11 +311,16 @@ font-size: var(--font-ui-small); line-height: var(--line-height-tight); - /* A sidebar is secondary-colored. The mini player stands out from the page above it. */ + /* A sidebar is secondary-colored, and a mobile drawer has its own color. The mini + player stands out from the page above it. */ .mod-sidedock & { --pn-bg: var(--background-secondary); } + .workspace-drawer & { + --pn-bg: var(--mobile-sidebar-background); + } + .mod-sidedock &.is-compact { --pn-surface: var(--background-primary); } From 7b15c0d101ad268a5818b0ae804544c8ed180863 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:40:19 +0200 Subject: [PATCH 3/5] test(ui): switch themes by body class in the phone header test Switching through app.changeTheme wrote app.json and raced the harness's config restore. The test now sets the theme class, measures, and puts the class and the centering rule back in one evaluation. --- tests/e2e/view-polish.test.ts | 70 ++++++++++++++--------------------- 1 file changed, 28 insertions(+), 42 deletions(-) diff --git a/tests/e2e/view-polish.test.ts b/tests/e2e/view-polish.test.ts index f49e5513..86f7c8c7 100644 --- a/tests/e2e/view-polish.test.ts +++ b/tests/e2e/view-polish.test.ts @@ -236,34 +236,6 @@ test("on a phone, a podcast's header keeps its cover at the left under a theme t const server = await startFixtureServer([SHORT]); onTestFinished(() => server.stop()); await seedLibrary(context, server, { subscriptions: [SHORT] }); - const theme = await obsidian.dev.evalJson(`(() => { - const style = document.head.appendChild(document.createElement("style")); - style.id = "theme-centered-images"; - style.textContent = "img { margin-inline: auto !important; }"; - return app.vault.getConfig("theme"); - })()`); - // The window may have reloaded out of mobile emulation already, without the rule. - onTestFinished(async () => { - await obsidian.dev.evalJson( - `(document.getElementById("theme-centered-images")?.remove(), app.changeTheme(${jsLiteral(theme)}), true)`, - ); - }); - const header = () => - obsidian.dev.evalJson(`(() => { - if (!document.getElementById("theme-centered-images")) throw new Error("The theme's rule is gone"); - const view = document.querySelector(${jsLiteral(PLAYER_VIEW)}); - const hero = view.querySelector(".pn-hero"); - const cover = hero.querySelector(".pn-cover").getBoundingClientRect(); - const filter = view.querySelector(".pn-filter"); - let page = filter.parentElement; - while (getComputedStyle(page).backgroundColor === "rgba(0, 0, 0, 0)") page = page.parentElement; - return { - coverFromEdge: Math.round(cover.left - hero.getBoundingClientRect().left), - titleFromCover: Math.round(hero.querySelector("h1").getBoundingClientRect().left - cover.right), - band: getComputedStyle(filter).backgroundColor, - page: getComputedStyle(page).backgroundColor, - }; - })()`); const headers: Record = {}; for (const place of ["right drawer", "main area"] as const) { @@ -271,20 +243,34 @@ test("on a phone, a podcast's header keeps its cover at the left under a theme t await showLibrary(obsidian); await click(obsidian, ui.podcastCard(SHORT.title)); await waitUntilShown(obsidian, ui.podcastPage(SHORT.title)); - for (const [name, id] of [ - ["dark", "obsidian"], - ["light", "moonstone"], - ]) { - await obsidian.dev.evalJson(`(app.changeTheme(${jsLiteral(id)}), true)`); - await obsidian.waitFor( - () => - obsidian.dev.evalJson( - `document.body.classList.contains("theme-${name}")`, - ), - { timeoutMs: 5_000, intervalMs: 100, message: `the ${name} theme` }, - ); - headers[`${place}, ${name}`] = await header(); - } + // A theme or snippet that centers every image, as on the phone in #506. + const shown = await obsidian.dev.evalJson>(`(() => { + const view = document.querySelector(${jsLiteral(PLAYER_VIEW)}); + const hero = view.querySelector(".pn-hero"); + const filter = view.querySelector(".pn-filter"); + const centered = document.head.appendChild(document.createElement("style")); + centered.textContent = "img { margin-inline: auto !important; }"; + const classes = document.body.className; + try { + return Object.fromEntries(["dark", "light"].map((theme) => { + document.body.classList.remove("theme-dark", "theme-light"); + document.body.classList.add("theme-" + theme); + const cover = hero.querySelector(".pn-cover").getBoundingClientRect(); + let page = filter.parentElement; + while (getComputedStyle(page).backgroundColor === "rgba(0, 0, 0, 0)") page = page.parentElement; + return [${jsLiteral(place)} + ", " + theme, { + coverFromEdge: Math.round(cover.left - hero.getBoundingClientRect().left), + titleFromCover: Math.round(hero.querySelector("h1").getBoundingClientRect().left - cover.right), + band: getComputedStyle(filter).backgroundColor, + page: getComputedStyle(page).backgroundColor, + }]; + })); + } finally { + centered.remove(); + document.body.className = classes; + } + })()`); + Object.assign(headers, shown); } const atLeft = { coverFromEdge: 0, titleFromCover: 12 }; From 5cdfedcf6f3857c6b5cb18ecef16b89636fb1c75 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:40:19 +0200 Subject: [PATCH 4/5] refactor(ui): keep the mini player note on the sidedock rules In a mobile drawer the mini player does not stand out from the page, so the drawer rule gets its own comment. --- src/ui/PodcastView/PodcastView.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index e270cefc..59d4a2ee 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -311,20 +311,20 @@ font-size: var(--font-ui-small); line-height: var(--line-height-tight); - /* A sidebar is secondary-colored, and a mobile drawer has its own color. The mini - player stands out from the page above it. */ + /* A sidebar is secondary-colored. The mini player stands out from the page above it. */ .mod-sidedock & { --pn-bg: var(--background-secondary); } - .workspace-drawer & { - --pn-bg: var(--mobile-sidebar-background); - } - .mod-sidedock &.is-compact { --pn-surface: var(--background-primary); } + /* A mobile drawer has its own color. */ + .workspace-drawer & { + --pn-bg: var(--mobile-sidebar-background); + } + &.is-wide { --pn-pad: var(--size-4-6); --pn-head: 44px; From 683e49f4c804db49224b25130baa4f0eb6819929 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:46:15 +0200 Subject: [PATCH 5/5] test(ui): measure the header's cover only as an image A cover that fell back to its text tile would dodge the image rule and let the test pass without the bug's conditions. --- tests/e2e/view-polish.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/view-polish.test.ts b/tests/e2e/view-polish.test.ts index 86f7c8c7..a07a080f 100644 --- a/tests/e2e/view-polish.test.ts +++ b/tests/e2e/view-polish.test.ts @@ -255,7 +255,7 @@ test("on a phone, a podcast's header keeps its cover at the left under a theme t return Object.fromEntries(["dark", "light"].map((theme) => { document.body.classList.remove("theme-dark", "theme-light"); document.body.classList.add("theme-" + theme); - const cover = hero.querySelector(".pn-cover").getBoundingClientRect(); + const cover = hero.querySelector("img.pn-cover").getBoundingClientRect(); let page = filter.parentElement; while (getComputedStyle(page).backgroundColor === "rgba(0, 0, 0, 0)") page = page.parentElement; return [${jsLiteral(place)} + ", " + theme, {