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
+113 -37
View File
@@ -3,7 +3,7 @@
<!--
The palette, named for what a colour is for rather than for what it looks like. Every one of these is
from the design; the names are this codebase's, because "#0C0F0E" appearing in nine files is how a
from the design; the names are this codebase's, because "#0E1220" appearing in nine files is how a
surface ends up two shades off in the tenth.
It lives here, in the shared project, because there are now two heads drawing the same product. Two
@@ -13,62 +13,138 @@
Five near-black surfaces rather than one, and the difference between them is real work: the window is
the darkest so the terminal reads as the lit thing, chrome sits one step up so the header and status
bar frame it, and the lists sit between the two so a list does not look like part of either.
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
This is the second design's palette: blue-black surfaces and a blue accent, replacing the green-black
ones the first import shipped. Both heads move together, which is the whole reason the file is here —
the Android v2 design and the desktop v2 design specify the same seventeen tokens, so a phone that went
blue while the desktop stayed green would be the drift this file exists to prevent.
Where the design names a colour, the value below is that colour exactly. The design has seventeen
tokens and this palette has rather more, so the rest are interpolated along the same ramp and are
marked. That is deliberate rather than lazy: the finer distinctions here — three border weights, four
text steps — are ones the design draws without naming, and collapsing them to match the token count
would lose the rule each one encodes.
-->
<Color x:Key="CanvasColor">#0A0C0B</Color>
<!--
The surfaces, darkest first. Two things changed shape rather than merely colour.
A field is now *inset* — darker than the canvas it sits on — where it used to be raised. That follows
the design, which draws the search box as a well rather than as a tile, and it is the one place where
reading the old palette's ordering across to the new one gives the wrong answer.
And Chrome and Panel are now the same value, because the design uses one surface for both the header
and a card. They are kept as two names anyway: they answer different questions, and the day one of the
two moves is the day having merged them would have to be undone across every file that used the wrong
one.
-->
<Color x:Key="CanvasColor">#0E1220</Color>
<SolidColorBrush x:Key="Canvas" Color="{StaticResource CanvasColor}" />
<SolidColorBrush x:Key="Chrome" Color="#0D100F" />
<SolidColorBrush x:Key="Sidebar" Color="#0C0F0E" />
<SolidColorBrush x:Key="Panel" Color="#0F1211" />
<SolidColorBrush x:Key="Raised" Color="#111514" />
<SolidColorBrush x:Key="Field" Color="#121615" />
<SolidColorBrush x:Key="Chrome" Color="#111629" />
<SolidColorBrush x:Key="Sidebar" Color="#0F1426" />
<SolidColorBrush x:Key="Panel" Color="#111629" />
<SolidColorBrush x:Key="Raised" Color="#151B31" />
<SolidColorBrush x:Key="Field" Color="#0B0F1D" />
<!--
The surface a terminal, a log and a snippet's command are drawn on, and it is not one of the five
above. The design gives every block of monospaced output this one value, warmer than the chrome around
it, and that is what makes the lit thing read as lit. Named here rather than repeated in the renderer's
stylesheet and in two screens, which is how the three of them would drift.
-->
<SolidColorBrush x:Key="TerminalSurface" Color="#171A26" />
<!-- Row hover, and the heavier one the chrome's own buttons use. On the phone these are press states. -->
<SolidColorBrush x:Key="Hover" Color="#141817" />
<SolidColorBrush x:Key="ChromeHover" Color="#1A1F1D" />
<SolidColorBrush x:Key="Hover" Color="#151B31" />
<SolidColorBrush x:Key="ChromeHover" Color="#1D2749" />
<!--
Selection, and it is a surface rather than a highlight: the design fills a chosen host row, the current
segment of a toggle and the current chip with this and adds no border. Distinct from Hover, which is
what the finger is on rather than what is chosen.
-->
<SolidColorBrush x:Key="Active" Color="#1D2749" />
<!-- The fill behind a small monospaced tag — a group name, a storage class, a bucket. -->
<SolidColorBrush x:Key="Chip" Color="#16204A" />
<!--
Three border weights, and they are not interchangeable. Strong separates one region from another,
subtle separates rows inside one region, and mid is what a control draws around itself.
-->
<SolidColorBrush x:Key="Border" Color="#1E2422" />
<SolidColorBrush x:Key="BorderSubtle" Color="#171C1A" />
<SolidColorBrush x:Key="BorderMid" Color="#2A312E" />
<SolidColorBrush x:Key="BorderHover" Color="#3A423E" />
<SolidColorBrush x:Key="BorderFaint" Color="#232927" />
<SolidColorBrush x:Key="Border" Color="#1A2138" />
<SolidColorBrush x:Key="BorderSubtle" Color="#151B31" />
<SolidColorBrush x:Key="BorderMid" Color="#232B45" />
<SolidColorBrush x:Key="BorderHover" Color="#2E3859" />
<SolidColorBrush x:Key="BorderFaint" Color="#1F2740" />
<!--
The text ramp. Three steps, used consistently: what you read, what you glance at, and what is there
only so its absence would be noticed. A fourth step would be one nobody could tell from its neighbours.
-->
<SolidColorBrush x:Key="Text" Color="#DCE3DF" />
<SolidColorBrush x:Key="TextDim" Color="#7E8A84" />
<SolidColorBrush x:Key="TextFaint" Color="#566059" />
<SolidColorBrush x:Key="TextGhost" Color="#404743" />
<SolidColorBrush x:Key="Text" Color="#E3E7F4" />
<SolidColorBrush x:Key="TextDim" Color="#8B93B0" />
<SolidColorBrush x:Key="TextFaint" Color="#565E7E" />
<SolidColorBrush x:Key="TextGhost" Color="#414A6B" />
<!--
The accent, and the three colours that are allowed to disagree with it. Green means live, connected or
yours; amber means a caveat worth reading; red means refused or destructive; blue is for the one thing
that is neither — a directory, a distinct scope — and is deliberately rare.
The accent, and it is two colours rather than one. The design fills with #5B8CFF — the button, the
floating action button, a progress bar, a toggle that is on — and writes with #8FA7FF: the current
destination in the bottom bar, a link, the text inside a chip. They are not interchangeable in either
direction. The fill is too saturated to read as small text on a dark surface, and the text colour is
too pale to carry a button.
AccentInk is what goes *on* the fill. It equals Canvas today and is named separately anyway, because
"the darkest surface" and "the colour of text on a blue button" are two facts that happen to coincide,
and a light theme would separate them immediately.
-->
<Color x:Key="AccentColor">#3CE88F</Color>
<Color x:Key="AccentColor">#5B8CFF</Color>
<SolidColorBrush x:Key="Accent" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="AccentSoft" Color="#3CE88F" Opacity="0.35" />
<SolidColorBrush x:Key="AccentWash" Color="#3CE88F" Opacity="0.06" />
<SolidColorBrush x:Key="Warn" Color="#E8B44C" />
<SolidColorBrush x:Key="WarnSoft" Color="#E8B44C" Opacity="0.35" />
<SolidColorBrush x:Key="WarnWash" Color="#E8B44C" Opacity="0.06" />
<SolidColorBrush x:Key="WarnText" Color="#B9A26B" />
<SolidColorBrush x:Key="Danger" Color="#E85D5D" />
<SolidColorBrush x:Key="DangerSoft" Color="#E85D5D" Opacity="0.3" />
<SolidColorBrush x:Key="DangerWash" Color="#E85D5D" Opacity="0.08" />
<SolidColorBrush x:Key="DangerText" Color="#D98A8A" />
<SolidColorBrush x:Key="Info" Color="#5DA9E8" />
<SolidColorBrush x:Key="AccentText" Color="#8FA7FF" />
<SolidColorBrush x:Key="AccentInk" Color="#0E1220" />
<SolidColorBrush x:Key="AccentSoft" Color="#5B8CFF" Opacity="0.35" />
<SolidColorBrush x:Key="AccentWash" Color="#5B8CFF" Opacity="0.06" />
<!--
The design asks for IBM Plex Mono and IBM Plex Sans. Neither ships with this application, and neither
is on a stock Windows install or a stock Android one, so requesting them by name would render as
whatever the font fallback chose that day — which is worse than choosing deliberately. Inter is
embedded by both heads and is what they already draw with.
◆ Green is no longer the accent, and that is the one semantic change in this file rather than a
recolour.
It used to be both: Ellipse.dot.live filled with Accent, so "a shell is open on this host" and "this is
the thing the screen wants you to press" were the same colour by construction. The v2 design keeps a
green — #3DDC97, for the live dot, the sync light and the follow indicator — and makes the accent blue,
which finally separates the two. Live is that green, and nothing that is merely interactive may use it.
Read the pair as: blue is what you can do, green is what is true.
-->
<SolidColorBrush x:Key="Live" Color="#3DDC97" />
<SolidColorBrush x:Key="LiveWash" Color="#3DDC97" Opacity="0.12" />
<!--
The three colours that are allowed to disagree with the accent. Amber means a caveat worth reading, red
means refused or destructive, and blue-light is for the one thing that is neither — an informational log
line, a directory, a distinct scope.
WarnText and DangerText are the prose weights: a wash is a very dark tint, and the signal colour that
works as a 6-pixel dot or a one-word label is not the one that reads as a sentence on top of it. The
design supplies both reds — #E86A6A labels the refusal and #FF8F8F writes it out — and one amber, so
the amber prose weight is interpolated.
-->
<SolidColorBrush x:Key="Warn" Color="#FFB454" />
<SolidColorBrush x:Key="WarnSoft" Color="#FFB454" Opacity="0.35" />
<SolidColorBrush x:Key="WarnWash" Color="#FFB454" Opacity="0.12" />
<SolidColorBrush x:Key="WarnText" Color="#E4A868" />
<SolidColorBrush x:Key="Danger" Color="#E86A6A" />
<SolidColorBrush x:Key="DangerSoft" Color="#E86A6A" Opacity="0.3" />
<SolidColorBrush x:Key="DangerWash" Color="#E86A6A" Opacity="0.12" />
<SolidColorBrush x:Key="DangerText" Color="#FF8F8F" />
<SolidColorBrush x:Key="Info" Color="#7FB0FF" />
<!--
The design asks for Instrument Sans and Fira Code, as the one before it asked for IBM Plex. Neither
ships with this application, and neither is on a stock Windows install or a stock Android one, so
requesting them by name would render as whatever the font fallback chose that day — which is worse than
choosing deliberately. Inter is embedded by both heads and is what they already draw with.
The stack ends in the generic `monospace` rather than a Windows face, which is what makes it work on
both: Android has no Cascadia Mono or Consolas and resolves the generic name to its own mono face.