Skip to content

fix(feeds): show missing private addresses while offline - #482

Merged
chhoumann merged 3 commits into
masterfrom
fix/offline-missing-address
Oct 14, 2026
Merged

chhoumann merged 3 commits into
masterfrom
fix/offline-missing-address

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

A synced private podcast now shows "Address not on this device" and Set feed address on its first offline open. The repository checks the local address before gating network access. The error text wraps so it stays readable in a narrow sidebar.

Fixes #476

Scope

Local private-address diagnosis and the episodes-page error description. Online fetch behavior and fresh-cache lookup rules stay unchanged.

Verification

  • Passed npm run lint, npm run format:check, npm run typecheck, npm run build, npm run test with 2,614 tests, npm run test:tooling with 13 tests, and npm run docs:build. Commands used Node v22.23.2 through fnm exec --using 22 --.
  • Passed npm run test:e2e on a fresh isolated vault in Obsidian 1.14.4 on Linux, 199 tests across 27 files, exit 0. This repeated a full run whose tests passed but whose terminal session returned 143 after cleanup. The isolated instance is stopped.
  • Unit and real-Obsidian regression tests failed on master and passed with the fix. The private feed fixture is podnotes-private-feed:Private with an absent device secret and no cache. The journey opens it through Library and checks the message, offline-disabled action, enabled action after reconnect, persisted subscription, and readable banner width.
  • Both fix(feeds): skip automatic requests while offline #474 request-count journeys still record zero offline requests and two reconnect requests using http://podnotes.test:<port>/feed2-alpha/feed.xml and /feed2-beta/feed.xml.
  • npm run size passed at 321,686 raw bytes and 101,871 gzip bytes. The delta is +46 raw bytes and +34 gzip bytes.

Three wrong alternatives each failed a named test:

  • Gate address resolution behind online. a private feed whose URL is not on this device is unavailable with online false and the real-Obsidian journey failed.
  • Allow fetching offline. offline, a feed with no document waits idle without a request failed.
  • Treat every private placeholder as a missing address. offline, a private feed with its address on this device waits without an error or request failed.

The rendered-width assertion also failed before the error-description wrapping change.

Verification logs, mutation driver, and independent reviews.

Before. After the fix:

Missing private address shown offline

Release impact

No settings, storage format, public API, or URI changes. No migration.

Docs at release

Update docs/src/content/docs/podcasts.md, under When a feed cannot be reached, to say that a missing private-feed address is reported offline too, and Set feed address needs a connection. Published docs remain unchanged.

Note

Fix offline resolution of private feed addresses in FeedRepository

  • Private feeds used to show no status while offline because the offline path in ensureAll skipped all per-feed work. It now evaluates every subscription through the cached request path.
  • The request coordinator in FeedRepository.ts gains an optional online flag. When false, it resolves the feed address but does not start a fetch; an unresolved address still records an unavailable error, and fresh cached feeds are reused as-is.
  • Feed-status text in EpisodesPage.svelte now wraps instead of being clipped to one line.
  • Adds unit tests for online/offline private-feed cases in FeedRepository.test.ts and an end-to-end offline missing-address scenario in private-feeds.test.ts.
  • Behavioral Change: an offline private feed whose address cannot resolve now shows an unavailable error instead of staying silent; a resolvable address keeps the feed idle with no fetch.

Macroscope summarized c0ac05c.

@chhoumann chhoumann linked an issue Oct 14, 2026 that may be closed by this pull request
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: c0ac05c
Status: ✅  Deploy successful!
Preview URL: https://12d7a79f.podnotes.pages.dev
Branch Preview URL: https://fix-offline-missing-address.podnotes.pages.dev

View logs

@chhoumann
chhoumann merged commit bdf3b82 into master Oct 14, 2026
16 checks passed
@chhoumann
chhoumann deleted the fix/offline-missing-address branch October 14, 2026 22:13
chhoumann added a commit that referenced this pull request Oct 15, 2026
* 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.

* 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.

* 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.

* fix(ui): name settings fields, widen Edit, and stop all motion on request

- Each settings field is labelled by its row's name through aria-labelledby,
  which Obsidian does not show as a tooltip. Toggles keep Obsidian's native
  30 px size, as every toggle in Obsidian's settings has.
- Up next's Edit, and every other panel head control, is at least 44 px
  wide on a phone, not only 44 px tall.
- One shell rule stops every transition and animation in the view when the
  user prefers reduced motion; the grid card's press had none before. It
  replaces the play button's and the playing bars' own rules.
- styles.test.ts cascades the components' compiled CSS in jsdom: motion
  under the reduced-motion query, and the video play button's focus ring
  drawn inside the frame that clips it.

* 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.

* 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.

* 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.

* fix(ui): leave video fullscreen on the first Escape, and open a saved transcript note

- In a video's fullscreen the view's Escape now leaves fullscreen and keeps
  the player open; the next Escape closes the player. Obsidian's window does
  not leave element fullscreen on Escape by itself, and closing the player
  only ended fullscreen by removing the frame. The fullscreen button stays
  on the frame in fullscreen as Exit fullscreen.
- The transcript panel offers Open note, offline too, when the episode's
  transcript note already exists at the transcript path, instead of Save as
  note, which the command refuses for an existing note. It follows a note
  made, moved or deleted, and a new transcript path.
- The podcast menu's Set feed address, and Edit feed URL, are disabled
  offline, as the feed banner's button is: an address is checked by loading
  its feed.

* 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.

* 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.

* 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.

* refactor(ui): keep the row equalizer's keyframes global

A scoped keyframes rule makes Svelte write its scoping class into every
element of the component. EpisodeRow's `pn-eq` bars are the only
keyframes in the view, and put the class on all 17 of the row's
elements. A global `pn-eq`, inside `:global { }`, leaves it on the root.

* 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.

* 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.

* fix(ui): name each settings toggle where it takes focus

Obsidian 1.14 focuses a toggle's `label.checkbox-container`, and gives its
checkbox tabindex -1, so the row's name on the checkbox never reached a
screen reader. The label is named after the row too. The unit mock builds
Obsidian's toggle markup, so the test can tell. Each Setting has a fresh
name element, so it always takes a new id.

* fix(ui): light up hovered controls only on a device that can hover

A touch screen keeps the last tapped element hovered. Eleven `:hover`
rules outside `@media (hover: hover)` stayed lit after a tap, and the
panel switch's hover rule outranked its pressed look, so a tapped switch
on a phone looked hovered rather than on. Every component's hover rules
now sit in that query, and the test walks every component under src/ui
instead of a hand-kept list.

* 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.

* 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.

* 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.

* 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.

* 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.

* refactor(ui): count items with a class of the view's own, not Obsidian's flair

The counts used Obsidian's `.flair` and undid its tag look with five
declarations. A `pn-flair` class needs none of them. What Obsidian's rule
still set does not show here: a radius without a background, a relative
position without an offset, and vertical-align on a flex item.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* test(e2e): share the phone layout and the right drawer placement

view-polish and journey-transcript each pasted the CDP phone metrics and
the steps that open PodNotes in a phone's right drawer. asPhone now sits
in the journey actions, and placePodNotes takes a "right drawer" place
that waits until PodNotes shows there. The Escape focus test removes the
global it records focus in.

* test(e2e): check the name Chromium gives each settings field Tab reaches

The test read each field's aria-labelledby and looked the label up
itself, so it passed on markup that names nothing. It now turns on
diarization, whose rows take focus only then, tabs through PodNotes'
settings with trusted key presses, and reads the name Chromium computes
for each field that takes focus, the name a screen reader announces,
and what Chromium took it from. Each must be its row's name, through
aria-labelledby: a focused toggle's label would otherwise take the name
of the checkbox inside it.

* fix(ui): name the transcript's note button by its label, with no tooltip

The note button carried an aria-label equal to its label, so Obsidian
showed a tooltip repeating "Open note" in the empty panel, where the
label is in sight. The aria-label kept the button named in a narrow
head, which folded the label away with display: none. A narrow head now
clips the label out of sight and keeps it in the accessibility tree, so
the label names the button everywhere and the aria-label is gone.

* fix(ui): keep the rail's add button padding on a tablet

The rail's add button was the one control whose rule sat outside its
component's root, at the specificity of two classes. On a tablet,
Obsidian's .is-tablet button:not(.clickable-icon) outranks that and gave
it 4 px by 20 px of padding. The rail's styles now nest under its root
like every other component's.

* fix(ui): keep the stage's artwork look off a panel's row covers

The stage's artwork rules matched every cover in the stage, so where a
narrow player shows Up next in the stage, its rows' covers took the
artwork's drop shadow, and a local file's row tile took the artwork's
icon size. The rules now match only the stage's own child.

* fix(ui): dim every disabled control to 0.4, the look of Obsidian's disabled icons

Disabled buttons took Obsidian's 0.7 for buttons, which reads as
enabled, most of all on icon buttons and on an offline row that needs a
connection. One rule in the view now dims a disabled or aria-disabled
button to 0.4, as Obsidian dims a disabled icon. Hover no longer lights
a disabled icon button or call to action, so the icon button's own
disabled rule is gone. Nothing inside a disabled button dims again, and
the scrubber keeps its own 0.5.

* fix(ui): end a phone's search text where its clear button starts

On a phone the clear button is 44 px wide, but the field kept the
desktop's 28 px of room on its right, so a pasted link ran into the X.
A phone's field now keeps 44 px on its right.

* docs(ui): say what fits the transcript head's 340 px

The container rule that folds Save's label gave no reason for 340 px.
Measured on a phone, the head's icon, title, Free tag, 44 px Find and
labelled Save take 339.8 px.

* fix(ui): keep a pressed icon button dimmed when it is disabled

The one disabled rule dims every disabled control to 0.4, but the
pressed icon button's full opacity outranked it. reveal now says "at
least the row before" with the position it has already taken instead
of reading the DOM again.

* test(e2e): turn diarization on only when it is off, and name what the head check reads

The settings test clicked the diarization toggle, which turned it off
on a vault where it was already on, so the diarization rows never took
focus. The transcript head check reports whether Save has an
aria-label; it now says so.
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.

Offline, a private feed without its address shows No episodes

1 participant