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:
1 parent
7aea030aee
commit
3a87f9ef9c
7 files changed
+164
-17
No files matched your search
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"posthog@claude-plugins-official": true,
|
||||
"playwright@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user