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
+166 -16
View File
@@ -10,6 +10,12 @@
Classes rather than implicit styles for the buttons, because the shell needs three that differ in
meaning rather than in degree: the accent one is what the screen wants you to do, the outlined one is
the alternative, and the red one is the one that costs something.
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
The second design rounds everything. The corner radii below are the design's own — 4 for a tag, 9 for a
button or a pill, 10 for a list row, 11 for the search well, 12 for a card, 14 for a block of
monospaced output — and they are a ladder rather than a set of preferences: the radius says how big the
thing is, so a 12 on a chip or a 4 on a card reads as the wrong size before it reads as the wrong shape.
-->
<Style Selector="Button.primary">
@@ -18,8 +24,8 @@
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource Canvas}" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -35,7 +41,7 @@
reads the screen as broken rather than as waiting for them.
-->
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="#1A1F1D" />
<Setter Property="Background" Value="{StaticResource Raised}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
</Style>
@@ -48,7 +54,7 @@
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11.5" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -63,7 +69,7 @@
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource Danger}" />
<Setter Property="CornerRadius" Value="5" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -76,7 +82,7 @@
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="16,6" />
</Style>
<Style Selector="Button.row:pressed /template/ ContentPresenter">
@@ -84,20 +90,108 @@
</Style>
<!--
A category chip. A RadioButton rather than a ToggleButton because the four are exclusive and a screen
The 44-pixel square in a header — a back arrow, an add, an upload. Square rather than stretched because
it sits beside a title that must keep the width it has, and 44 rather than the glyph's own size because
a 17-pixel arrow is a 17-pixel target.
-->
<Style Selector="Button.icon">
<Setter Property="Width" Value="44" />
<Setter Property="Height" Value="44" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="17" />
</Style>
<Style Selector="Button.icon.accent">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
<Setter Property="FontSize" Value="20" />
</Style>
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
</Style>
<!--
No floating action button. The design puts one on HOSTS and one on S3, and both would open an editor
this head has not got — hosts and keychain items are created on the desktop and sync down. The style is
not here waiting for them either: an unused style is a claim that the control exists somewhere.
-->
<!--
A card: one item, one rule, one thing you can act on. The design uses it for a key, a snippet, a
transfer and the panel at the foot of the keychain, and they are all the same shape deliberately —
the screens differ in what they hold rather than in how they are built.
-->
<Style Selector="Border.card">
<Setter Property="Background" Value="{StaticResource Sidebar}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="14,13" />
</Style>
<!--
A block of monospaced output — a snippet's command, a log, the terminal. One surface for all three, and
it is not one of the palette's chrome greys: see TerminalSurface in Palette.axaml.
-->
<Style Selector="Border.output">
<Setter Property="Background" Value="{StaticResource TerminalSurface}" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="12" />
</Style>
<!--
A small monospaced tag: a vault's name, a sync badge, what is open on the files screen. Not a control —
it is never pressed — so a Border rather than a Button, and the class exists so that the four screens
drawing one cannot disagree about its padding.
-->
<Style Selector="Border.tag">
<Setter Property="Background" Value="{StaticResource Chip}" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="7,2" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Border.tag > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="9" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<!-- An outlined tag, for a fact about an item rather than a name: a type, an algorithm, a count. -->
<Style Selector="Border.tag.outline">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="5,1" />
</Style>
<Style Selector="Border.tag.outline > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="8.5" />
</Style>
<!--
A category chip. A RadioButton rather than a ToggleButton because the options are exclusive and a screen
reader should say so; the bullet its default template draws is removed, since the chip's own fill is
what carries the state.
Checked is a filled surface with accent *text*, not an accent fill. v2 makes that distinction
everywhere — see the remark on AccentText in Palette.axaml — and a chip is where it matters most: a row
of four solid blue lozenges is a row of four things that all look like the primary action.
-->
<Style Selector="RadioButton.chip">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="12,6" />
<Setter Property="CornerRadius" Value="17" />
<Setter Property="Padding" Value="13,6" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Template">
<ControlTemplate>
@@ -114,19 +208,27 @@
</Setter>
</Style>
<Style Selector="RadioButton.chip:checked">
<Setter Property="Background" Value="{StaticResource Accent}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource Canvas}" />
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!--
No segmented control either. The design draws one over the file listing for LOCAL / REMOTE, and there is
no local side on a phone — see FilesScreen. The two-way choices that are real, the log's two logs and
the keychain's categories, are chip rows, which is the same control at the same size without a well
around it.
-->
<Style Selector="TextBox.field">
<Setter Property="Height" Value="48" />
<Setter Property="Height" Value="44" />
<Setter Property="Padding" Value="14,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="CornerRadius" Value="11" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
@@ -136,6 +238,9 @@
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
nothing pings anything, and a dot meaning "up" would be a claim the application never checks.
Live rather than Accent since v2. The accent is blue now and this is not an accent: it is a fact about
a host. See the remark on Live in Palette.axaml.
-->
<Style Selector="Ellipse.dot">
<Setter Property="Width" Value="7" />
@@ -143,7 +248,7 @@
<Setter Property="Fill" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="Ellipse.dot.live">
<Setter Property="Fill" Value="{StaticResource Accent}" />
<Setter Property="Fill" Value="{StaticResource Live}" />
</Style>
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
@@ -177,11 +282,49 @@
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!--
A screen's own name, in the header. The one place this design is not monospaced: the design sets every
heading in its sans face and keeps the mono for what is machine-shaped — an address, a fingerprint, a
count, a command. That split is worth honouring even though both resolve to substituted fonts here.
-->
<Style Selector="TextBlock.heading">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontSize" Value="17" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!--
A group heading over a list: PRODUCTION, STAGING. Letter-spaced, and quieter than the rows under it —
but TextDim rather than the design's own #565E7E. In a flat list these headings are the only thing
separating one block of hosts from the next, and at 10px with a letter-space the fainter step is under
3:1 on this background. Quieter than a host row, still readable, which is what "quieter" has to mean.
-->
<Style Selector="TextBlock.section">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LetterSpacing" Value="1" />
</Style>
<!--
A fingerprint, and it is a style rather than four repeated setters because the rule it encodes is a
product rule: never truncated, and always breakable across lines rather than clipped. Four screens show
one, and the day one of them clips is the day somebody approves the wrong key.
-->
<!--
◆ Declared twice, and this is the bug the rule above exists to prevent rather than a tidiness problem.
Avalonia's type selectors are exact: `TextBlock.fingerprint` does not match a SelectableTextBlock, which
derives from it. Every fingerprint on this head is selectable — you want to be able to copy one and
compare it — so with only the first selector the style applied to none of them, and a SHA256 fingerprint
rendered in the proportional font at the inherited size with TextWrapping left at NoWrap. At 360dp that
runs off the edge and clips, which is exactly the thing the comment above says must never happen.
The desktop head already carries the same pair for `mono` (App.axaml), for the same reason. Avalonia has
an `:is(TextBlock)` form that would match both in one selector; two explicit lines are used here because
the duplication is the thing worth seeing when somebody adds a third fingerprint site.
-->
<Style Selector="TextBlock.fingerprint">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
@@ -189,5 +332,12 @@
<Setter Property="LineHeight" Value="18" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="SelectableTextBlock.fingerprint">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="LineHeight" Value="18" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</Styles>