Phase 0: P1 accessibility/theming wave (#22) #23

Merged
KofTwentyTwo merged 2 commits from dev into main 2026-07-04 18:00:05 +00:00
8 changed files with 320 additions and 17 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"posthog@claude-plugins-official": true,
"playwright@claude-plugins-official": true
}
}
+156
View File
@@ -0,0 +1,156 @@
# Implementation plan: Accounts mode + workspace redesign
Delivers issue #16 (Accounts mode) built directly into the layout redesign
direction recorded on that issue, with issue #22's findings reconciled along the
way. Tracker of record: GitHub Issues; this document is the executable plan.
**Decisions already made (owner-confirmed on #16):** Windows Credential Manager
for tokens · one account = one org · Sync All · CLI parity (`--account`).
**Defaults standing unless overridden on #16:** NavigationView with Quick Sync
pinned · Sync All runs accounts sequentially · accounts remember the full
settings bundle and show a status badge · no account cap.
Every phase ends the same way: 0-warning build, all tests green, format clean,
app launch verified, gated PR `dev -> main`, and a `v0.1.0-beta.N` tag so each
increment is installable and self-update keeps getting exercised.
---
## Phase 0 — P1 accessibility & theming wave (issue #22)
Prerequisite, not part of the redesign; everything here transfers unchanged
into the new layout.
1. `StatusFormat.BrushFor` → resolve WinUI semantic brushes per call from
`Application.Current.Resources` (`SystemFillColorSuccessBrush` Done,
`SystemFillColorCriticalBrush` Failed, `SystemFillColorCautionBrush`
Canceled, `SystemFillColorAttentionBrush` Cloning/Pulling, neutral Queued).
Add `GlyphFor(SyncStatus)` returning Segoe Fluent Icons glyphs and a
`FontIcon` beside the status text so state is never color-alone.
2. `MainWindow.xaml` inline error: `Foreground="Crimson"` →
`{ThemeResource SystemFillColorCriticalBrush}`.
3. `AutomationProperties.LiveSetting="Polite"` on StatusText; raise UIA
notifications for run summary and per-repo failures
(`FrameworkElementAutomationPeer.RaiseNotificationEvent`).
4. `AutomationProperties.Name` on per-row checkboxes (repo name), select-all,
Resolve links (repo-specific), overall ProgressBar, both ProgressRings.
Verification: build/launch; Accessibility Insights or Narrator spot-check is an
owner follow-up (headless agent cannot hear announcements). Tag `beta.5`.
## Phase 1 — Accounts foundation (libraries + CLI; no UI)
New in `gclo.ViewModels` (app-layer library, headless-testable):
- `Account` model: `Id` (guid), `Name` (unique, case-insensitive),
`Description`, `Organization`, `TargetRoot`, `CreateOrgSubfolder`,
`MaxConcurrency`, `LastSyncUtc`, `LastSyncSummary` (counts string).
Metadata JSON at `%LOCALAPPDATA%\gclo\accounts.json` — never contains tokens.
- `ITokenVault` { `Store(accountId, token)`, `TryRetrieve(accountId)`,
`Delete(accountId)` } — implementation `CredentialManagerVault` using
`Windows.Security.Credentials.PasswordVault`, resource name
`gclo:account:<id>`; `InMemoryVault` fake for tests.
- `AccountsStore`: CRUD + rename-safe persistence (same never-throw discipline
as `AppSettings`); deleting an account deletes its vault entry.
- CLI (`gclo.Cli`): `gclo accounts` (list: name, org, target, last sync);
`gclo sync --account <name>` resolves registry + vault (error 2 if the vault
entry is missing); explicit `--org/--token-*` flags override account values.
`docs/CLI.md` section.
Tests: store CRUD/uniqueness/corrupt-file recovery; vault fake round-trip +
delete-on-remove; CLI resolution and override precedence. The real PasswordVault
is thin glue — verified by launch-time smoke, not unit tests. Tag `beta.6`.
## Phase 2 — Navigation shell
- `MainWindow` becomes a `NavigationView` (left pane): pinned "Quick Sync"
item, accounts from `AccountsStore` (name + status dot + last-sync tooltip),
footer "+ Add account". Content region hosts one `WorkspacePage`
(UserControl) bound to a `WorkspaceViewModel`.
- `WorkspaceViewModel` = today's `MainViewModel` generalized: constructed from
an `Account` (persistent) or the ephemeral Quick Sync account (today's
behavior, nothing saved). One cached VM per account per session so switching
accounts never loses a running sync; nav badge reflects each VM's state
(running/ok/failed).
- Menu bar survives (File/View/Help); Settings keeps global defaults whichseed
new accounts.
Tests: WorkspaceViewModel construction from account values; nav-badge state
mapping (pure VM). Tag `beta.7`.
## Phase 3 — Workspace redesign (the #16-comment direction)
Inside `WorkspacePage`, replacing the stacked form:
1. **Connection chip**: org · connected-state · repo count · destination +
Edit (opens wizard for accounts; inline flyout for Quick Sync). Org-lookup
feedback lives here — the multiplexed StatusText dies.
2. **Hero table card**: header + ListView on
`LayerFillColorDefaultBrush`, `CornerRadius=8`; attached toolbar =
selection summary ("12 of 87 selected"), filter box, options flyout (gear:
parallelism, org subfolder), Refresh, and ONE accent CTA "Sync N repos".
During a run the toolbar's right side swaps to progress (bar + "34 of 87" +
Cancel); progress scoped to the run's selected set (fixes the #22
progress-honesty P2).
3. **Results InfoBar** (severity-aware): summary + inline Retry failed / Open
folder; replaces permanent buttons and the stale Retry visibility bug.
4. **Designed empty states**: no token → connect card (+ "how to create a
token" link, fixing that P2); connected → "Load N repositories from {org}";
filter-no-match state.
5. **Reconciled #22 items in this phase**: input disabling while running
(CanEditInputs), min window size via `OverlappedPresenter`, sort-header
accessibility, table row composed automation names, access keys +
accelerators (Ctrl+R / Ctrl+Enter / Esc), microcopy fixes ("Parallel
clones", placement preview wording, ellipsis, pluralization).
Tests: VM-level (selection summary, run-scoped progress, InfoBar state,
CanEditInputs); XAML verified by build + launch. Tag `beta.8`.
## Phase 4 — Account wizard + Sync All
- `AccountWizardDialog`: 1) name + description → 2) token (stored to vault,
validated live via `IOrganizationLister`) → 3) org picker (dropdown from the
token; manual entry fallback) → 4) destination + options (seeded from
Settings defaults) → summary/save. Same dialog pre-filled = Edit. Delete
confirms and removes the vault entry.
- **Sync All** (nav pane footer button): sequentially runs each account's
WorkspaceViewModel sync; per-account rolled-up progress via nav badges;
cancel stops the queue after the in-flight account.
- Activity log gains account context in messages (never tokens).
Tests: wizard VM validation per step; Sync All ordering/cancellation with fake
clients. Tag `beta.9`.
## Phase 5 — Close-out & release
- Reconcile remaining #22 P3s; README rewrite for accounts + new screenshots
(issue #10 — needs owner or an interactive session for captures).
- Docs: README, CLI.md, SECURITY.md (vault storage note), RELEASING.md
untouched.
- Owner gate: confirm self-update beta-chain works installed (#6), then tag
**v0.1.0** stable; winget first submission (#7) follows.
---
## Risks & watch items
- **PasswordVault in unpackaged builds**: works without package identity, but
verify early in Phase 1 on the unpackaged launch path (it is the dev loop).
Fallback if it misbehaves: `CredRead/CredWrite` P/Invoke behind the same
`ITokenVault`.
- **One VM per account with concurrent runs**: memory/dispatcher pressure is
fine at ~5 accounts, but Sync All is deliberately sequential — do not let a
future "parallel accounts" ask sneak in without revisiting disk/API limits.
- **NavigationView + MenuBar chrome**: two top-level chrome systems can fight;
prototype the shell (Phase 2) before styling it (Phase 3).
- **Quick Sync regression risk**: it must behave exactly like today's app;
its VM tests are the regression net.
## Execution notes
- Order is strict: 0 → 1 → 2 → 3 → 4 → 5; each phase is a separate PR through
the required checks; no phase starts until the previous merged.
- Multi-agent builds continue to use pinned cross-agent contracts (the pattern
that has held since the productionize wave); single-writer per file area.
- Any change to the four standing defaults happens as a comment on #16 before
the affected phase starts.
+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;