Phase 0: P1 accessibility and theming wave (#22)

- Status brushes resolve theme- and high-contrast-aware WinUI semantic
  resources per call (SystemFillColor Success/Critical/Caution/Attention)
  instead of cached color literals; a Segoe Fluent status glyph sits
  beside the text so state is never color-alone
- Inline row errors use SystemFillColorCriticalBrush
- StatusText is a real live region: LiveSetting=Polite plus an explicit
  LiveRegionChanged raise on every update (XAML never raises it
  automatically — confirmed against Microsoft docs by verification)
- Per-repo failures raise UIA notifications via the ListView peer
  (coalesced with ImportantMostRecent); AnnouncementRequested event on
  the view model with two pinning tests
- AutomationProperties.Name on row/select-all checkboxes, Resolve links,
  overall ProgressBar, and both ProgressRings
- App-level RequestedTheme set at startup from settings so app-resource
  theme lookups agree with the visible theme

Verification: 8 doc-grounded claims checked, 1 found wrong (the silent
live region) and fixed before ship. 116/116 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James MaesandClaude Fable 5 committed 2026-07-04 12:55:47 -05:00
1 parent 7aea030aee
commit 3a87f9ef9c
7 files changed
+164 -17

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"posthog@claude-plugins-official": true,
"playwright@claude-plugins-official": true
}
}
+36
View File
@@ -176,6 +176,42 @@ public sealed class MainViewModelTests
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
public async Task Sync_RepoFailure_RaisesAssistiveAnnouncement()
{
_git.CloneHandler = (url, path, token, onProgress, ct) =>
Path.GetFileName(path) == "bravo"
? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask;
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
var announcements = new List<string>();
vm.AnnouncementRequested += announcements.Add;
await vm.SyncCommand.ExecuteAsync(null);
var announcement = Assert.Single(announcements);
Assert.Contains("bravo", announcement);
Assert.Contains("failed", announcement);
Assert.Contains("boom", announcement);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
public async Task Sync_AllSucceed_RaisesNoAnnouncements()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
var announcements = new List<string>();
vm.AnnouncementRequested += announcements.Add;
await vm.SyncCommand.ExecuteAsync(null);
Assert.Empty(announcements);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
public async Task Sync_CancelCommand_CancelsRun_AndReportsCanceled()
{
+10
View File
@@ -561,6 +561,13 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
// ---------------------------------------------------------------- progress
/// <summary>
/// Raised with a message that assistive technology should announce. Per-repo
/// failures use this channel; run summaries land in <see cref="StatusText"/>,
/// whose live region already announces changes. Raised on the UI thread.
/// </summary>
public event Action<string>? AnnouncementRequested;
/// <summary>Applies one engine progress report to the table. Runs on the UI thread.</summary>
private void HandleProgress(RepoProgress report)
{
@@ -580,6 +587,9 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable
if (report.Status == SyncStatus.Failed)
{
_log.Error($"{report.RepoName} failed: {report.Error}");
// Failures never reach StatusText (which has a live region), so assistive
// technology hears them only through this explicit channel.
AnnouncementRequested?.Invoke($"{report.RepoName} failed. {report.Error}");
}
if (report.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled)
+15
View File
@@ -1,3 +1,4 @@
using gclo.ViewModels;
using Microsoft.UI.Xaml;
namespace gclo
@@ -16,6 +17,20 @@ namespace gclo
public App()
{
InitializeComponent();
// App-level so Application.Current.Resources theme lookups (the status
// brushes) agree with the visible theme. Only settable here, before any
// window exists; mid-session theme changes still use the root element's
// RequestedTheme, and status brushes adopt them as row statuses update.
string theme = AppSettings.Load().Theme;
if (theme == "Light")
{
RequestedTheme = ApplicationTheme.Light;
}
else if (theme == "Dark")
{
RequestedTheme = ApplicationTheme.Dark;
}
}
/// <summary>
+19 -4
View File
@@ -78,6 +78,7 @@
Height="20"
Margin="0,0,0,8"
VerticalAlignment="Bottom"
AutomationProperties.Name="Loading organizations"
IsActive="{x:Bind ViewModel.IsLoadingOrgs, Mode=OneWay}" />
</Grid>
@@ -134,6 +135,7 @@
Width="20"
Height="20"
VerticalAlignment="Center"
AutomationProperties.Name="Loading repositories"
IsActive="{x:Bind ViewModel.IsLoadingRepos, Mode=OneWay}" />
<Button
MinWidth="110"
@@ -163,9 +165,12 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Spacing="4">
<ProgressBar
AutomationProperties.Name="Overall sync progress"
Maximum="{x:Bind ViewModel.TotalCount, Mode=OneWay}"
Value="{x:Bind ViewModel.CompletedCount, Mode=OneWay}" />
<TextBlock
x:Name="StatusTextBlock"
AutomationProperties.LiveSetting="Polite"
Text="{x:Bind ViewModel.StatusText, Mode=OneWay}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
@@ -191,6 +196,7 @@
Padding="0"
VerticalAlignment="Center"
ToolTipService.ToolTip="Select all"
AutomationProperties.Name="Select all repositories"
IsChecked="{x:Bind ViewModel.AllSelected, Mode=TwoWay}" />
<Button
Grid.Column="1"
@@ -275,6 +281,7 @@
MinWidth="0"
Padding="0"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Name, Mode=OneTime}"
IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />
<TextBlock
Grid.Row="0"
@@ -289,9 +296,16 @@
Margin="0,0,12,0"
VerticalAlignment="Center"
Spacing="2">
<TextBlock
Text="{x:Bind StatusText, Mode=OneWay}"
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon
FontSize="12"
VerticalAlignment="Center"
Glyph="{x:Bind local:StatusFormat.GlyphFor(Status), Mode=OneWay}"
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
<TextBlock
Text="{x:Bind StatusText, Mode=OneWay}"
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
</StackPanel>
<ProgressBar
Maximum="1"
Value="{x:Bind ProgressValue, Mode=OneWay}"
@@ -318,7 +332,7 @@
Grid.Column="0"
Grid.ColumnSpan="5"
TextWrapping="Wrap"
Foreground="Crimson"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Text="{x:Bind Error, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasError), Mode=OneWay}" />
<HyperlinkButton
@@ -327,6 +341,7 @@
Grid.ColumnSpan="5"
Padding="0"
HorizontalAlignment="Left"
AutomationProperties.Name="{x:Bind local:StatusFormat.ResolveAutomationName(Name), Mode=OneTime}"
Content="Resolve…"
Command="{Binding DataContext.ResolvePathsCommand, ElementName=RepoListView}"
CommandParameter="{x:Bind}"
+41
View File
@@ -31,6 +31,18 @@ namespace gclo
ViewModel = new MainViewModel(log: _log);
InitializeComponent();
ViewModel.AnnouncementRequested += AnnounceToAssistiveTechnology;
// LiveSetting alone does not announce: XAML never raises LiveRegionChanged
// automatically, so each StatusText update must raise it in code-behind.
ViewModel.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(MainViewModel.StatusText))
{
// Enqueued so the binding has pushed the new text before the event.
DispatcherQueue.TryEnqueue(RaiseStatusLiveRegionChanged);
}
};
Title = "gclo — Git Clone Large Organizations";
// The view model stays UI-free: when ResolvePathsCommand needs the user's
@@ -117,6 +129,35 @@ namespace gclo
private void ExitMenuItem_Click(object sender, RoutedEventArgs e) => Close();
private void RaiseStatusLiveRegionChanged()
{
var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(StatusTextBlock)
?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(StatusTextBlock);
peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged);
}
/// <summary>
/// Raises a UIA notification so screen readers hear per-repo failures. Routed
/// through the ListView's peer: panels like Grid have no automation peer.
/// ImportantMostRecent coalesces bursts when many repositories fail at once.
/// </summary>
private void AnnounceToAssistiveTechnology(string message)
{
if (!DispatcherQueue.HasThreadAccess)
{
DispatcherQueue.TryEnqueue(() => AnnounceToAssistiveTechnology(message));
return;
}
var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(RepoListView)
?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(RepoListView);
peer?.RaiseNotificationEvent(
Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationKind.ActionCompleted,
Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationProcessing.ImportantMostRecent,
message,
"gclo-repo-status");
}
private async void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new LogViewerDialog(_log) { XamlRoot = Content.XamlRoot };
+37 -13
View File
@@ -8,22 +8,46 @@ namespace gclo;
/// <summary>Static helpers for x:Bind function bindings in MainWindow.xaml.</summary>
public static class StatusFormat
{
// Created lazily on first x:Bind evaluation, which happens on the UI thread.
private static readonly SolidColorBrush QueuedBrush = new(Colors.Gray);
private static readonly SolidColorBrush ActiveBrush = new(Colors.DodgerBlue);
private static readonly SolidColorBrush DoneBrush = new(Colors.ForestGreen);
private static readonly SolidColorBrush FailedBrush = new(Colors.Crimson);
private static readonly SolidColorBrush CanceledBrush = new(Colors.Orange);
public static Brush BrushFor(SyncStatus status) => status switch
/// <summary>
/// Theme- and high-contrast-aware brush for a status. Resolved from the app's
/// resources on every call so the current theme's instance is returned; x:Bind
/// re-invokes this whenever the bound Status changes.
/// </summary>
public static Brush BrushFor(SyncStatus status) => ResourceBrush(status switch
{
SyncStatus.Cloning or SyncStatus.Pulling => ActiveBrush,
SyncStatus.Done => DoneBrush,
SyncStatus.Failed => FailedBrush,
SyncStatus.Canceled => CanceledBrush,
_ => QueuedBrush,
SyncStatus.Cloning or SyncStatus.Pulling => "SystemFillColorAttentionBrush",
SyncStatus.Done => "SystemFillColorSuccessBrush",
SyncStatus.Failed => "SystemFillColorCriticalBrush",
SyncStatus.Canceled => "SystemFillColorCautionBrush",
_ => "TextFillColorSecondaryBrush",
});
/// <summary>
/// Segoe Fluent Icons glyph for a status, shown beside the status text so state
/// is never communicated by color alone.
/// </summary>
public static string GlyphFor(SyncStatus status) => status switch
{
SyncStatus.Queued => "", // Recent (clock)
SyncStatus.Cloning => "", // Download
SyncStatus.Pulling => "", // Sync
SyncStatus.Done => "", // CheckMark
SyncStatus.Failed => "", // Error
SyncStatus.Canceled => "", // Cancel
_ => "",
};
/// <summary>Screen-reader name for a row's path-recovery link.</summary>
public static string ResolveAutomationName(string repoName)
=> $"Resolve invalid paths for {repoName}";
private static Brush ResourceBrush(string key)
=> Application.Current.Resources.TryGetValue(key, out object? value) && value is Brush brush
? brush
// Unreachable with XamlControlsResources merged; a visible-but-neutral
// fallback beats crashing the row template if a key ever disappears.
: new SolidColorBrush(Colors.Gray);
public static Visibility VisibleIf(bool value)
=> value ? Visibility.Visible : Visibility.Collapsed;