From acb4c50743458cdf4a6b7817dc51c8e936f565fa Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 10 Oct 2026 17:01:09 -0500 Subject: [PATCH 1/8] docs(roadmap): plan v1.1.1 accessibility fixes from the audit --- ROADMAP.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 86a91b6..3b496f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -225,6 +225,18 @@ Each release's notes say what it brought, and the decisions above say why. The c | [0.2: Safe, searchable and shareable](https://github.com/hazeliscoding/prompuff/releases/tag/v0.2.0) | 2026-10-07 | Backups, Recently deleted, FTS5 search, remembered values and `.zip` sharing | | [0.1: MVP](https://github.com/hazeliscoding/prompuff/releases/tag/v0.1.0) | 2026-10-07 | The library, `{{variables}}`, versions, Markdown import and export, and updates | +## v1.1.1: Accessibility fixes + +An audit on 2026-10-10 went through the UI tests' screenshots, the themes and the views. These are what it found. + +- [ ] Screen readers and the keyboard: the rating takes the keyboard and reads out as "Usefulness, Solid", toasts and Quick save's error are announced, a favorite reads as one, Show details says when it's open, and the workflow's step strip moves with Left and Right. +- [ ] Contrast and focus: danger buttons on hover, the focused list row against a picked one, the picked option in segmented controls, control edges and empty rating pips at 3:1, counts on the active sidebar row and tags on their tinted fill, and focus outlines that follow each control's corners. `ThemeTests` holds every theme to the new pairs. +- [ ] Layout: long dialog titles wrap, the library and Settings fit the 960-pixel minimum width with a screenshot test at that size, toasts stay clear of the buttons beneath them, the Render tab scrolls once, the data folder's path shows in full on hover, and Settings sections share one spacing. +- [ ] Copy and polish: Quick save's button says Save, the list view shows the rating's word, and small icon buttons use one icon size. +- [ ] Publish v1.1.1. + +**Done when:** a screen reader reads a prompt's rating and favorite and announces a toast, the library and Settings fit a 960 × 600 window, and `ThemeTests` holds all thirteen themes to the new pairs. + ## Getting found These need no release. Each lands as its own pull request whenever it's ready, alongside the milestones below. From d56f8cd4796a2ebd62b1e580b17b84298e7a98ce Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 10 Oct 2026 17:14:36 -0500 Subject: [PATCH 2/8] fix(a11y): let the keyboard rate a prompt and read the rating aloud The editor's rating took only clicks. It now takes Tab focus with a teal ring, steps with the arrow keys, jumps with Home, End and the digits, and clears with Delete. Screen readers read it as a Usefulness slider whose value is the rating's word. --- src/Prompuff.App/Controls/Tones.cs | 88 ++++++++++++++++++- src/Prompuff.App/Themes/Controls.axaml | 4 + src/Prompuff.App/Views/PromptEditorView.axaml | 3 +- .../Prompuff.App.Tests/AccessibilityTests.cs | 49 +++++++++++ 4 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/Prompuff.App/Controls/Tones.cs b/src/Prompuff.App/Controls/Tones.cs index 0d61a75..61f39ea 100644 --- a/src/Prompuff.App/Controls/Tones.cs +++ b/src/Prompuff.App/Controls/Tones.cs @@ -1,4 +1,7 @@ using Avalonia; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; @@ -94,7 +97,8 @@ public object? RemoveCommandParameter } /// Five small squares showing usefulness from 1 to 5. Clicking a square sets the rating; -/// clicking the current rating clears it. +/// clicking the current rating clears it. When it's interactive it also takes the keyboard, and screen +/// readers read it as a slider whose value is the rating's word, such as "Solid". public sealed class RatingPips : Control { public static readonly StyledProperty ValueProperty = @@ -115,12 +119,21 @@ public sealed class RatingPips : Control public static readonly StyledProperty EmptyBrushProperty = AvaloniaProperty.Register(nameof(EmptyBrush)); + /// The ring drawn around the pips while they have keyboard focus; styles set it on :focus-visible. + public static readonly StyledProperty FocusBrushProperty = + AvaloniaProperty.Register(nameof(FocusBrush)); + + private const double FocusInset = 3; + private int? _hover; static RatingPips() { - AffectsRender(ValueProperty, FilledBrushProperty, EmptyBrushProperty); + AffectsRender(ValueProperty, FilledBrushProperty, EmptyBrushProperty, FocusBrushProperty); AffectsMeasure(PipSizeProperty, SpacingProperty); + IsInteractiveProperty.Changed.AddClassHandler((pips, _) => pips.Focusable = pips.IsInteractive); + ValueProperty.Changed.AddClassHandler((pips, e) => + (ControlAutomationPeer.FromElement(pips) as RatingPipsAutomationPeer)?.RaiseValueChanged(e.OldValue as int?, e.NewValue as int?)); } public int? Value @@ -159,6 +172,12 @@ public Avalonia.Media.IBrush? EmptyBrush set => SetValue(EmptyBrushProperty, value); } + public Avalonia.Media.IBrush? FocusBrush + { + get => GetValue(FocusBrushProperty); + set => SetValue(FocusBrushProperty, value); + } + protected override Size MeasureOverride(Size availableSize) => new(PipSize * 5 + Spacing * 4, PipSize); public override void Render(Avalonia.Media.DrawingContext context) @@ -170,8 +189,42 @@ public override void Render(Avalonia.Media.DrawingContext context) var rect = new Rect(i * (PipSize + Spacing), (Bounds.Height - PipSize) / 2, PipSize, PipSize); context.DrawRectangle(i < shown ? FilledBrush : EmptyBrush, null, rect, radius, radius); } + + if (FocusBrush is { } focus) + { + var ring = new Rect(Bounds.Size).Inflate(FocusInset); + context.DrawRectangle(null, new Avalonia.Media.Pen(focus, 1.5), ring, radius + FocusInset, radius + FocusInset); + } + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!IsInteractive || e.Handled || e.KeyModifiers != KeyModifiers.None || RatingForKey(e.Key, Value ?? 0) is not { } next) + { + return; + } + + Value = next == 0 ? null : next; + e.Handled = true; } + /// The rating a key moves to, where 0 clears it: arrows step, Home and End jump to the ends, and 0 to 5 + /// pick one. Other keys return null. + public static int? RatingForKey(Key key, int current) => key switch + { + Key.Right or Key.Up => Math.Min(current + 1, 5), + Key.Left or Key.Down => Math.Max(current - 1, 0), + Key.Home => 1, + Key.End => 5, + Key.Delete or Key.Back => 0, + >= Key.D0 and <= Key.D5 => key - Key.D0, + >= Key.NumPad0 and <= Key.NumPad5 => key - Key.NumPad0, + _ => null, + }; + + protected override AutomationPeer OnCreateAutomationPeer() => new RatingPipsAutomationPeer(this); + protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); @@ -209,3 +262,34 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) return index is >= 1 and <= 5 ? index : null; } } + +/// Reads the rating as a slider whose value is its word ("Solid"), which screen readers say aloud, and +/// lets them set it by word or number. +public sealed class RatingPipsAutomationPeer(RatingPips owner) : ControlAutomationPeer(owner), IValueProvider +{ + private RatingPips Pips => (RatingPips)Owner; + + public bool IsReadOnly => !Pips.IsInteractive; + + public string? Value => ViewModels.Format.Rating(Pips.Value); + + public void SetValue(string? value) + { + var rating = int.TryParse(value, out var number) + ? number + : Enumerable.Range(0, 6).FirstOrDefault(i => string.Equals(ViewModels.Format.Rating(i), value?.Trim(), StringComparison.OrdinalIgnoreCase), -1); + if (!IsReadOnly && rating is >= 0 and <= 5) + { + Pips.Value = rating == 0 ? null : rating; + } + } + + internal void RaiseValueChanged(int? oldValue, int? newValue) => + RaisePropertyChangedEvent(ValuePatternIdentifiers.ValueProperty, ViewModels.Format.Rating(oldValue), ViewModels.Format.Rating(newValue)); + + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Slider; + + protected override bool IsContentElementCore() => Pips.IsInteractive; + + protected override bool IsControlElementCore() => Pips.IsInteractive; +} diff --git a/src/Prompuff.App/Themes/Controls.axaml b/src/Prompuff.App/Themes/Controls.axaml index 3e08ba1..0461633 100644 --- a/src/Prompuff.App/Themes/Controls.axaml +++ b/src/Prompuff.App/Themes/Controls.axaml @@ -634,6 +634,10 @@ + + +