diff --git a/README.md b/README.md
index 7bb53b7..2bf19a7 100644
--- a/README.md
+++ b/README.md
@@ -274,6 +274,12 @@ that design now; the desktop's own v2 is a 190-pixel labelled sidebar in place o
search box in the titlebar, and session tabs as pills. Its light theme is not built — see
[`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both.
+**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom
+bar are collapsed while a shell is up, and a single 52-pixel row replaces them: back on the left, the
+sessions as pills, and a `+` on the right offering the three connections this application can make — a
+shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers
+that menu first if it is open.
+
Widening the sidebar moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
screen exactly the width it was designed against.
diff --git a/docs/android-port.md b/docs/android-port.md
index e07119a..2b3933a 100644
--- a/docs/android-port.md
+++ b/docs/android-port.md
@@ -508,6 +508,14 @@ go at 360dp:
drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt
believing they typed an `l`.
+ **The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
+ and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen
+ draws a 52-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
+ connections there are. That sheet is the head's first control that could be drawn over the renderer, so
+ it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
+ WebView actually composites above Avalonia content is still the unverified question recorded below, and
+ collapsing is correct under either answer.
+
All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7.
What is left, in the order it matters:
diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md
index 336c364..1b6b98b 100644
--- a/docs/design-import-gaps.md
+++ b/docs/design-import-gaps.md
@@ -24,7 +24,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | v2 element | What ships instead |
> | --- | --- |
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. |
-> | `23 ms · fwd 5432` on the terminal's connection line | The account and endpoint actually dialled. There is no RTT measurement in SSH.NET and nothing forwards anything. |
+> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 52-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index 0acef2e..9027715 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -889,3 +889,66 @@ Focus a passphrase box, then turn the phone sideways.
**Pass:** the box is still visible and still focused, and the shell is intact — the activity handles the
rotation rather than being recreated, and live shells survive it.
+
+---
+
+## Phase 11 — The phone's terminal surface
+
+Every check here needs a real device for the reason Phase 10's do, plus one of its own: the interesting
+question on this screen is whether a native `WebView` composites above what Avalonia draws over it, and no
+headless surface has a native view to answer with. `docs/android-port.md` still records that as unverified;
+11.2 is the check that settles it.
+
+### 11.1 A shell gets the screen · **the important one for chrome**
+
+Open a shell from HOSTS.
+
+**Pass:** the vault header, the session strip and the four-entry bottom bar are all gone. What is left is
+one bar — a back arrow, the session pills, a `+` — and then the terminal down to the accessory keys. Press
+back: all three come back, the tab is still in the strip and its dot is still green.
+
+**Failure means:** one of the three rows is not bound on `IsShowingPages`, or the terminal is being reached
+by a route that leaves `Surface` on `Page`.
+
+### 11.2 The connect menu is not drawn over the renderer · **the important one**
+
+With a shell showing output, press `+`.
+
+**Pass:** the terminal's rectangle goes to the canvas colour and the sheet sits over it whole — scrim, three
+rows and CANCEL, every one of them tappable, none of them sliced down the left edge. Tap the scrim: the
+terminal comes back with its scrollback intact and the shell still running.
+
+**Failure means:** `IsTerminalShowing` is not being cleared by `IsConnectSheetOpen` — or, if the sheet is
+sliced *despite* the rectangle going blank, something else in that Panel is still showing. A sheet that
+draws over live terminal output is the Android answer to the compositing question, and it means every
+future sheet on this surface has to collapse the renderer too.
+
+### 11.3 Back lowers the menu before it leaves the terminal
+
+With the connect menu open, use the system back gesture.
+
+**Pass:** the menu closes and the terminal is still showing. A second back leaves the terminal for the
+screen it was opened over.
+
+**Failure means:** the guard in `PhoneShell.axaml.cs` is below the surface check rather than above it, and
+one gesture is spending two levels.
+
+### 11.4 The two end buttons cannot be pushed off the bar
+
+Open six or more shells.
+
+**Pass:** the pills scroll under a fixed back arrow and a fixed `+`; neither ever leaves the screen, and
+scrolling the pills to either end does not move them.
+
+**Failure means:** a control was put inside the `ScrollViewer` rather than beside it.
+
+### 11.5 The text-size buttons are always reachable
+
+With a shell open, scroll the accessory key row to the far left and the far right.
+
+**Pass:** `A−` and `A+` stay pinned at the right-hand end throughout, separated from the keys by the
+hairline, and both are at least 38 tall. At the smallest and largest sizes the one that can do nothing is
+visibly disabled rather than silently inert.
+
+**Failure means:** they have been folded into the scrolling row — which is the arrangement the connection
+line existed to avoid, and the reason it could be removed at all.
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
index b774a53..a13c271 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
@@ -21,6 +21,16 @@
The order is the design's rather than the rail's. Terminal sits second, beside Hosts, because those two
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. All three are bound on IsShowingPages, which is the same question asked once — the
+ surface is either a page or a terminal, and these are the chrome a page has.
+
+ 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.
+ What takes their place is one 52-pixel bar drawn by the surface itself, carrying back on the left and
+ the sessions and a + across from it. See TerminalScreen.axaml.
-->
-
-
+
+
+
-
-
-
-
+
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
@@ -180,41 +199,57 @@
v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a
rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row
saying what the row already looks like.
+
+ On every screen except the one it names. The terminal draws these same sessions in its own bar, and
+ two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
+ stop. Wrapped rather than given a second condition, because the strip's own visibility is about
+ whether there are any tabs and this one is about which surface is up.
-->
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -225,8 +260,13 @@
-
-
+
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index 70a5b6f..6f67979 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -799,8 +799,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
- /// locked vault (the unlock card), the page area (every screen uses the full width), and the
- /// quick-connect palette.
+ /// locked vault (the unlock card), the page area (every screen uses the full width), the quick-connect
+ /// palette, and the phone's connect sheet.
+ ///
+ ///
+ /// The sheet is here rather than in , and the palette is not. The
+ /// palette replaces the whole surface, so collapsing everything the terminal half draws is right. The
+ /// sheet is raised from the terminal's own top bar and that bar has to stay on screen behind it —
+ /// dropping the surface would take the bar, the tabs and the phone's whole chrome with it and leave the
+ /// sheet floating over the page underneath. So only the renderer's rectangle is given up.
///
///
/// The terminal and the pages are exclusive, and that is the whole of the rule. They share one
@@ -829,7 +836,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// safe — that detaches it and destroys the whole WebView2 process tree.
///
///
- internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true };
+ internal bool IsTerminalShowing =>
+ IsTerminalSurface && !IsConnectSheetOpen && SelectedTab is { HasSession: true };
///
/// Whether the terminal half of the window is the half being shown, pane or no pane.
@@ -893,6 +901,52 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void ShowTerminal() => Surface = ShellSurface.Terminal;
+ ///
+ /// Whether the phone's connect menu is open over the terminal.
+ ///
+ ///
+ ///
+ /// Drawn by the Android head alone, and shell state rather than something that view could hold on its
+ /// own for the reason is: it has to collapse the renderer while it is up. See
+ /// .
+ ///
+ ///
+ /// It exists because the phone gives a terminal the whole screen. The bottom bar and the vault header
+ /// are gone while a shell is showing, so the three things that bar was the way to — a host, a host's
+ /// files, a bucket — need a way back that is not "leave the terminal first and remember what you were
+ /// doing". The menu is that, and every entry on it is one of the two navigation commands above.
+ ///
+ ///
+ [ObservableProperty]
+ private bool isConnectSheetOpen;
+
+ /// Raises the connect menu over the terminal.
+ ///
+ /// Gated on the terminal surface rather than merely trusting its only button to be off screen otherwise.
+ /// The flag collapses the renderer, so one set while a page was showing would be a sheet nobody can see
+ /// holding a terminal hidden that nothing would put back.
+ ///
+ [RelayCommand]
+ private void OpenConnectSheet()
+ {
+ if (!IsTerminalSurface)
+ {
+ return;
+ }
+
+ IsConnectSheetOpen = true;
+ }
+
+ /// Lowers the connect menu, leaving the terminal where it was.
+ ///
+ /// The scrim, the CANCEL row and the system back gesture all come here. Choosing an entry does not, and
+ /// does not need to: every entry navigates, and leaving the terminal surface lowers the sheet on its own
+ /// — see , which is what makes "the sheet is only ever up over a terminal"
+ /// true of routes nobody wrote it for.
+ ///
+ [RelayCommand]
+ private void CloseConnectSheet() => IsConnectSheetOpen = false;
+
// ---- Open terminals ----
///
@@ -2537,7 +2591,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
///
- partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
+ ///
+ /// The one place the connect sheet is lowered by something other than a tap. Every way out of a
+ /// terminal ends here — a rail or bottom-bar destination, the files screen, the palette connecting to a
+ /// host, closing the last tab, a lock — and each of them would otherwise leave the flag set on a shell
+ /// showing a page. That is not merely untidy: the flag collapses the renderer, so the next return to the
+ /// terminal would draw the sheet again over a rectangle held blank by it.
+ ///
+ partial void OnSurfaceChanged(ShellSurface value)
+ {
+ if (value is not ShellSurface.Terminal)
+ {
+ IsConnectSheetOpen = false;
+ }
+
+ RaiseSurfaceState();
+ }
///
/// Both changes raise the same set, and they have to: and its four siblings
@@ -2600,6 +2669,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();
+ ///
+ partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
+
///
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
/// .
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 750805c..f2416a1 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -687,6 +687,82 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Screen.ShouldBe(ShellScreen.Vault);
}
+ ///
+ /// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
+ /// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
+ /// was raised from is part of it — see MainWindowViewModel.IsTerminalShowing.
+ ///
+ [Fact]
+ public async Task TheConnectSheet_HidesTheRendererAndLeavesTheSurfaceUnderIt()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+ await vault.ConnectCommand.ExecuteAsync(null);
+
+ shell.OpenConnectSheetCommand.Execute(null);
+
+ shell.IsConnectSheetOpen.ShouldBeTrue();
+ shell.IsTerminalShowing.ShouldBeFalse("the sheet draws over the renderer's rectangle");
+ shell.IsTerminalSurface.ShouldBeTrue("the bar the sheet was raised from is on that surface");
+
+ shell.CloseConnectSheetCommand.Execute(null);
+
+ shell.IsTerminalShowing.ShouldBeTrue();
+ }
+
+ ///
+ /// The flag holds the renderer blank, so one set while a page was showing would be a sheet nobody can
+ /// see keeping a terminal hidden that nothing would put back.
+ ///
+ [Fact]
+ public async Task TheConnectSheet_RefusesToOpenOverAPage()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+ await vault.ConnectCommand.ExecuteAsync(null);
+
+ shell.ShowScreenCommand.Execute(ShellScreen.Vault);
+
+ shell.OpenConnectSheetCommand.Execute(null);
+
+ shell.IsConnectSheetOpen.ShouldBeFalse();
+ }
+
+ ///
+ /// Every entry on the menu navigates, and none of them closes the sheet itself: leaving the terminal
+ /// surface is what lowers it. That is the guarantee worth a test — it is what makes routes nobody wrote
+ /// the sheet for, like closing the last tab or locking, safe.
+ ///
+ [Fact]
+ public async Task LeavingTheTerminal_LowersTheConnectSheetHoweverItIsLeft()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+ await vault.ConnectCommand.ExecuteAsync(null);
+
+ // The menu's own second entry: one screen, over one view model, with the kind of remote chosen by
+ // the thing that navigates.
+ shell.OpenConnectSheetCommand.Execute(null);
+ shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
+
+ shell.IsConnectSheetOpen.ShouldBeFalse();
+ shell.IsBucketsShowing.ShouldBeTrue();
+
+ // And a route the sheet was never wired to: back to the terminal, open it, then end the only shell
+ // there is.
+ shell.ShowTerminalCommand.Execute(null);
+ shell.OpenConnectSheetCommand.Execute(null);
+ shell.IsConnectSheetOpen.ShouldBeTrue();
+
+ await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
+
+ shell.IsConnectSheetOpen.ShouldBeFalse("closing the last tab returns the surface to a page");
+ shell.IsShowingPages.ShouldBeTrue();
+ }
+
///
/// A visible WebView with no pane in it reads as the application having broken, so this is the one
/// transition that moves the surface back on its own.