diff --git a/CHANGELOG.md b/CHANGELOG.md
index cce0a72..62abaee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Fixed
+- **Screen readers read the feed tree properly.** Every feed and folder was announced as
+ "RSSReaderWPF.FeedItem" instead of its name. Each one now says its own name.
+
## [1.4.0] - 2026-10-01
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index bc4f4c4..9036823 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,6 +70,7 @@ Flow:
### Accessibility constraints — treat these as load-bearing
- **Neither items control is its own tab stop.** `IsTabStop="False"` on both `FeedTree` and `HeadlinesList`, plus an `ItemContainerStyle` that sets `IsTabStop="True"` on `TreeViewItem` (unlike `ListBoxItem`, it is not one by default). With `TabNavigation="Once"` each panel is a single stop that lands on an item. Setting `IsTabStop="True"` on a container reintroduces the 1.1.0 Shift+Tab bug: focus lands on a container that reports no name, value or state, and the `GotFocus` handler pushes it straight back in, so Shift+Tab appears to do nothing. `tests/RSSQuick.Tests/TabOrderTests.cs` measures this — every test there was verified to fail against the unfixed window.
+- **Every data-bound item needs a name, twice over.** A `TreeViewItem`, `ListBoxItem` or `ComboBoxItem` built from a data object is named for UI Automation by that object's `ToString` unless its `ItemContainerStyle` binds `AutomationProperties.Name`, and that is the class name: the feed tree shipped in 1.4.0 reading "RSSReaderWPF.FeedItem" for every row, and the Subscribe dialog's folder list once read out a whole record. Bind the name in the container style, and give the model a `ToString` that returns its visible text, for rows a virtualized list has no container for. `AccessibleNameTests` checks both.
- **The `GotFocus` handlers are guarded on `e.OriginalSource`.** They redirect only focus that landed on the container itself. Without the guard they run for every focus change bubbling through the panel, so each arrow-key step re-focuses the row it just left.
- `FeedText.CleanTitle()` strips zero-width characters (U+200B/C/D, U+FEFF, U+2060), normalizes exotic spaces (U+00A0, U+2009, U+202F) to plain spaces, replaces control characters with a space, and collapses whitespace. Invisible characters and stray whitespace render as confusing blank cells on a braille display. Do not bypass it for text that reaches a headline.
- **The menu bar is not in the tab ring** (`KeyboardNavigation.TabNavigation="None"`, `IsTabStop="False"`); Alt or F10 reaches it. Its items are never disabled, because WPF skips a disabled menu item when arrowing and a reader would never learn it was there — each command says in the status bar why it did nothing instead. Access keys avoid D, B and U, which are Alt+D, Alt+B and Alt+U bindings. A key shown in a menu must be an `InputBinding`; `MenuAndFeedCommandsTests` checks.
diff --git a/src/RSSQuick/MainWindow.xaml b/src/RSSQuick/MainWindow.xaml
index b0450bd..f9128a0 100644
--- a/src/RSSQuick/MainWindow.xaml
+++ b/src/RSSQuick/MainWindow.xaml
@@ -123,6 +123,11 @@
is not one by default, so taking IsTabStop off the TreeView alone
dropped the whole panel out of the tab ring. -->
+
+
diff --git a/src/RSSQuick/Models/ArticleItem.cs b/src/RSSQuick/Models/ArticleItem.cs
index 2051d24..a3b7559 100644
--- a/src/RSSQuick/Models/ArticleItem.cs
+++ b/src/RSSQuick/Models/ArticleItem.cs
@@ -28,6 +28,14 @@ public string Title
set { _title = value; OnPropertyChanged(); }
}
+ /// The title alone.
+ ///
+ /// Load-bearing: where WPF has no item container to ask, such as a row scrolled out of a
+ /// virtualized list, it names the item for a screen reader by its ToString, which would
+ /// otherwise be the class name. The XAML binds the name too; this is the fallback.
+ ///
+ public override string ToString() => Title;
+
public string Link
{
get => _link;
diff --git a/src/RSSQuick/Models/FeedItem.cs b/src/RSSQuick/Models/FeedItem.cs
index de52962..132cdc3 100644
--- a/src/RSSQuick/Models/FeedItem.cs
+++ b/src/RSSQuick/Models/FeedItem.cs
@@ -21,6 +21,14 @@ public string Title
set { _title = value; OnPropertyChanged(); }
}
+ /// The title alone.
+ ///
+ /// Load-bearing: where WPF has no item container to ask, such as a row scrolled out of a
+ /// virtualized list, it names the item for a screen reader by its ToString, which would
+ /// otherwise be the class name. The XAML binds the name too; this is the fallback.
+ ///
+ public override string ToString() => Title;
+
/// The feed URL. Empty for a folder.
public string Url
{
diff --git a/tests/RSSQuick.Tests/AccessibleNameTests.cs b/tests/RSSQuick.Tests/AccessibleNameTests.cs
new file mode 100644
index 0000000..7969835
--- /dev/null
+++ b/tests/RSSQuick.Tests/AccessibleNameTests.cs
@@ -0,0 +1,63 @@
+using System.Linq;
+using System.Windows;
+using System.Windows.Automation.Peers;
+using System.Windows.Controls;
+using RSSReaderWPF;
+
+namespace RSSQuick.Tests;
+
+///
+/// Every item a screen reader lands on reports the text the reader sees.
+///
+///
+/// The feed tree shipped in 1.4.0 with no name on its items, so UI Automation fell back to the
+/// data object's ToString and NVDA read "RSSReaderWPF.FeedItem" for every feed and folder. The
+/// tree tests only ever checked where focus went, never what it was called. Names are read here
+/// the way a screen reader reaches them: as the children of the tree's or list's own automation
+/// peer, which are wrappers around the data items rather than the containers themselves.
+///
+[Collection(WpfCollection.Name)]
+public class AccessibleNameTests
+{
+ [WpfFact]
+ public void Feed_tree_items_are_named_by_their_title()
+ {
+ using var ui = new FocusHarness();
+ var folder = new FeedItem { Title = "Technology", IsCategory = true };
+ folder.Children.Add(new FeedItem { Title = "Ars Technica" });
+ ui.SetFeeds(folder, new FeedItem { Title = "BBC News" });
+
+ var top = ChildrenOf(ui.FeedTree);
+ Assert.Equal(new[] { "Technology", "BBC News" }, top.Select(p => p.GetName()));
+
+ var container = (TreeViewItem)ui.FeedTree.ItemContainerGenerator.ContainerFromIndex(0);
+ container.IsExpanded = true;
+ ui.Window.UpdateLayout();
+ ui.Drain();
+ Assert.Equal("Ars Technica", ChildrenOf(ui.FeedTree)[0].GetChildren()
+ .Single(p => p.GetAutomationControlType() == AutomationControlType.TreeItem).GetName());
+ }
+
+ [WpfFact]
+ public void Headlines_are_named_by_their_title()
+ {
+ using var ui = new FocusHarness();
+ Assert.Equal(new[] { "Headline one", "Headline two" },
+ ChildrenOf(ui.Headlines).Select(p => p.GetName()));
+ }
+
+ [Fact]
+ public void Items_without_a_container_fall_back_to_their_title_not_the_class_name()
+ {
+ // A row scrolled out of a virtualized list has no container, and WPF then names it by
+ // the item's ToString.
+ Assert.Equal("BBC News", new FeedItem { Title = "BBC News" }.ToString());
+ Assert.Equal("Storm warning", new ArticleItem { Title = "Storm warning" }.ToString());
+ }
+
+ private static AutomationPeer[] ChildrenOf(UIElement control) =>
+ UIElementAutomationPeer.CreatePeerForElement(control).GetChildren()
+ .Where(p => p.GetAutomationControlType() is AutomationControlType.TreeItem
+ or AutomationControlType.ListItem)
+ .ToArray();
+}