diff --git a/docs/android-port.md b/docs/android-port.md
index 8524354..362f19a 100644
--- a/docs/android-port.md
+++ b/docs/android-port.md
@@ -640,6 +640,16 @@ go at 360dp:
the membership test, the tab that lights and the back gesture's first case all read that one property.
The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange
deliberately differently, after the hub itself.
+
+ **And the 2026-10-04 redesign moved it back out.** The bar is VAULTS · CONNECTIONS · SETTINGS. The first
+ entry is the open vault, with a HOSTS · KEYCHAIN · SNIPPETS switch under the vault's header, so the
+ keychain and snippets sit beside the hosts they are used with rather than behind the hub (audit M8);
+ `IsVaultSurface` lights it, and `IsMoreSurface` lost `Keychain` and `Snippets`. SETTINGS keeps what is
+ about this phone and this account: an account card, security, vaults and the log, files, preferences.
+ The strip of open shells above the bar went too — Connections carries the count in a badge instead. The
+ keychain gained an add sheet and an editor page over the editors the shared view model already had, the
+ files screen moved its seven buttons into a selection bar and an actions sheet, and a terminal's `+`
+ sheet now opens SFTP on that terminal's own host.
8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the
same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
reflows a terminal while output is arriving.
diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index 97cd152..d49fc09 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -2058,16 +2058,31 @@ not have. If the password came back, something is storing it — it never was. A
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through
`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press.
-### 11.9 Settings holds the keychain, and back walks out of it
+### 11.9 The keychain and snippets are the vault's, and back walks out of them
-From HOSTS: Settings, then Keychain.
+From HOSTS, tap KEYCHAIN in the switch under the vault's header, then SNIPPETS.
-**Pass:** the bottom bar has three entries and the Settings one stays lit while the keychain is showing.
-The keychain draws its own header with a back arrow — the vault name and sync light are not above it. Back,
-by gesture or by the arrow, returns to Settings and not to HOSTS; a second back returns to HOSTS.
+**Pass:** the bottom bar reads Vaults · Connections · Settings, and Vaults stays lit on all three. The
+vault's name, the sync light, the lock and the switch stay above each one; neither screen draws a header of
+its own. Back from KEYCHAIN or SNIPPETS returns to HOSTS; a second back leaves the application.
-**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first
-case, and those two have to move together — the switch mirrors that property by construction.
+**Failure means:** `IsVaultSurface` and the back gesture's first case have drifted apart — they list the same
+three screens and have to move together.
+
+### 11.9b Every add is the floating button, and every confirm is on the right
+
+On HOSTS, KEYCHAIN and SNIPPETS, and on SFTP once connected, there is a round + (an upload arrow on SFTP)
+at the bottom right, glowing without a square behind it. On the keychain it raises a sheet of five things
+to add; choosing ADD A PASSWORD opens a full-screen page with labels above its boxes. Tap a host that asks
+for a password.
+
+**Pass:** the password sheet opens with the keyboard already up, its box ringed in violet; CANCEL is on the
+left and CONNECT on the right; the header and the bottom bar behind the sheet are dimmed, and tapping either
+closes the sheet. On SFTP, tapping a file puts a bar across the top with its name, a save arrow and ⋮; the ⋮
+sheet has Rename, Move, Permissions and a red Delete.
+
+**Failure means:** a square behind a glowing button is `ClipToBounds` back on (`Phone.axaml`); a bright
+header under a sheet is `PhoneShell.ShowsSheetScrim` missing that sheet.
### 11.10 The accessory keys do not cost the terminal its keyboard · **needs a hardware keyboard**
diff --git a/src/DodoSSH.Client.Android/Theme/Phone.axaml b/src/DodoSSH.Client.Android/Theme/Phone.axaml
index fb3f0d7..c6b42b7 100644
--- a/src/DodoSSH.Client.Android/Theme/Phone.axaml
+++ b/src/DodoSSH.Client.Android/Theme/Phone.axaml
@@ -36,6 +36,156 @@
this paragraph to learn it was deliberate.
-->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -468,9 +649,9 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+ CommandParameter="{x:Static vm:ShellScreen.More}">
+
+
@@ -78,10 +81,59 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
@@ -225,12 +277,12 @@
-
-
-
+
-
-
+
+
+
+
+
+
+
+
+
-
+
-
@@ -464,10 +493,10 @@
-
-
-
+
+
-
+
@@ -604,9 +632,9 @@
Text="A host inside this group uses these wherever it says nothing itself. They are read at connect time rather than copied in, so changing one here changes every host that never overrode it." />
-
-
@@ -628,10 +656,13 @@
twelfth machine while eleven are ticked for deletion is worse, because it does something and the
something is not what the screen is about.
-->
-
+ Command="{Binding OpenAddSheetCommand}">
+
+
@@ -646,9 +677,10 @@
"Which kind of thing are you adding" has no wrong answer and no answer at all is one of them.
And it lives here rather than in PhoneShell. HostKeySheet sits up there because the files screen raises
- it too; nothing but this screen raises this. The cost is that the scrim covers the screen and not the
- 64px nav bar underneath, so the four destinations stay lit and tappable — which for a menu is right:
- navigating away is a perfectly good way to decide not to add anything.
+ it too; nothing but this screen raises this. The cost used to be that the scrim covered the screen and
+ not the header or the nav bar, which stayed lit and tappable round every sheet (audit M12). PhoneShell
+ now dims those two rows as well while any sheet here is up, and a tap on them lowers the sheet — see
+ PhoneShell.ShowsSheetScrim.
-->
@@ -670,7 +702,7 @@
-
+
@@ -678,7 +710,7 @@
-
+
@@ -742,19 +774,19 @@
-
-
-
+
@@ -762,19 +794,19 @@
-
+
-
-
@@ -782,7 +814,7 @@
-
@@ -816,34 +848,73 @@
the box the next tap reads, and it would satisfy the length check that decides whether to raise this at
all. See CancelConnectPassword.
-->
-
+
+
-
+ CornerRadius="22,22,0,0" Padding="20,10,20,18">
+
+ HorizontalAlignment="Center" Margin="0,0,0,4" />
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
@@ -912,7 +983,7 @@
Command="{Binding EditGroupFromHeadingCommand}"
CommandParameter="{Binding GroupSheet}">
-
+
@@ -922,7 +993,7 @@
Command="{Binding MoveGroupFromHeadingCommand}"
CommandParameter="{Binding GroupSheet}">
-
@@ -935,7 +1006,7 @@
Command="{Binding DeleteGroupFromHeadingCommand}"
CommandParameter="{Binding GroupSheet}">
-
@@ -1066,10 +1137,10 @@
-
-
@@ -1265,9 +1336,9 @@
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
-
-
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs
index 6392239..5fc6aad 100644
--- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs
@@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
@@ -62,8 +63,30 @@ internal sealed partial class HostsScreen : UserControl
InputElement.SetIsHoldingEnabled(rows, true);
rows.Holding += OnRowHeld;
+
+ var sheet = this.FindControl("PasswordSheet")!;
+ var box = this.FindControl("PasswordBox")!;
+
+ sheet.PropertyChanged += (_, e) =>
+ {
+ if (e.Property == IsVisibleProperty && sheet.IsVisible)
+ {
+ FocusThePasswordBox(box);
+ }
+ };
}
+ ///
+ /// Puts the focus, and so the keyboard, in the connect sheet's box as the sheet opens.
+ ///
+ ///
+ /// The sheet exists for that one box, and it used to open with nothing focused — a second tap before
+ /// typing could start (audit M12). Posted at Loaded priority because the box is not yet laid out
+ /// on the turn the sheet's visibility changes, and focusing a control with no bounds raises no keyboard.
+ ///
+ private static void FocusThePasswordBox(TextBox box) =>
+ Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
+
///
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
///
diff --git a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml
index e6f4e68..d3bfb2e 100644
--- a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views"
+ xmlns:ssh="using:DodoSSH.Client.Ssh"
x:Class="DodoSSH.Client.Android.Views.KeychainScreen"
x:DataType="vm:VaultViewModel"
Background="{StaticResource Canvas}">
@@ -32,38 +33,42 @@
This screen moved behind SETTINGS, so it draws its own header — the one every hub screen has. It is not
a decoration: the shell's header stands down behind the hub, and without a row of its own there would be
no back arrow and nothing on screen saying what this list is.
+
+ ── 2026-10-04 redesign ───────────────────────────────────────────────────────────────────────────────
+ And it moved back out, under VAULTS: the keychain is the open vault's contents as much as its hosts are,
+ so it is the middle of the vault's HOSTS · KEYCHAIN · SNIPPETS switch, and the shell's header is over it
+ again. The header below is now drawn only where that switch is not.
-->
+
+
-
-
-
-
+
+
-
+
-
+
@@ -71,7 +76,7 @@
-
+
@@ -80,7 +85,7 @@
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Credentials}">
-
+
@@ -95,7 +100,7 @@
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Tags}">
-
+
@@ -104,7 +109,7 @@
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Buckets}">
-
+
@@ -134,35 +139,45 @@
style below, which had nothing to colour until now. DELETE is the last button that should be pressed on
a guess about which row it means.
-->
-
+
-
-
-
-
-
-
-
-
-
+ BorderThickness="1" CornerRadius="14" Padding="14,12">
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
+
@@ -183,9 +198,9 @@
IsVisible="{Binding PendingDeletion.HasUsage}"
Text="{Binding PendingDeletion.Usage}" />
-
-
@@ -224,9 +239,9 @@
IsVisible="{Binding HasMovingItemUsage}"
Text="{Binding MovingItemUsage}" />
-
-
@@ -239,12 +254,15 @@
-
+
-
-
+
+
-
-
+
+
-
@@ -287,18 +305,310 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml.cs
index d939fe5..9cae0bf 100644
--- a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml.cs
@@ -1,10 +1,70 @@
using Avalonia.Controls;
+using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
+using DodoSSH.Client.Shell.ViewModels;
+
namespace DodoSSH.Client.Android.Views;
/// Design 04 — everything in the vault that is not a host.
internal sealed partial class KeychainScreen : UserControl
{
public KeychainScreen() => AvaloniaXamlLoader.Load(this);
+
+ ///
+ /// Cancels whichever of the keychain's five editors is open.
+ ///
+ /// Whether one was, and so whether there was anything to cancel.
+ ///
+ /// One place for the question the editor page's ← and the system back gesture both ask. Only one editor is
+ /// ever open — each one's command refuses while another is — so the order only decides which test runs
+ /// first. Cancelling rather than hiding, as the host editor does, because cancelling is what empties the
+ /// boxes, and two of them hold a private key and a password.
+ ///
+ internal static bool TryCancelAnEditor(VaultViewModel vault)
+ {
+ if (vault.IsGeneratingKey)
+ {
+ vault.CancelGenerateKeyCommand.Execute(null);
+ }
+ else if (vault.IsEditingKey)
+ {
+ vault.CancelKeyEditCommand.Execute(null);
+ }
+ else if (vault.IsEditingCredential)
+ {
+ vault.CancelCredentialEditCommand.Execute(null);
+ }
+ else if (vault.IsEditingObjectStore)
+ {
+ vault.CancelObjectStoreEditCommand.Execute(null);
+ }
+ else if (vault.IsEditingTag)
+ {
+ vault.CancelTagEditCommand.Execute(null);
+ }
+ else
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private void OnCancelEditor(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is VaultViewModel vault)
+ {
+ TryCancelAnEditor(vault);
+ }
+ }
+
+ /// A kind was chosen on the add sheet: the sheet goes down as its editor opens.
+ private void OnAddChosen(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is VaultViewModel vault)
+ {
+ vault.IsKeychainAddSheetOpen = false;
+ }
+ }
}
diff --git a/src/DodoSSH.Client.Android/Views/LogsScreen.axaml b/src/DodoSSH.Client.Android/Views/LogsScreen.axaml
index 44d8fd6..74a2d56 100644
--- a/src/DodoSSH.Client.Android/Views/LogsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/LogsScreen.axaml
@@ -85,7 +85,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
+
+
diff --git a/src/DodoSSH.Client.Android/Views/NavButton.cs b/src/DodoSSH.Client.Android/Views/NavButton.cs
index 9dda918..85ca6c8 100644
--- a/src/DodoSSH.Client.Android/Views/NavButton.cs
+++ b/src/DodoSSH.Client.Android/Views/NavButton.cs
@@ -1,4 +1,7 @@
+using System.Globalization;
+
using Avalonia;
+using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
@@ -17,16 +20,31 @@ namespace DodoSSH.Client.Android.Views;
///
///
///
-/// Written in C# rather than as a XAML control because it is a button with a glyph, a label and a rule about
-/// the accent bar, and a templated control for that would be more machinery than the thing it draws.
+/// Written in C# rather than as a XAML control because it is a button with an icon, a label and a rule about
+/// the indicator, and a templated control for that would be more machinery than the thing it draws.
///
///
-/// The accent bar is on top, not underneath. The v2 design distinguishes the current destination with
-/// colour and weight alone and draws no bar at all; the bar is kept, and kept at the top, for a reason the
-/// design does not have to think about. The bottom of a phone screen is under the system's own gesture bar,
-/// so an indicator drawn there is one the user cannot see — and colour alone at 9pt on a dark background is
-/// the single signal a user with a colour deficiency would be left with. Weight, colour and the bar are
-/// three, which is the right number for the control that says where you are.
+/// ◆ The indicator is a pill behind the icon, where it was a bar along the top edge. The bar was put at
+/// the top because the bottom of the screen is under the system's gesture bar, and an indicator drawn there is
+/// one nobody sees; the pill answers the same worry from the other side, by sitting in the middle of the
+/// control rather than at either edge. It is the shape Android's own navigation bar has drawn since Material 3
+/// and the one the 2026-10-04 redesign asks for. The rule the bar existed to keep is kept: the current entry is
+/// still told apart three ways — the filled pill, the accent colour and the heavier label — because colour
+/// alone at this size is the one signal a user with a colour deficiency would be left with.
+///
+///
+/// ◆ An icon from the embedded Material font rather than a Unicode glyph. The glyphs (▤ ⌗ ⚙) came from
+/// whichever symbol font the phone had, at whatever weight it drew them, and ⚙ meant both this tab and the
+/// Preferences row under it (audit M7). The icon font is the one the desktop's rail uses.
+///
+///
+/// A count, when there is one. Connections carries the number of open sessions in a green badge, which
+/// was otherwise only said by the strip of shells above the bar. Zero draws nothing: a "0" is a number about
+/// nothing, and the badge being absent already says it.
+///
+///
+/// It has a name. Its content is a panel, and Avalonia's automation peer reported that panel's type name
+/// to TalkBack — "Avalonia.Controls.Panel", three times across the bar (audit H4). The label is the name.
///
///
/// Every colour here is resolved from Theme/Palette.axaml rather than written out. A nav bar that
@@ -39,25 +57,28 @@ internal sealed class NavButton : Button
public static readonly StyledProperty LabelProperty =
AvaloniaProperty.Register(nameof(Label), string.Empty);
- /// The glyph drawn above the label.
- ///
- /// A character rather than an icon asset. The four the design uses — ▤ ⌗ ⚿ ≣ — are all in the fonts
- /// Android already has, so shipping four vectors to draw them would buy nothing but four more files to
- /// keep in step with the palette.
- ///
+ /// The icon drawn above the label: one codepoint of the embedded Material Icons font.
public static readonly StyledProperty GlyphProperty =
AvaloniaProperty.Register(nameof(Glyph), string.Empty);
public static readonly StyledProperty IsCurrentProperty =
AvaloniaProperty.Register(nameof(IsCurrent));
- private readonly Border indicator;
+ /// A count drawn over the icon, or zero for none.
+ public static readonly StyledProperty CountProperty =
+ AvaloniaProperty.Register(nameof(Count));
+
+ private readonly Border pill;
private readonly TextBlock glyph;
private readonly TextBlock text;
+ private readonly Border badge;
+ private readonly TextBlock badgeText;
// Resolved from the palette once the control is in the tree; see Repaint.
private IBrush? currentBrush;
+ private IBrush? currentIconBrush;
private IBrush? idleBrush;
+ private IBrush? pillBrush;
public NavButton()
{
@@ -70,35 +91,71 @@ internal sealed class NavButton : Button
HorizontalContentAlignment = HorizontalAlignment.Stretch;
VerticalContentAlignment = VerticalAlignment.Stretch;
- indicator = new Border
- {
- Height = 2,
- VerticalAlignment = VerticalAlignment.Top,
- Background = Brushes.Transparent,
- };
-
glyph = new TextBlock
{
- FontSize = 15,
+ FontSize = 21,
HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ AutomationProperties.SetAccessibilityView(glyph, AccessibilityView.Raw);
+
+ (badge, badgeText) = MakeBadge();
+
+ pill = new Border
+ {
+ Width = 64,
+ Height = 32,
+ CornerRadius = new CornerRadius(16),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Background = Brushes.Transparent,
+ Child = glyph,
};
text = new TextBlock
{
- FontSize = 9,
+ FontSize = 12,
FontWeight = FontWeight.Medium,
HorizontalAlignment = HorizontalAlignment.Center,
};
- var stack = new StackPanel
+ Content = new StackPanel
{
- Spacing = 3,
+ Spacing = 4,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children = { new Panel { Children = { pill, badge } }, text },
+ };
+ }
+
+ /// The count's badge, over the pill's top-right corner.
+ ///
+ /// Over the corner rather than beside the label, so it reads as belonging to the icon and costs the
+ /// label no width.
+ ///
+ private static (Border Badge, TextBlock Text) MakeBadge()
+ {
+ var count = new TextBlock
+ {
+ FontSize = 10.5,
+ FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
- Children = { glyph, text },
};
- Content = new Panel { Children = { indicator, stack } };
+ var holder = new Border
+ {
+ MinWidth = 18,
+ Height = 18,
+ CornerRadius = new CornerRadius(9),
+ Padding = new Thickness(5, 0),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, -4, 8, 0),
+ IsVisible = false,
+ Child = count,
+ };
+
+ return (holder, count);
}
public string Label
@@ -121,6 +178,13 @@ internal sealed class NavButton : Button
set => SetValue(IsCurrentProperty, value);
}
+ ///
+ public int Count
+ {
+ get => GetValue(CountProperty);
+ set => SetValue(CountProperty, value);
+ }
+
///
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
@@ -129,6 +193,7 @@ internal sealed class NavButton : Button
if (change.Property == LabelProperty)
{
text.Text = Label;
+ AutomationProperties.SetName(this, Label);
}
else if (change.Property == GlyphProperty)
{
@@ -138,9 +203,10 @@ internal sealed class NavButton : Button
{
Repaint();
}
- else if (change.Property == FontFamilyProperty)
+ else if (change.Property == CountProperty)
{
- text.FontFamily = FontFamily;
+ badge.IsVisible = Count > 0;
+ badgeText.Text = Count.ToString(CultureInfo.InvariantCulture);
}
}
@@ -151,13 +217,17 @@ internal sealed class NavButton : Button
// Resolved here rather than in the constructor: application resources are not reachable while the
// control is still being constructed, and a hard-coded stack would drift from Palette.axaml.
- if (Application.Current?.FindResource("MonoFont") is FontFamily mono)
+ if (Application.Current?.FindResource("IconFont") is FontFamily icons)
{
- text.FontFamily = mono;
+ glyph.FontFamily = icons;
}
- currentBrush = Application.Current?.FindResource("AccentText") as IBrush;
+ currentBrush = Application.Current?.FindResource("Text") as IBrush;
+ currentIconBrush = Application.Current?.FindResource("AccentText") as IBrush;
idleBrush = Application.Current?.FindResource("TextFaint") as IBrush;
+ pillBrush = Application.Current?.FindResource("AccentSoft") as IBrush;
+ badge.Background = Application.Current?.FindResource("Live") as IBrush;
+ badgeText.Foreground = Application.Current?.FindResource("Canvas") as IBrush;
// IsCurrent is normally bound and so has usually already been set by the time the template applies,
// which is why this runs again here rather than only on the property change.
@@ -166,16 +236,21 @@ internal sealed class NavButton : Button
private void Repaint()
{
- // Both the label and the bar, not just the bar — and the weight as well. See the remark on the type.
+ // The pill, the colour and the weight together. See the remark on the type.
var paint = IsCurrent ? currentBrush : idleBrush;
+ var iconPaint = IsCurrent ? currentIconBrush : idleBrush;
if (paint is not null)
{
- glyph.Foreground = paint;
text.Foreground = paint;
}
- text.FontWeight = IsCurrent ? FontWeight.SemiBold : FontWeight.Medium;
- indicator.Background = IsCurrent && currentBrush is not null ? currentBrush : Brushes.Transparent;
+ if (iconPaint is not null)
+ {
+ glyph.Foreground = iconPaint;
+ }
+
+ text.FontWeight = IsCurrent ? FontWeight.Bold : FontWeight.Medium;
+ pill.Background = IsCurrent && pillBrush is not null ? pillBrush : Brushes.Transparent;
}
}
diff --git a/src/DodoSSH.Client.Android/Views/PhoneRail.axaml b/src/DodoSSH.Client.Android/Views/PhoneRail.axaml
index fc1d06a..716fae9 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneRail.axaml
+++ b/src/DodoSSH.Client.Android/Views/PhoneRail.axaml
@@ -73,17 +73,28 @@
+
+
@@ -120,11 +131,11 @@
-
-
+
@@ -134,11 +145,11 @@
HorizontalScrollBarVisibility="Disabled">
-
-
+
@@ -155,67 +166,67 @@
surface and is that count, drawn as the sessions themselves — a number beside this word would be
the same fact said twice, three inches apart.
-->
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
index 76745cf..d9939d1 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
@@ -24,10 +24,11 @@
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
- Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
- bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
- computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
- is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
+ The rows around the screen stand down while a shell is showing: the header and the bottom bar. Both go
+ through flags the control computes, and the bar's is the one that differs — it stays up on Connections
+ with nothing running, which is the terminal surface drawing a page rather than a shell. See
+ PhoneShell.RefreshChrome. (A third row, the strip of open shells, was removed by the 2026-10-04 redesign;
+ the count on Connections took its place — see the remark where it was.)
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
@@ -78,7 +79,7 @@
-
+
+
+ BorderThickness="0,0,0,1" Padding="10,0,6,0" Height="60">
-
-
-
+ ◆ The vault's name is a button now, and it opens the vaults.
-
+ The 2026-10-04 redesign draws the name with a chevron, as the switch it is on every phone app
+ that holds more than one of something. What it leads to is VAULTS, which is where the vaults
+ this phone shows are chosen — so the chevron is a promise the screen behind it keeps, rather
+ than a dropdown this head has not got.
+
+ The mark stays filled, and takes the gradient Button.primary has: it is the one thing on this
+ header that is not a fact about the vault, and the launcher icon draws it the same way.
+ -->
+
+
+
+
+
+
+
+
+
-
-
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -277,67 +330,14 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ BorderThickness="0,1,0,0" Height="72">
-
+
@@ -386,9 +392,11 @@
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
bar can now sit under and not point at is the entry looking broken instead.
+
+ ◆ The count is the open tabs, and it is what the shells strip used to say. See the remark above.
-->
-
-
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
index 7c85e27..2498b61 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
@@ -43,6 +43,13 @@ internal sealed partial class PhoneShell : UserControl
/// The software keyboard, while this control is attached. Null on a platform without one.
private IInputPane? keyboard;
+ ///
+ /// Whether the software keyboard is up, read from the pane's state rather than from the inset: before
+ /// Android 15 the window itself shrinks for the keyboard and the inset reads zero. See
+ /// .
+ ///
+ private bool keyboardOpen;
+
///
/// Whether the lock screen currently showing is the one the application launched into.
///
@@ -89,6 +96,7 @@ internal sealed partial class PhoneShell : UserControl
if (shell is not null)
{
shell.PropertyChanged -= OnShellChanged;
+ shell.Transfers.PropertyChanged -= OnVaultChanged;
}
shell = DataContext as MainWindowViewModel;
@@ -96,6 +104,11 @@ internal sealed partial class PhoneShell : UserControl
if (shell is not null)
{
shell.PropertyChanged += OnShellChanged;
+
+ // The files screen's actions sheet is the one sheet outside the hosts screen that the chrome
+ // scrim covers for, so its openness is heard here too. Transfers is built once with the shell
+ // and never replaced, unlike the vault.
+ shell.Transfers.PropertyChanged += OnVaultChanged;
ApplyScreenshotPolicy(shell.State);
thisLockIsTheLaunch = true;
@@ -170,9 +183,13 @@ internal sealed partial class PhoneShell : UserControl
public static readonly StyledProperty ShowsHostSelectionBarProperty =
AvaloniaProperty.Register(nameof(ShowsHostSelectionBar));
- /// Whether the strip of open shells above the bottom bar is drawn.
- public static readonly StyledProperty ShowsShellStripProperty =
- AvaloniaProperty.Register(nameof(ShowsShellStrip));
+ /// Whether the HOSTS · KEYCHAIN · SNIPPETS switch is drawn under the vault's header.
+ ///
+ /// Narrow surfaces only: a wide one has the rail, which lists the three already, and a switch repeating
+ /// three of the rail's entries an inch to the right of them would be the same choice offered twice.
+ ///
+ public static readonly StyledProperty ShowsVaultTabsProperty =
+ AvaloniaProperty.Register(nameof(ShowsVaultTabs));
///
public bool IsWide
@@ -209,11 +226,22 @@ internal sealed partial class PhoneShell : UserControl
private set => SetValue(ShowsHostSelectionBarProperty, value);
}
- ///
- public bool ShowsShellStrip
+ /// Whether the header and the bottom bar are dimmed under a sheet the hosts screen raised.
+ public static readonly StyledProperty ShowsSheetScrimProperty =
+ AvaloniaProperty.Register(nameof(ShowsSheetScrim));
+
+ ///
+ public bool ShowsSheetScrim
{
- get => GetValue(ShowsShellStripProperty);
- private set => SetValue(ShowsShellStripProperty, value);
+ get => GetValue(ShowsSheetScrimProperty);
+ private set => SetValue(ShowsSheetScrimProperty, value);
+ }
+
+ ///
+ public bool ShowsVaultTabs
+ {
+ get => GetValue(ShowsVaultTabsProperty);
+ private set => SetValue(ShowsVaultTabsProperty, value);
}
///
@@ -243,6 +271,13 @@ internal sealed partial class PhoneShell : UserControl
/// Losing them on the keychain would be losing the only LOCK button on the surface.
///
///
+ /// ◆ Narrow, it is the VAULTS tab's header now, on all three of its screens. The keychain and
+ /// snippets moved from behind SETTINGS to the vault's own switch (see
+ /// ), so the header and the switch under it are drawn
+ /// over hosts, keychain and snippets alike, and those two screens' own headers stand down on a narrow
+ /// surface to make room.
+ ///
+ ///
/// ◆ The header is now a swap rather than a switch, and the editor takes the whole screen. Two
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
/// . While hosts are ticked the header stands down and
@@ -272,9 +307,11 @@ internal sealed partial class PhoneShell : UserControl
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
- // not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
+ // not the vault's switch, and not the way off the screen. Its own header carries the back arrow, which
// is the one control it needs and the one the system gesture already maps to.
- var editing = vault?.IsEditing == true;
+ // The keychain's editor page counts too: it is the host editor's shape and wants the same screen.
+ var editing = vault?.IsEditing == true
+ || (vault?.IsEditingAVaultItem == true && shell?.IsKeychainShowing == true);
// Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
// navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
@@ -293,10 +330,17 @@ internal sealed partial class PhoneShell : UserControl
IsWide = wide;
ShowsRail = wide && (pages || connectPage) && !editing;
- ShowsBottomBar = !wide && (pages || connectPage) && !editing;
- ShowsShellStrip = pages && !editing;
+ // ◆ And not while the software keyboard is up. The body shrinks to stay clear of the keyboard, so the
+ // bar rode up on top of it and took 72 pixels from whatever was being typed into — the connect sheet,
+ // most often (audit M12). Every Android app with a bottom bar puts it away for the keyboard.
+ ShowsBottomBar = !wide && (pages || connectPage) && !editing && !keyboardOpen;
ShowsHostSelectionBar = pages && choosing && !editing;
- ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
+ ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsVaultSurface == true);
+ ShowsVaultTabs = ShowsVaultHeader && !wide;
+ ShowsSheetScrim = pages
+ && ((vault is not null && HasASheetUp(vault))
+ || (shell is { Transfers.IsEntryMenuOpen: true }
+ && (shell.IsTransfersShowing || shell.IsBucketsShowing)));
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -452,10 +496,18 @@ internal sealed partial class PhoneShell : UserControl
DispatcherPriority.Background);
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
- => ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
+ {
+ keyboardOpen = e.NewState is InputPaneState.Open;
+ ApplyKeyboardInset(keyboardOpen ? e.EndRect.Height : 0);
+ RefreshChrome();
+ }
private void ApplyKeyboardInset(IInputPane pane)
- => ApplyKeyboardInset(pane.State is InputPaneState.Open ? pane.OccludedRect.Height : 0);
+ {
+ keyboardOpen = pane.State is InputPaneState.Open;
+ ApplyKeyboardInset(keyboardOpen ? pane.OccludedRect.Height : 0);
+ RefreshChrome();
+ }
///
/// Holds the phone's whole interface clear of the software keyboard.
@@ -568,7 +620,7 @@ internal sealed partial class PhoneShell : UserControl
/// application opens on, which is what every other Android app does.
///
///
- /// v3 adds one guard above the switch rather than another case inside it. The switch's first case
+ /// v3 adds one guard above the switch rather than another case inside it. The switch's second case
/// is the membership test of minus More itself,
/// and has to stay in step with it — a screen added to the hub and not to that case would trap the user
/// on it. An editor is not a screen and has no entry there. It is also strictly nearer: the add sheet
@@ -608,6 +660,14 @@ internal sealed partial class PhoneShell : UserControl
return;
}
+ // The files screen's actions sheet, raised from its selection bar.
+ if (current.Transfers.IsEntryMenuOpen)
+ {
+ current.Transfers.IsEntryMenuOpen = false;
+ e.Handled = true;
+ return;
+ }
+
if (TryCloseAnOpenEditor(current))
{
e.Handled = true;
@@ -621,28 +681,43 @@ internal sealed partial class PhoneShell : UserControl
return;
}
+ if (TryGoUp(current))
+ {
+ e.Handled = true;
+ }
+ }
+
+ /// Moves one level up the screens, for the back gesture.
+ /// Whether it moved, and so whether back has been spent.
+ /// See , whose last step this is.
+ private bool TryGoUp(MainWindowViewModel current)
+ {
switch (current.Screen)
{
- case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
- or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults
- or ShellScreen.Keychain:
+ // ◆ The vault's other two screens go back to its hosts rather than to the hub, since the redesign
+ // moved them under VAULTS: the switch at the top of the vault is where they were reached from, and
+ // HOSTS is where that switch starts.
+ case ShellScreen.Keychain or ShellScreen.Snippets:
+ current.ShowScreenCommand.Execute(ShellScreen.Hosts);
+ return true;
+
+ case ShellScreen.Logs or ShellScreen.Transfers
+ or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults:
// ◆ The hub, or Hosts where there is no hub. A wide surface draws the rail instead and
// never draws SETTINGS, so backing out to it would land on a screen with no way off it but
// a second back — and the arrows in these screens' own headers are hidden there for the
// same reason. Home is where back goes when the thing you came from is not on the surface.
current.ShowScreenCommand.Execute(IsWide ? ShellScreen.Hosts : ShellScreen.More);
- e.Handled = true;
- break;
+ return true;
case ShellScreen.More:
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
- e.Handled = true;
- break;
+ return true;
default:
// Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
- break;
+ return false;
}
}
@@ -690,6 +765,25 @@ internal sealed partial class PhoneShell : UserControl
return false;
}
+ /// Whether one of the hosts screen's four sheets is up.
+ /// The same four lowers, in the same terms.
+ private static bool HasASheetUp(VaultViewModel vault) =>
+ vault.IsHostActionSheetOpen || vault.IsAskingForConnectPassword || vault.IsAddSheetOpen
+ || vault.GroupSheet is not null || vault.IsKeychainAddSheetOpen;
+
+ /// A tap on the scrim over the header or the bar: lowers the sheet, as its own scrim does.
+ private void OnChromeScrimTapped(object? sender, RoutedEventArgs e)
+ {
+ if (shell is { Transfers.IsEntryMenuOpen: true })
+ {
+ shell.Transfers.IsEntryMenuOpen = false;
+ }
+ else if (shell?.Vault is { } open)
+ {
+ TryLowerASheet(open);
+ }
+ }
+
///
/// Lowers whatever the hosts screen has raised over its list, topmost first.
///
@@ -755,6 +849,12 @@ internal sealed partial class PhoneShell : UserControl
return true;
}
+ if (vault.IsKeychainAddSheetOpen)
+ {
+ vault.CloseKeychainAddSheetCommand.Execute(null);
+ return true;
+ }
+
if (vault.GroupSheet is not null)
{
vault.CloseGroupSheetCommand.Execute(null);
@@ -792,6 +892,11 @@ internal sealed partial class PhoneShell : UserControl
return true;
}
+ if (KeychainScreen.TryCancelAnEditor(vault))
+ {
+ return true;
+ }
+
if (vault.IsEditingGroup)
{
vault.CancelGroupEditCommand.Execute(null);
diff --git a/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml
index a9ba586..c96c112 100644
--- a/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml
@@ -42,7 +42,7 @@
-
+
-
-
diff --git a/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml
index e4ae987..0f3758a 100644
--- a/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml
@@ -27,18 +27,14 @@
-
-
-
-
-
+
+
+
@@ -103,9 +99,9 @@
Text="Left off, inserting types the command at the prompt and stops, so it can be read and edited before it runs. Nothing here can tell whether the terminal is even at a prompt, which is why that is the default." />
-
-
@@ -119,27 +115,41 @@
-
+
+
+
-
-
+
+
+
+
-
-
-
-
-
-
+
@@ -156,22 +166,39 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
+
@@ -301,9 +303,12 @@
-
+ Command="{Binding OpenConnectSheetCommand}">
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -779,7 +806,7 @@
-
+
@@ -787,7 +814,7 @@
-
+
@@ -1006,7 +1033,7 @@
-
diff --git a/src/DodoSSH.Client.Android/Views/VaultsScreen.axaml b/src/DodoSSH.Client.Android/Views/VaultsScreen.axaml
index a0aa77b..702f274 100644
--- a/src/DodoSSH.Client.Android/Views/VaultsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/VaultsScreen.axaml
@@ -113,9 +113,9 @@
Text="Its key is made on this phone and nobody else has it. Add people to it once it exists, then share the key with them." />
-
-
diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml
index 95c116a..8caa75a 100644
--- a/src/DodoSSH.Client.App/App.axaml
+++ b/src/DodoSSH.Client.App/App.axaml
@@ -214,7 +214,17 @@
ContentPresenter for Background for the Fluent-override reason explained above, and ContentPresenter
carries its own BoxShadow property in Avalonia 11, so the glow and the fill live on the same element
and move together.
+
+ ◆ ClipToBounds on the Button, because Fluent's own Button theme sets it to True — and a glow is
+ drawn outside the control by definition. With the clip on, the 22-pixel blur was cut at the button's
+ square bounds and showed as a faint rectangle behind the rounded fill: the "slightly lighter square"
+ the 2026-10-02 Android audit measured (M1), which this head shares because the pattern is the same.
+ Measured in the layout harness rather than assumed: Button.ClipToBounds reads True under the theme,
+ and the same button with it cleared renders the full glow.
-->
+