diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx
index 404ec36..0db2235 100644
--- a/src/pages/Feed/FeedPage.tsx
+++ b/src/pages/Feed/FeedPage.tsx
@@ -267,31 +267,55 @@ function FeedPage() {
}}
>
-
+
{index() === fi() ? marker() : " "}
-
+
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
-
-
- {formatDate(item.episode.pubDate)}
-
-
- {formatDuration(item.episode.duration)}
-
-
+ {/* podcast name on its own row — readable at a glance; the
+ 50% current pane fits it in full for typical names, and
+ truncate keeps the row one line tall either way */}
+
+
{item.feed.customName || item.feed.podcast.title}
+
+
+
+ {formatDate(item.episode.pubDate)}
+
+
+ {formatDuration(item.episode.duration)}
+
- ●
+
+ ●
+
-
+
{downloadLabel(item.episode.id)}
diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx
index a1dac5a..213da62 100644
--- a/src/pages/MyShows/MyShowsPage.tsx
+++ b/src/pages/MyShows/MyShowsPage.tsx
@@ -362,26 +362,41 @@ export function MyShowsPage() {
}}
>
-
+
{index() === lf() ? marker() : " "}
-
+
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
-
+
{formatDate(ep.pubDate)}
-
+
{formatDuration(ep.duration)}
- ●
+
+ ●
+
-
+
{downloadLabel(ep.id)}
diff --git a/tests/feed-row-wrap.test.tsx b/tests/feed-row-wrap.test.tsx
new file mode 100644
index 0000000..d852c40
--- /dev/null
+++ b/tests/feed-row-wrap.test.tsx
@@ -0,0 +1,207 @@
+/**
+ * Episode-row height regression — Feed rows must stay exactly 3 lines tall:
+ * title, podcast name, meta (date + duration + markers). Every line carries
+ * `wrapMode="none"` + `truncate` on flexible text and `flexShrink={0}` on
+ * fixed-width cells so Yoga can never shrink a text below its content width
+ * and wrap it — a wrapped row grows to 4+ lines and every entry below
+ * shifts its starting position while scrolling (the original bug).
+ *
+ * Rendered at 70 columns so the 35-col current pane is narrow enough to
+ * force truncation on the long title; at the default 100-col/50-col pane
+ * the same rows show everything in full.
+ */
+
+import { describe, test, expect, afterAll } from "bun:test";
+import type { JSX } from "solid-js";
+import { testRender } from "@opentui/solid";
+import { ThemeProvider } from "../src/context/ThemeContext";
+import { PaneRow } from "../src/components/PaneRow";
+
+type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
+
+function frameText(spans: Frame): string[] {
+ return spans.lines.map((l) => l.spans.map((s) => s.text).join(""));
+}
+
+// The exact row shape FeedPage renders: title, podcast name, meta. Static
+// text, no store hooks — pure layout probe.
+const FixedRow = () => (
+
+
+ ❯
+
+ #674 - Scott Payne
+
+
+
+
+ This Past Weekend w/ Theo Von
+
+
+
+ Aug 10, 2026
+ 3h 50m
+
+
+);
+
+// A long title proves the truncate guard: without it the title wraps to
+// multiple lines and the row grows past 3 lines.
+const LongTitleFixed = () => (
+
+
+ ❯
+
+ Out of Whiskey and Reaching for the Rotgut (Members Only #338)
+
+
+
+
+ This Past Weekend w/ Theo Von
+
+
+
+ Aug 10, 2026
+ 3h 50m
+
+
+);
+
+// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title
+// wraps at the shrunken width and the row grows.
+const NaiveRow = () => (
+
+
+ ❯
+ Out of Whiskey and Reaching for the Rotgut (Members Only #338)
+
+
+ This Past Weekend w/ Theo Von
+
+
+ Aug 10, 2026
+ 3h 50m
+
+
+);
+
+async function renderCurrent(
+ row: () => JSX.Element,
+ width = 70,
+): Promise<{ lines: string[]; destroy: () => Promise }> {
+ const setup = await testRender(
+ () => (
+
+
+
+ ),
+ { width, height: 10, useThread: false },
+ );
+ // ThemeProvider mounts children only once the theme resolves; poll for
+ // the header row so the captured frame is a mounted PaneRow.
+ let lines: string[] | null = null;
+ for (let i = 0; i < 40 && !lines; i++) {
+ await setup.renderOnce();
+ const frame = setup.captureSpans() as unknown as Frame;
+ const ls = frameText(frame);
+ if (ls.some((l) => l.includes("List"))) lines = ls;
+ else await new Promise((r) => setTimeout(r, 100));
+ }
+ if (!lines) throw new Error("PaneRow did not render before timeout");
+ return {
+ lines,
+ destroy: async () => {
+ setup.renderer.destroy();
+ },
+ };
+}
+
+const cleanups: (() => void | Promise)[] = [];
+afterAll(async () => {
+ for (const c of cleanups) {
+ try {
+ await c();
+ } catch {
+ // renderer already torn down — ignore
+ }
+ }
+});
+
+describe("episode row height in the current pane (70-wide → 35-col pane)", () => {
+ test("fixed row: title, podcast name and meta each render on exactly one line", async () => {
+ const { lines, destroy } = await renderCurrent(() => );
+ cleanups.push(destroy);
+
+ // Podcast name is fully visible on its own line — the usability the
+ // dedicated row exists for.
+ expect(lines.filter((l) => l.includes("This Past Weekend w/ Theo Von"))).toHaveLength(1);
+ // Title and meta each on a single frame line (a wrapped date would
+ // split "Aug 10, 2026" across lines).
+ expect(lines.filter((l) => l.includes("Scott Payne"))).toHaveLength(1);
+ expect(lines.filter((l) => l.includes("Aug 10, 2026"))).toHaveLength(1);
+ expect(lines.filter((l) => l.includes("3h 50m"))).toHaveLength(1);
+ // The row occupies exactly the 3 content lines below the header.
+ const content = lines.filter(
+ (l) =>
+ l.includes("Scott Payne") ||
+ l.includes("This Past Weekend") ||
+ l.includes("Aug 10,"),
+ );
+ expect(content).toHaveLength(3);
+ });
+
+ test("long title stays on one line (truncated), keeping the row at 3 lines", async () => {
+ const { lines, destroy } = await renderCurrent(() => );
+ cleanups.push(destroy);
+
+ // Truncated: the title head appears on exactly one line and the full
+ // title never appears on any line (middle-ellipsis clips it).
+ expect(lines.filter((l) => l.includes("Out of Whiske"))).toHaveLength(1);
+ expect(
+ lines.some((l) =>
+ l.includes(
+ "Out of Whiskey and Reaching for the Rotgut (Members Only #338)",
+ ),
+ ),
+ ).toBe(false);
+ // Podcast name and meta still each on one line — row total 3.
+ expect(lines.filter((l) => l.includes("This Past Weekend"))).toHaveLength(1);
+ expect(lines.filter((l) => l.includes("Aug 10,"))).toHaveLength(1);
+ const content = lines.filter(
+ (l) =>
+ l.includes("Out of Whiske") ||
+ l.includes("This Past Weekend") ||
+ l.includes("Aug 10,"),
+ );
+ expect(content).toHaveLength(3);
+ });
+
+ test("naive row (pre-fix props) wraps the long title — the regression the test guards", async () => {
+ const { lines, destroy } = await renderCurrent(() => );
+ cleanups.push(destroy);
+
+ // The title's wrapped fragments span 3 frame lines instead of 1.
+ const titleFragments = lines.filter(
+ (l) =>
+ l.includes("Out of Whiske") ||
+ l.includes("for the Rotgut") ||
+ l.includes("#338)"),
+ );
+ expect(titleFragments).toHaveLength(3);
+ // Row total: 3 title lines + podcast + meta = 5 content lines.
+ const content = lines.filter(
+ (l) =>
+ l.includes("Out of Whiske") ||
+ l.includes("for the Rotgut") ||
+ l.includes("#338)") ||
+ l.includes("This Past Weekend") ||
+ l.includes("Aug 10,"),
+ );
+ expect(content).toHaveLength(5);
+ });
+});