Give the phone the second design, and both heads the palette it arrives with

The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts,
Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the
last. The first design's four had nothing behind them, which is what made a hub worth building.

The palette moved from green-black to blue-black, and it moved in the shared project because that is
where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning
rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so
Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were
the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which
finally separates them: Live is that green and nothing merely interactive may use it. The accent is
also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is
a row of things that all look like the primary action.

A palette is not one file, which is the part worth knowing before the next one. Nine hex literals
lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state,
two scrims, the window background Android paints before Avalonia has a frame, and the launcher
vector. The two C# sites now resolve from the dictionary by name rather than restating it. The
renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and
terminal.js keep hand-copied values and say so at both sites.

ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over
one TransfersViewModel differing only in which picker they offer, and the kind is set by the button
that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers
force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket
already open. It refuses to change kind while a session is live, because there is one session behind
both destinations and switching under it would title a screen S3 while it listed an SFTP host.

What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here
forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub
naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd
5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a
fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet
run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are
written once at close and read when the screen opens, and the severity filter, which has nothing to
count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and
lifecycle. And the + on HOSTS, which would open a host editor this head has not got.

SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local
pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at
SpecialFolder.UserProfile, which on Android is the application's own private directory. A download
would have reported success and left the file where the person who asked for it cannot open it, which
is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is
not drawn either, since nothing here can put anything in it. Both return with the document picker.
The foreground service still counts zero transfers, and the reason moved rather than went away.

Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox
ItemTemplate swallows the pointer press before the list sees it, so the files listing selected
nothing and every command reading the selection did nothing — the row is a Border now and the
phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from
walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched
SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was
breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no
handler for the system back gesture, so back left the application from a log screen. And the tab's
close cross had shrunk to a 30x32 target flush against the select target, which is the one control
here that ends a shell with no confirmation and no undo.

Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which
is still there. Only at launch: a lock the user asked for is not answered with an immediate request
to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody
asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a
prompt that came back after being dismissed would be a modal you cannot get out of to type into it.

Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with
Accent, which was blue against green and is now two steps of one blue; an executable is Live now.
And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification
time — blank now, for the reason a directory's size is blank.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout
suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on
a device — nothing in this head ever has been; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
This commit is contained in:
2026-08-02 18:23:53 +02:00
co-authored by Claude Opus 5
parent c00e5dbc5c
commit 5593f337b6
33 changed files with 1959 additions and 257 deletions
+144 -63
View File
@@ -13,10 +13,14 @@
It switches on ShellState, which is the same six states the desktop has because both heads share one
state machine. What differs is only what each one draws.
The nav rail's eight destinations become four in a bottom bar. That is not a reflow: the plan says five
characters was a desktop constraint and the phone can use words, and it also says a phone cannot carry
eight. What is here is what the decided scope needs — the keychain and a terminal — and the destinations
behind the other four are reachable from those two rather than being hidden.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
The desktop's eight rail destinations become four in a bottom bar, and five more live one tap deeper
behind MORE: snippets, SFTP, S3, logs and preferences. That is the v2 design's own arrangement, and it
replaces the first design's four, which had nothing behind them at all. Five characters was a desktop
constraint and the phone uses words.
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.
-->
<Panel>
@@ -34,29 +38,47 @@
<!-- ============ unlocked ============ -->
<Grid IsVisible="{Binding IsUnlocked}" RowDefinitions="Auto,*,Auto,Auto">
<!-- The header: which vault, and whether it is synced. -->
<!--
The header: which vault, and whether it is synced.
Hidden behind MORE, and that is the design's arrangement rather than a saving. v2 gives every screen
one header carrying that screen's own name and its own actions — a back arrow, an add, a refresh —
so the five hub screens draw their own and this one stands down rather than stacking a second row of
chrome above theirs. It stays on the three destinations that are the product's top level, where the
vault's name and the sync light are the most useful thing a header could say.
-->
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="16,0" Height="52">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="20" Height="20" BorderBrush="{StaticResource Accent}"
BorderThickness="1" VerticalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource Accent}"
FontFamily="{StaticResource MonoFont}" FontSize="9" FontWeight="SemiBold"
BorderThickness="0,0,0,1" Padding="14,0" Height="56" IsVisible="{Binding !IsMoreSurface}">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!--
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
fact about the vault, and the design gives it the accent as a solid tile — which is also what
the launcher icon draws, so the two agree.
-->
<Border Grid.Column="0" Width="26" Height="26" CornerRadius="8"
Background="{StaticResource Accent}" VerticalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource AccentInk}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center" Spacing="1">
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="5">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="5" Height="5"
VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="8" Text="{Binding SyncLabel}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="heading" Margin="10,0,8,0" FontSize="16"
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
<!--
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws a
permanently green "Synced" here, which is the one claim on that mock-up this application will
not make.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" />
</StackPanel>
<Button Grid.Column="2" Classes="row" Width="44" MinHeight="44" Padding="0"
HorizontalContentAlignment="Center" Command="{Binding LockCommand}">
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
ToolTip.Tip="Lock the keychain">
<TextBlock Text="LOCK" Classes="label" FontSize="8.5" Foreground="{StaticResource TextDim}" />
</Button>
</Grid>
@@ -85,15 +107,60 @@
-->
<views:TerminalScreen IsVisible="{Binding IsTerminalSurface}" />
<views:PendingScreen IsVisible="{Binding IsPreferencesShowing}"
Heading="PREFERENCES"
Detail="Registering this phone's device key, and signing out, are on the unlock screen for now. Importing an ~/.ssh/config has no meaning here — scoped storage means there is no such file to read." />
<!--
============ under MORE ============
The hub itself takes the shell as its data context, because every row on it is a navigation
command; the five destinations behind it each take the view model they are about, so each one is
wrapped. SnippetsScreen and LogsScreen are nullable on the shell — they are rebuilt on every
unlock and nulled on lock — and it is the collapsed wrapper that keeps a template from binding
against nothing.
-->
<views:MoreScreen IsVisible="{Binding IsMoreShowing}" />
<Panel IsVisible="{Binding IsSnippetsShowing}">
<views:SnippetsScreen DataContext="{Binding SnippetsScreen}" />
</Panel>
<Panel IsVisible="{Binding IsLogsShowing}">
<views:LogsScreen DataContext="{Binding LogsScreen}" />
</Panel>
<!--
One screen for both file destinations. SFTP and S3 differ in which picker they offer and in
nothing else below it — the panes, the queue and the transfers are the same IRemoteFileStore
either way — so drawing them twice would be two copies of a screen that has to stay identical.
Which picker shows is TransfersViewModel.Remote, set on arrival by the shell.
-->
<Panel IsVisible="{Binding IsTransfersShowing}">
<views:FilesScreen DataContext="{Binding Transfers}" />
</Panel>
<Panel IsVisible="{Binding IsBucketsShowing}">
<views:FilesScreen DataContext="{Binding Transfers}" />
</Panel>
<!--
Preferences keeps PendingScreen for its body — it is still a screen that states what is absent
rather than one with settings on it — and gains the header its siblings under MORE have, here
rather than inside PendingScreen: that control is shared with three states of getting in, none of
which has anywhere to go back to.
-->
<Grid IsVisible="{Binding IsPreferencesShowing}" RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
<Button Grid.Column="0" Classes="icon" Content="←" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" />
</Grid>
<views:PendingScreen Grid.Row="1"
Heading="PREFERENCES"
Detail="Registering this phone's device key, and signing out, are on the unlock screen for now. Importing an ~/.ssh/config has no meaning here — scoped storage means there is no such file to read." />
</Grid>
</Panel>
<!--
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
makes its own trust decision on the desktop, and when the phone grows that screen this must already
be somewhere both can reach rather than nested inside one of them.
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
inside the hosts screen would be one the files screen could not raise.
-->
<Panel Grid.Row="1">
<views:HostKeySheet DataContext="{Binding Vault}" />
@@ -104,64 +171,78 @@
desktop-shaped idea in the product — it is what makes a terminal a surface you switch to rather than
a screen you navigate away from. Kept, because losing it would make an open shell invisible from the
host list, which is where you are when you open the next one.
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.
-->
<Border Grid.Row="2" IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="12,0" VerticalAlignment="Center">
<TextBlock Classes="label" FontSize="8" Text="SHELLS" VerticalAlignment="Center" />
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Button Classes="row" MinHeight="32" Padding="10,6" CornerRadius="5"
Background="{StaticResource Field}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="6">
<!--
Green only while there is a shell behind the tab. It used to be lit unconditionally,
which was true when a tab could not exist without a session; one can now — connecting
opens the tab first — and a dot that was green before anything had answered would be
the one thing on this strip claiming something untrue.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="5" Height="5"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="10" Text="{Binding Label}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="9"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green only while there is a shell behind the tab. It used to be lit unconditionally,
which was true when a tab could not exist without a session; one can now — connecting
opens the tab first — and a dot that was green before anything had answered would be
the one thing on this strip claiming something untrue.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- ============ navigation ============ -->
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="58">
BorderThickness="0,1,0,0" Height="64">
<Grid ColumnDefinitions="*,*,*,*">
<views:NavButton Grid.Column="0" Label="HOSTS" IsCurrent="{Binding IsHostsShowing}"
<views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
<views:NavButton Grid.Column="1" Label="KEYCHAIN" IsCurrent="{Binding IsVaultShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
<!--
The terminal is a surface rather than a page — see ShellSurface — so this one does not go
through ShowScreen. Its own command is on the shell.
-->
<views:NavButton Grid.Column="2" Label="TERMINAL" IsCurrent="{Binding IsTerminalSurface}"
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗" IsCurrent="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}" />
<views:NavButton Grid.Column="3" Label="MORE" IsCurrent="{Binding IsPreferencesShowing}"
<!--
"Keychain", although the design's tab says Vault and the enum member is Vault. The split is a
standing rule rather than an inconsistency: every sentence a user reads calls the encrypted
store a keychain, and everything under the surface calls it a vault, because renaming the wire
route, the tables and the CLR types would put an AAD resource type inside the blast radius of a
find-and-replace. See docs/design-import-gaps.md. A tab reading Vault would be the one place the
product surface leaked the implementation's word.
-->
<views:NavButton Grid.Column="2" Label="Keychain" Glyph="⚿" IsCurrent="{Binding IsVaultShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}" />
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
<!--
IsMoreSurface rather than IsMoreShowing: this tab stands for six screens, and a bar that went
dark the moment you opened one of them would only ever light three of its four entries.
-->
<views:NavButton Grid.Column="3" Label="More" Glyph="≣" IsCurrent="{Binding IsMoreSurface}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
</Grid>
</Border>