Skip to content
12 changes: 12 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

- [x] 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 heart says whether its prompt is a favorite, Show details says when it's open, and the workflow's Previous and Next buttons point left and right like its step strip.
- [ ] 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ A prompt lives in one collection, or none, and can have any number of tags.

A prompt has a **title**, a one-line **description**, the **prompt** itself (Markdown), and **Why this worked**, notes to your future self.

**Saving:** changes to those four fields wait for **Save** (Ctrl+S), and each save makes a new version. Prompuff also saves when you leave the prompt or close the window. The favorite heart (Ctrl+D), the usefulness rating (Meh, Okay, Solid, Great, Cooked), the collection and the tags save as soon as you change them, and don't make versions.
**Saving:** changes to those four fields wait for **Save** (Ctrl+S), and each save makes a new version. Prompuff also saves when you leave the prompt or close the window. The favorite heart (Ctrl+D), the usefulness rating (Meh, Okay, Solid, Great, Cooked; Tab to it and use the arrow keys or 0 to 5), the collection and the tags save as soon as you change them, and don't make versions.

**Copying:** **Copy** copies the prompt with your variable values filled in. The **…** menu has **Copy template with variables** (Ctrl+Shift+C), which copies it as written.

Expand Down
1 change: 1 addition & 0 deletions scripts/generate-icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
("ArrowUp", "arrow-up"),
("ArrowDown", "arrow-down"),
("ArrowRight", "arrow-right"),
("ArrowLeft", "arrow-left"),
("CircleCheck", "circle-check"),
("Plug", "plug"),
("Terminal", "terminal"),
Expand Down
10 changes: 10 additions & 0 deletions src/Prompuff.App/Controls/AccessibleNames.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Automation;
using Avalonia.Automation.Peers;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.LogicalTree;
Expand All @@ -25,12 +26,21 @@
Control.UnloadedEvent.AddClassHandler<Button>((button, _) => Stop(button));
Control.LoadedEvent.AddClassHandler<TextBox>((box, _) => Follow(box));
Control.UnloadedEvent.AddClassHandler<TextBox>((box, _) => Stop(box));

// Windows announces a live region when its name changes, but Avalonia's peers don't report that change, so report it here.
AutomationProperties.NameProperty.Changed.AddClassHandler<Control>((control, e) =>
{
if (AutomationProperties.GetLiveSetting(control) != AutomationLiveSetting.Off)
{
ControlAutomationPeer.FromElement(control)?.RaisePropertyChangedEvent(AutomationElementIdentifiers.NameProperty, e.OldValue, e.NewValue);
}
});
}

/// <summary>The name this control's own content gives it: its text, its tooltip, or its placeholder.</summary>
public static string? Describe(Control control) => control switch
{
TextBox box => Clean(box.Watermark?.ToString()) ?? Clean(ToolTip.GetTip(box) as string),

Check warning on line 43 in src/Prompuff.App/Controls/AccessibleNames.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

'TextBox.Watermark' is obsolete: 'Use PlaceholderText instead.'

Check warning on line 43 in src/Prompuff.App/Controls/AccessibleNames.cs

View workflow job for this annotation

GitHub Actions / Build and test (macos-latest)

'TextBox.Watermark' is obsolete: 'Use PlaceholderText instead.'

Check warning on line 43 in src/Prompuff.App/Controls/AccessibleNames.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

'TextBox.Watermark' is obsolete: 'Use PlaceholderText instead.'
ContentControl { Content: string text } => Clean(text),
ContentControl content => Clean(string.Join(" ", Texts(content))) ?? Clean(ToolTip.GetTip(content) as string),
_ => Clean(ToolTip.GetTip(control) as string),
Expand All @@ -49,7 +59,7 @@
// New content brings new text blocks; follow those once they're laid out.
Avalonia.Threading.Dispatcher.UIThread.Post(() => Follow(control), Avalonia.Threading.DispatcherPriority.Loaded);
}
else if (e.Property == ToolTip.TipProperty || e.Property == TextBox.WatermarkProperty)

Check warning on line 62 in src/Prompuff.App/Controls/AccessibleNames.cs

View workflow job for this annotation

GitHub Actions / Build and test (ubuntu-latest)

'TextBox.WatermarkProperty' is obsolete: 'Use PlaceholderTextProperty instead.'

Check warning on line 62 in src/Prompuff.App/Controls/AccessibleNames.cs

View workflow job for this annotation

GitHub Actions / Build and test (macos-latest)

'TextBox.WatermarkProperty' is obsolete: 'Use PlaceholderTextProperty instead.'

Check warning on line 62 in src/Prompuff.App/Controls/AccessibleNames.cs

View workflow job for this annotation

GitHub Actions / Build and test (windows-latest)

'TextBox.WatermarkProperty' is obsolete: 'Use PlaceholderTextProperty instead.'
{
Update();
}
Expand Down
1 change: 1 addition & 0 deletions src/Prompuff.App/Controls/IconData.g.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ internal static class IconData
["ArrowUp"] = "M 5 12 l 7 -7 l 7 7 M 12 19 V 5",
["ArrowDown"] = "M 12 5 v 14 M 19 12 l -7 7 l -7 -7",
["ArrowRight"] = "M 5 12 h 14 M 12 5 l 7 7 l -7 7",
["ArrowLeft"] = "M 12 19 l -7 -7 l 7 -7 M 19 12 H 5",
["CircleCheck"] = "M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z M 9 12 l 2 2 l 4 -4",
["Plug"] = "M 12 22 v -5 M 9 8 V 2 M 15 8 V 2 M 18 8 v 5 a 4 4 0 0 1 -4 4 h -4 a 4 4 0 0 1 -4 -4 V 8 Z",
["Terminal"] = "M 4 17 L 10 11 L 4 5 M 12 19 L 20 19",
Expand Down
88 changes: 86 additions & 2 deletions src/Prompuff.App/Controls/Tones.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -94,7 +97,8 @@ public object? RemoveCommandParameter
}

/// <summary>Five small squares showing usefulness from 1 to 5. Clicking a square sets the rating;
/// clicking the current rating clears it.</summary>
/// 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".</summary>
public sealed class RatingPips : Control
{
public static readonly StyledProperty<int?> ValueProperty =
Expand All @@ -115,12 +119,21 @@ public sealed class RatingPips : Control
public static readonly StyledProperty<Avalonia.Media.IBrush?> EmptyBrushProperty =
AvaloniaProperty.Register<RatingPips, Avalonia.Media.IBrush?>(nameof(EmptyBrush));

/// <summary>The ring drawn around the pips while they have keyboard focus; styles set it on :focus-visible.</summary>
public static readonly StyledProperty<Avalonia.Media.IBrush?> FocusBrushProperty =
AvaloniaProperty.Register<RatingPips, Avalonia.Media.IBrush?>(nameof(FocusBrush));

private const double FocusInset = 3;

private int? _hover;

static RatingPips()
{
AffectsRender<RatingPips>(ValueProperty, FilledBrushProperty, EmptyBrushProperty);
AffectsRender<RatingPips>(ValueProperty, FilledBrushProperty, EmptyBrushProperty, FocusBrushProperty);
AffectsMeasure<RatingPips>(PipSizeProperty, SpacingProperty);
IsInteractiveProperty.Changed.AddClassHandler<RatingPips>((pips, _) => pips.Focusable = pips.IsInteractive);
ValueProperty.Changed.AddClassHandler<RatingPips>((pips, e) =>
(ControlAutomationPeer.FromElement(pips) as RatingPipsAutomationPeer)?.RaiseValueChanged(e.OldValue as int?, e.NewValue as int?));
}

public int? Value
Expand Down Expand Up @@ -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)
Expand All @@ -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;
}

/// <summary>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.</summary>
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);
Expand Down Expand Up @@ -209,3 +262,34 @@ protected override void OnPointerPressed(PointerPressedEventArgs e)
return index is >= 1 and <= 5 ? index : null;
}
}

/// <summary>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.</summary>
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;
}
4 changes: 4 additions & 0 deletions src/Prompuff.App/Themes/Controls.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,10 @@
<Setter Property="FilledBrush" Value="{DynamicResource LavenderBrush}" />
<Setter Property="EmptyBrush" Value="{DynamicResource Bg4Brush}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
</Style>
<Style Selector="c|RatingPips:focus-visible">
<Setter Property="FocusBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="c|Puff">
<Setter Property="BodyBrush" Value="{DynamicResource LavenderBrush}" />
Expand Down
3 changes: 3 additions & 0 deletions src/Prompuff.App/ViewModels/LibraryViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,11 @@ public PromptCardViewModel(LibraryViewModel owner, PromptSummary summary, string
public bool IsDeleted => _summary.DeletedAt is not null;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FavoriteLabel))]
private bool _isFavorite;

public string FavoriteLabel => Format.Favorite(IsFavorite);

/// <summary>The card the arrow keys are on.</summary>
[ObservableProperty]
private bool _isCurrent;
Expand Down
2 changes: 2 additions & 0 deletions src/Prompuff.App/ViewModels/PromptEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ public PromptEditorViewModel(
private string _notes = string.Empty;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FavoriteLabel))]
private bool _isFavorite;

[ObservableProperty]
Expand Down Expand Up @@ -242,6 +243,7 @@ public PromptEditorViewModel(
public string VariableCountLabel => Format.Count(Variables.Count, "variable") + " detected";
public string CharsLabel => $"{Body.Length.ToString("N0", CultureInfo.InvariantCulture)} chars · ~{(Body.Length / 4).ToString("N0", CultureInfo.InvariantCulture)} tokens";
public string RatingLabel => Format.Rating(Rating);
public string FavoriteLabel => Format.Favorite(IsFavorite) + " (Ctrl D)";
public string CollectionLabel => SelectedCollection.Name;
public string CollectionTone => SelectedCollection.Tone;
public string VersionLabel => IsNew ? "Not saved yet" : $"v{CurrentVersion} · edited {Format.Relative(UpdatedAt, _time.GetUtcNow())}";
Expand Down
6 changes: 6 additions & 0 deletions src/Prompuff.App/ViewModels/ShellServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ public sealed partial class ToastViewModel(string title, string? subtitle, bool
public string? Subtitle { get; } = subtitle;
public bool HasSubtitle => !string.IsNullOrEmpty(Subtitle);
public bool IsHappy { get; } = isHappy;

/// <summary>What a screen reader says when the toast appears. Titles end with a full stop, so the subtitle follows on.</summary>
public string Announcement => HasSubtitle ? $"{Title} {Subtitle}" : Title;
}

public sealed partial class ToastService(AppearanceState appearance) : ObservableObject
Expand Down Expand Up @@ -390,6 +393,9 @@ internal static class Format

public static string Rating(int? rating) => RatingLabels[Math.Clamp(rating ?? 0, 0, 5)];

/// <summary>The heart's tooltip, which is also what screen readers call it, so it says whether the prompt is a favorite.</summary>
public static string Favorite(bool isFavorite) => isFavorite ? "Remove from favorites" : "Add to favorites";

public static string Count(int count, string singular, string? plural = null) =>
$"{count.ToString(CultureInfo.InvariantCulture)} {(count == 1 ? singular : plural ?? singular + "s")}";

Expand Down
4 changes: 2 additions & 2 deletions src/Prompuff.App/Views/LibraryView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@
</Panel>
<TextBlock Grid.Column="1" Classes="card-title" Text="{Binding Title}" TextWrapping="Wrap" TextTrimming="CharacterEllipsis" />
<Button Grid.Column="2" Classes="ghost icon sm" Classes.active="{Binding IsFavorite}" Margin="0,-3,-4,0" VerticalAlignment="Top"
Command="{Binding ToggleFavoriteCommand}" ToolTip.Tip="Favorite" IsVisible="{Binding !IsDeleted}">
Command="{Binding ToggleFavoriteCommand}" ToolTip.Tip="{Binding FavoriteLabel}" IsVisible="{Binding !IsDeleted}">
<c:Icon Kind="Heart" Size="14" IsFilled="{Binding IsFavorite}" />
</Button>
</Grid>
Expand Down Expand Up @@ -329,7 +329,7 @@
<c:TagChipStrip Grid.Column="2" Names="{Binding TagNames}" Wrap="False" VerticalAlignment="Center" ClipToBounds="True" />
<c:RatingPips Grid.Column="3" Value="{Binding Rating, Mode=OneWay}" ToolTip.Tip="{Binding RatingLabel}" HorizontalAlignment="Left" />
<TextBlock Grid.Column="4" Classes="data faint" Text="{Binding EditedLabel}" ToolTip.Tip="{Binding EditedTooltip}" VerticalAlignment="Center" />
<Button Grid.Column="5" Classes="ghost icon sm" Classes.active="{Binding IsFavorite}" Command="{Binding ToggleFavoriteCommand}" ToolTip.Tip="Favorite" IsVisible="{Binding !IsDeleted}">
<Button Grid.Column="5" Classes="ghost icon sm" Classes.active="{Binding IsFavorite}" Command="{Binding ToggleFavoriteCommand}" ToolTip.Tip="{Binding FavoriteLabel}" IsVisible="{Binding !IsDeleted}">
<c:Icon Kind="Heart" Size="14" IsFilled="{Binding IsFavorite}" />
</Button>
<Button Grid.Column="5" Classes="ghost icon sm" Command="{Binding RestoreCommand}" ToolTip.Tip="Restore" IsVisible="{Binding IsDeleted}">
Expand Down
33 changes: 24 additions & 9 deletions src/Prompuff.App/Views/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@
<TextBlock Text="Open data folder" />
</StackPanel>
</Button>
<Button Classes="ghost" Content="Show details" IsVisible="{Binding HasDetails}" Command="{Binding ToggleDetailsCommand}" />
<Button Classes="ghost" IsVisible="{Binding HasDetails}" Command="{Binding ToggleDetailsCommand}">
<Panel>
<TextBlock Text="Show details" IsVisible="{Binding !ShowDetails}" />
<TextBlock Text="Hide details" IsVisible="{Binding ShowDetails}" />
</Panel>
</Button>
</StackPanel>
<Border Classes="well" Padding="10" IsVisible="{Binding ShowDetails}" MaxHeight="220">
<ScrollViewer>
Expand Down Expand Up @@ -353,6 +358,10 @@
</Grid>
</Border>

<!-- Stays in the window with no size, so screen readers hear its name change when an error appears. -->
<Panel x:Name="QuickSaveErrorAnnouncer" DockPanel.Dock="Bottom" Height="0"
AutomationProperties.LiveSetting="Assertive" AutomationProperties.AccessibilityView="Content" AutomationProperties.ControlTypeOverride="Text" AutomationProperties.Name="{Binding Error}" />

<StackPanel Margin="18,14" Spacing="12">
<TextBox Classes="mono"
Height="110"
Expand Down Expand Up @@ -446,8 +455,10 @@
<TextBox x:Name="DialogInput" IsVisible="{Binding HasInput}" Text="{Binding InputText}" PlaceholderText="{Binding InputPlaceholder}" Height="32" />
<Button Classes="ghost sm" HorizontalAlignment="Left" IsVisible="{Binding HasDetails}" Command="{Binding ToggleDetailsCommand}" Margin="-8,0,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<c:Icon Kind="ChevronRight" Size="12" />
<TextBlock Text="Show details" />
<c:Icon Kind="ChevronRight" Size="12" IsVisible="{Binding !ShowDetails}" />
<c:Icon Kind="ChevronDown" Size="12" IsVisible="{Binding ShowDetails}" />
<TextBlock Text="Show details" IsVisible="{Binding !ShowDetails}" />
<TextBlock Text="Hide details" IsVisible="{Binding ShowDetails}" />
</StackPanel>
</Button>
<Border Classes="well" Padding="10" MaxHeight="200" IsVisible="{Binding ShowDetails}">
Expand All @@ -464,17 +475,20 @@
</Panel>
</Grid>

<!-- Toast -->
<Border HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="20"
Padding="12,10,16,10"
<!-- Toast. The outer panel stays in the window, so screen readers hear its name change as a toast appears. -->
<Panel x:Name="ToastAnnouncer"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="20"
IsHitTestVisible="False"
AutomationProperties.LiveSetting="Polite" AutomationProperties.AccessibilityView="Content" AutomationProperties.ControlTypeOverride="Text"
AutomationProperties.Name="{Binding Toasts.Current.Announcement}">
<Border Padding="12,10,16,10"
CornerRadius="8"
Background="{DynamicResource Bg3Brush}"
BorderBrush="{DynamicResource Border2Brush}"
BorderThickness="1"
BoxShadow="{DynamicResource Shadow3}"
IsHitTestVisible="False"
IsVisible="{Binding Toasts.Current, Converter={x:Static ObjectConverters.IsNotNull}}">
<Panel DataContext="{Binding Toasts.Current}">
<StackPanel Orientation="Horizontal" Spacing="10" x:DataType="vm:ToastViewModel">
Expand All @@ -487,5 +501,6 @@
</StackPanel>
</Panel>
</Border>
</Panel>
</Panel>
</Window>
Loading
Loading