Skip to content

feat(ui): replace the tab bar with the v3 view shell - #434

Merged
chhoumann merged 47 commits into
masterfrom
feat/ui-shell
Oct 13, 2026
Merged

chhoumann merged 47 commits into
masterfrom
feat/ui-shell

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Why

The view's three icon tabs (grid, episode list, player) hide where you are and what is playing. The v3 design replaces them with a shell. In a narrow view, pages stack with Back. From 560 px wide, a rail lists the roots. A mini player stays at the bottom while you browse, and the full player opens over the page. This is the first step of the redesign (UI0). It hosts today's pages unchanged; UI1 to UI4 restyle them.

What changed

  • PodcastView.svelte is now the shell. Its page model and navigation live in pages.ts and Navigation.ts, kept at plugin scope as plugin.navigation, so links, menus and commands can open the full player before the view exists.
  • The legacy grid, episode list, playlist cards and settings podcast search are hosted inside new runes pages: LibraryPage, EpisodesPage, PlaylistsPage and AddPodcastPage. MiniPlayer and PlayerOverlay render PlayerController state only. EpisodePlayer moved to runes and gets its services by context.
  • The view's components get the library (saved feeds, queue, favorites, playlists, local files, played and downloaded episodes) from ShellServices.library, which MainView fills from the existing stores. A test fails if one of them imports a library store from src/store. PodcastGrid is now the one grid both library pages fill.
  • TopBar, the ViewState enum and the global viewState and podcastView stores are deleted.

What moved where

Before After
Grid tab Library page (compact) or the Podcasts rail item (wide)
Queue, Favorites, Local Files and Played cards in the grid Library rows and rail items: Queue, Playlists (Favorites and custom playlists), Local files, Played
Episode list tab and its "Latest Episodes" button Latest episodes row or rail item; Back on podcast and playlist pages
Player tab Full player overlay. The mini player opens it again.
Search Podcasts, in Settings only Also in the view: first run (empty library) and the Add podcast page
viewState.set(ViewState.Player) after a play plugin.navigation.expand() in the view, plugin.showPlayer() from outside it
Queue moves gated on the Player tab Queue moves in the player's queue list and on the Queue page

Journeys

Journey Master This PR
Open a podcast's episodes Grid tab, then the card Library card. Back returns to the Library where it was scrolled.
Browse while playing Leaving the Player tab hides what is playing The mini player shows the episode, time left, Timestamp and play or pause on every page. Screen readers read its progress ("12:34 of 45:00").
Return to the player Player tab Tap the mini player, or run Show player
Close the player Pick another tab Chevron, Escape, or any navigation. Playback continues.
Show player command Reveals the view Reveals the view, and opens the full player when an episode is loaded
Restart Obsidian Grid tab; the episode waits in the Player tab The last page, with the episode paused at its saved position in the mini player
Empty library "No saved podcasts."; search lives in Settings "Add your first podcast" with the search
View in the main pane The same three tabs, stretched A rail from 560 px; the leaf re-lays out in place when moved
Popout window Works Works, and re-lays out for its width. The media stays in the main window.
Queue card with episodes Plays the first and opens the player Opens the Queue page
Settings Obsidian's settings only Also the gear in the Library head and the rail

Scope

  • In scope: the shell, navigation, mini player, the full player overlay, restart restore, popouts, the phone's floating navbar, and the e2e journeys for the new navigation.
  • Not in scope: restyling the hosted pages (UI1, UI2), the settings tab (UI3), and the phone and accessibility pass (UI4). The first-run column is top-aligned rather than centered, because the hosted search lists results under its field, and centering would move the field while the user types.
  • No data.json, IAPI or obsidian://podnotes change. The last page lives in the view's workspace state.

Tradeoffs

  • Navigation is plugin-scoped rather than view-scoped. Only one PodNotes leaf exists, and a deferred or closed view still has to open with the full player after a URI or menu play.
  • Escape uses an Obsidian Scope, not a DOM listener. Obsidian closes an open menu before any DOM listener runs and does not mark the event, so a DOM listener would close the menu and the player on one key. The scope is pushed on the stack of the view's own window, because Obsidian keeps one stack per window.

Blast Radius

Every user sees the new navigation; the hosted pages look as before. Links, the file menu's Play with PodNotes, the episode menu's Play and Show player now open the full player through showPlayer or expand. The bundle grows from 93,104 to 98,760 bytes gzip (+5,656) against master e484498 (with PERF2), 1,240 bytes inside the 100,000 budget.

Verification

  • Checks at the head: lint, format:check, typecheck, build, size, test (112 files, 2,061 tests), test:tooling, docs:build, and test:e2e 93/93 on Obsidian 1.14.4 on a fresh vault.
  • New Testing Library tests cover the back stack and scroll restore, rail roots clearing the stack, the 560 px switch, collapse that keeps playing, the Escape order with a menu above the player, first-run routing, and the mini player showing only when an episode is loaded. Each fails on a planted defect. New e2e journeys: browse while playing, collapse and expand, restart restore, the width switch, and scroll restore on a real scroller.
  • The mini player's progressbar was read from Obsidian's real accessibility tree at 320 and 820: "Episode progress", value 19, max 60, "0:19 of 1:00".
  • Live in real Obsidian 1.14.4 (master shots on 1.13.7): screenshots at 320, 820 and an emulated phone, in light and dark, beside the design screens, plus a recording of a sidebar-to-pane move and a popout.

Master and head, 320 and 820:
Master and head at 320 and 820

Head beside the design at 320, light and dark:
Library, page with the mini player, and the full player at 320 in light
The same in dark

At 820:
Library with the rail at 820 in light
Library with the rail at 820 in dark
A podcast page with the wide mini player, and the full player, in light
The same in dark

Phone (emulated, 390 x 844), before and after the navbar fix, in the main area and the drawer:
Phone main area and drawer

First run at 320 and 820:
First run beside the design

The same view moves from the right sidebar into the main pane, then into a popout, while an episode plays; Escape in the popout collapses the player: screen recording.
Frames from the recording

Docs at release

  • getting-started.md and podcasts.md: replace "select Podcast grid, the first of the three buttons", the Episode list button and the Player button with the Library (or the rail in a wide view), Back, the mini player and the full player.
  • podcasts.md: the Queue no longer opens the player when selected; it opens the Queue page. "Move to top of queue" and the other moves also work on the Queue page.
  • playlists.md: the built-in playlists and custom playlists live under Playlists, Queue, Local files and Played in the Library or the rail, not in the podcast grid. Queue stays visible when the automatic queue is off.
  • local_files.md: "The Local Files playlist in the podcast grid" becomes Local files in the Library or the rail.
  • commands.md: Show player also opens the full player when an episode is loaded.
  • mobile.md: the mini player and the full player, in the drawer and in the main area.
  • getting-started.md: podcasts can be added from the view (first run, and Add podcast), not only from Settings.
  • Re-shoot podcast-grid.webp, player.webp, episode-list.webp, mobile-player.webp and add-podcast.webp.

…s restore

Obsidian 1.14 holds the PodNotes leaf in a collapsed sidebar deferred.
Revealing it loads the view, whose setState restored the saved page and
collapsed the player Show player had just expanded. Restoring the saved
page now leaves the player as it is; only the user's navigation collapses.

Show player also expands only while an episode is loaded, so an expanded
flag never waits hidden and pops the player open after a later restore.
Obsidian keeps one scope stack per window and pushes onto the active
window's. The player's scope stayed on the window it was pushed in, so a
player moved to a popout lost Escape, and Escape typed in a main-window
editor collapsed it, read from the popout's stale activeElement.

The scope now follows the view's window: it is pushed on mount and on each
focus of that window while it is active, and moves when Obsidian migrates
the view. Escape collapses only when the key comes from inside the view or
its document's body. A keyboard collapse returns focus to the mini player.

The test keymap keeps a stack per window, as Obsidian's does.
The full player covered the page, the rail and the first-run block only
visually: Tab and Shift+Tab still reached their controls. They are inert
while the player is open, and stay mounted with their scroll.
The shell handed components a plain settings object, so the player's
Queue no longer followed the queue automation toggle (#108), and a
settings import, which replaces the object, left the view on the old one.
ShellServices.settings is now a store MainView derives from the plugin
store, which the toggle and the import both emit.
Obsidian shows the nearest aria-label as a tooltip wherever the pointer
rests, so the rail showed "PodNotes" over empty space and the full player
showed "Player" over itself. The rail, the Library rows, the mini player
and the full player now take their names from hidden text through
aria-labelledby. The names stay the same.
ui.mjs clicked the center of a visible match even when another element,
such as the full player, covered it, so the click landed on the cover.
click, type, fill and wait now wait until nothing covers the match's
center, and fail with "covered by <tag.class>" when it stays covered.
When no editor took the timestamp and the episode note has no path or
template, the mini player now says "Set an episode note path and template
to capture timestamps". A failed write keeps its own Notice. The mini
player also drops the time left once none is left, instead of "1 min
left", and fills its play and pause glyphs as the prototype does.
Inside the shell's scroller the legacy podcast grid and the Playlists
grid lose their own padding, so they line up with the page's heading.
The first run's column is capped at 360 px and centered, top-aligned so
the search field stays put as results load under it. Podcasts uses the
layout-grid icon, the prototype's 2x2 grid.

The podcast search fades in only. Its outro attached to the page's key
block and lingered over the next page; an {#if true} wrapper hid that
and goes away.
The Back label, the head title, the page title and the compact Library
actions each re-encoded part of one rule over layout and page. headFor in
pages.ts now owns that rule and is table-tested. The DOM is unchanged.
Pushing it again on every focus of the view's window lifted it over a
modal opened since, such as Settings, so Escape no longer closed the
modal after the window lost and regained focus. The scope now goes onto
the view window's stack once, when that window is active, and moves
only when the view moves to another window.
On a phone, Obsidian's floating navbar covered the mini player in the
main area, so Timestamp and play/pause could not be tapped, and a mobile
drawer's fade washed out its bottom. The shell's main-area view content
now pads by the navbar's height and offset on phones only, since a
tablet has no navbar, and the drawer fade is hidden for the PodNotes
leaf while the mini player shows.
…roller

The browse journey now subscribes to enough podcasts that the Library
scrolls. It wheels the Library to the bottom, opens the last podcast,
goes Back, and reads where the page's scroller came to rest.
Without a keyring, Obsidian loads stored secrets with a Notice that stays
until dismissed, over the top of the right sidebar. Once a test stored a
secret, each later reload brought it back over the view, so a click on
the full player's Collapse button found it covered, depending on the
order the files ran in. The harness now dismisses the warning for the
run.
When Show player revealed a deferred view whose saved podcast or playlist
had been removed, the shell's fallback to Latest episodes went through
goRoot, which collapses, so the user got only the mini player. Removing
the open podcast under the full player collapsed it the same way.

The fallback is not the user navigating, so it now goes through
Navigation.replace, the renamed restore, which keeps the full player.
appendTimestampToEpisodeNote decides that the episode note is not set
up, so it now shows the "Set an episode note path and template" Notice
itself, next to its failure Notice. The headset button reports it as the
mini player's Timestamp did, and the mini player no longer re-derives
the rule from settings.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4c77bb2
Status: ✅  Deploy successful!
Preview URL: https://c5d6518d.podnotes.pages.dev
Branch Preview URL: https://feat-ui-shell.podnotes.pages.dev

View logs

function navItem(name: string, nav: string, label: string): UiTarget {
return {
name,
elements: `[...document.querySelectorAll(${q(`${PLAYER_VIEW} ${nav} button.pn-navitem`)})].filter((el) => el.querySelector(".lbl")?.textContent.trim() === ${q(label)})`,
ShellServices gains a library of the stores the view reads. MainView
fills it from the plugin's stores, and the shell, its pages and the
player read it from context. Tests seed an isolated library, and a check
fails on a view component that imports these stores from src/store.
PodcastGrid takes its cards as children and owns the grid's CSS. The
Library page fills it with podcasts and the Playlists page with
playlists, so the copied grid rules, the unused playlist path and the
shell's padding and scroll overrides for the grid go. Both pages render
pixel for pixel as before at 320 and 820 px.
The bar the mini player draws, the compact line or the wide track, is
a progressbar named Episode progress, with its time as value text such
as 12:34 of 45:00. A screen reader can read progress without opening
the full player. The times now come from formatDuration, which formats
them as the mini player's own clock did.
The mini player sets --pn-btn instead of overriding each button's size
for desktop and phone, and the rail and the head share one spacer rule.
Both render pixel for pixel as before.
The Selection type and the played-episode placeholder's export served
only the view the shell replaced.
…orts

Scan the shell's TypeScript too, match imports from src/store/feeds and the other store modules, and pin the layout each progress check renders.
$playerState.status === "playing" || $playerState.status === "loading",
);
const duration = $derived($playerState.duration);
const elapsed = $derived(Math.min($playerState.currentTime, duration));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium PodcastView/MiniPlayer.svelte:17

A restored episode with unknown duration displays 0:00 in the wide mini player and its accessible progress text, even when it has saved playback progress. Math.min(currentTime, duration) clamps that position to zero when duration is zero; only clamp when the duration is positive.

Suggested change
const elapsed = $derived(Math.min($playerState.currentTime, duration));
const elapsed = $derived(duration > 0 ? Math.min($playerState.currentTime, duration) : $playerState.currentTime);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/ui/PodcastView/MiniPlayer.svelte around line 17:

A restored episode with unknown duration displays `0:00` in the wide mini player and its accessible progress text, even when it has saved playback progress. `Math.min(currentTime, duration)` clamps that position to zero when `duration` is zero; only clamp when the duration is positive.

…e shell

The merged test from #427 asserted the deleted viewState store; the shell opens the full player through the link's reveal.
…ary guard

A helper with export { queue } from "src/store" let a shell component read the global queue past the guard (V-434b). The guard now reports export ... from, export * and import * as, and pins each form, the verifier's bridge included.
# Conflicts:
#	tests/e2e/journey-resilience.test.ts
The Local Files mirror comment named the deleted podcast grid, and the transcripts recipe said to click Podcast grid.
@chhoumann
chhoumann merged commit 647c069 into master Oct 13, 2026
14 checks passed
@chhoumann
chhoumann deleted the feat/ui-shell branch October 13, 2026 06:00
chhoumann added a commit that referenced this pull request Oct 13, 2026
The mini player's Timestamp now goes through CaptureService: ShellServices.captureTimestamp
returns the Capture result, and MiniPlayer names the note from its path. #434's
captureTimestampToEditorOrNote and its capture helpers in main.ts are gone, IPodNotes
exposes `capture` instead, and its two main.ts capture tests now drive capture.timestamp().
The NOTE1 journeys open the Library with showLibrary, as the shell replaced the grid tab.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants