Commit Graph
71 Commits
Author SHA1 Message Date
jaap-jan 7b7fd7b2ef Make a vault the thing you create, and let a window set one aside
Everything a shared vault needs was already here and arranged the wrong way
round. A vault has to belong to a team, so creating one meant going to the teams
screen, founding an organisation, and only then adding a vault to it — which the
NEW VAULT button named after the team, so a team with three of them held three
vaults called the same thing and nothing told them apart. Somebody who wants to
share four servers with two colleagues is not asking to found anything.

So the form asks for a name and nothing else. The team is derived from it, slug
included, and created with this account as its owner; the vault goes inside; and
the members, roles, invitations and key holders that hang off a team are all on
screen the moment it exists. The tab strip's New vault entry lands there with the
new vault selected, which is where the next thing anybody wants to do already is.

That is two calls, and the first can succeed alone. When it does the team is
kept: the id is minted once into pendingVaultTeamId, so pressing CREATE again
resends the identical create — which the server treats as the same team — and
retries the vault, and the message says all of that rather than "creating the
vault failed". Archiving the orphan instead would be a client deleting something
on the user's behalf because a later step failed, which is the kind of tidying
that eventually archives a team somebody has just been added to. A slug taken by
somebody else is retried once with a disambiguated one and never in a loop; a
name with no a-z or 0-9 anywhere in it falls back to the team's own id rather
than to a refusal pointing at a field nobody was shown.

The other half is the caret beside Vaults. Being in four teams means four teams'
machines in front of you all day, and the answer is a switch per vault rather
than four sign-ins. Switching one off takes its hosts, groups, keys and pins off
the screens that list them and does nothing else: it still syncs, its key stays
in the keyring, it stays choosable as somewhere to file a new item, and a shown
host that authenticates with a key filed in it still connects. That last one is
what shaped the design. TryBuildAuthentication resolves a binding out of the
keychain's typed list and a cross-vault binding is legal, so filtering the reload
loops — the obvious implementation — would have turned a preference about reading
into an outage. Only the projections a person reads consult IsVaultShown; every
Reload*Async stays whole, including the dialled-endpoint set that decides which
pins are described as unused, because that is a hint which invites deleting
trust.

Snippets, logs and buckets needed no code and the comment says so out loud: all
three read ActiveVaultId alone, and the personal vault is drawn in the menu
ticked and cannot be switched off — it is the active vault, the group and tag
editors' target, and the save picker's fallback, so hiding it would empty half
the application rather than filter it.

The preference is a column on the cache's vault row, which is what makes it
survive both a relaunch and the /me refresh that runs every minute: Apply does
not touch it, deliberately, because the server has never been told which vaults
this machine is showing. It is in the encrypted cache rather than settings.json
because it is a list of vault ids and that file's own doc comment says what may
go in it. VaultSession cannot see the type at all — ReadableVaults is what the
sync loop walks, and a filter reaching it would be a vault that quietly stopped
syncing, found out weeks later from a host that was never there.

The strip's note refusing a MenuFlyout stands and is unchanged. This flyout
sidesteps the question rather than answering it: the handler selects the Vaults
tab first, which collapses the renderer, so nothing native is under the popup by
the time it opens — the move QuickConnect already makes. A headless test asserts
that ordering, which is as far as headless can go with no native window, and
manual check 1.6 is the other half.

The phone is out of scope on purpose: it has no tab strip and its teams screen's
vault section is read-only. The plumbing is in Client.Shell, so it can adopt this
later; until then nothing there is ever hidden, which is today's behaviour.

1514 tests pass. Fifteen are new in VaultVisibilityTests, and the ones worth
naming are the guards: a hidden vault still syncs, still holds keys that
authenticate hosts on screen, still appears in the save picker, and still counts
towards which pins nothing dials.

Not fixed, and noted here because it is next door: VaultGrantService's team-vault
create refuses a taken vault id rather than returning the existing vault, while
VaultSharing's own remark claims a create whose response was lost is safe to
resend. A lost 200 therefore leaves a vault whose key the client's catch already
zeroed, openable by nobody.
2026-08-03 21:52:27 +02:00
jaap-jan ca6f69de34 Merge branch 'claude/group-double-click-breadcrumb-7bf3f8'
ci / build and test (push) Successful in 1m21s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 32s
2026-08-03 16:22:45 +02:00
jaap-jan 0c0ac94312 Open a group by double-clicking it, and say where you are
ONE PRESS WAS DOING TWO JOBS. A group card was the only place a group could be
selected — it is what EDIT and DELETE aim at — and it was also the control that
narrowed the grid to that group. So there was no way to name a group in order to
rename it without every host outside it leaving the screen at the same moment,
and no way back except a SHOW ALL button that appeared beside the heading.

Two gestures instead. A click selects and does nothing else; a double-click
opens, which is what the host cards below already do to get a shell and what the
transfers screen's directories already do to go inside one. The grid now has one
vocabulary rather than one per list. The gesture is wired in the code-behind
beside the host one, and guarded the same way: a double-click on the space
around the cards must not open whichever group happened to be selected.

THE CARDS ARE ONE LEVEL NOW, not every group in the keychain. Groups nest, and
drawing all of them flat was the only honest thing to do while a card was a
filter — a filter nobody can see is a filter nobody can turn off. Once opening
one became navigation the cards became its contents, and VisibleGroups is that
level beside Groups the way VisibleHosts sits beside Hosts. Groups itself is
untouched: it is what every lookup reads and what the phone's headings are built
from, and the phone binds none of the new members.

Which is what the trail is for. A level with no name and no way out is a grid
that has quietly hidden things, so a breadcrumb sits above the cards — drawn
exactly as the transfers screen draws a directory path, same flat crumbs and
same separator, because it is the same control answering the same question and a
window with two breadcrumbs that look different has two ideas of what a path is.
The first crumb is always there and always goes back to every host, which is
what SHOW ALL was; that button went with it, because a control that only says
"stop" beside a trail that says where you are is a second control for one job,
and this one also gets you back one level rather than all the way.

EDIT AND DELETE AIM AT GroupTarget: the selected card, or the open group when no
card is selected. Without the fallback a group with nothing inside it could be
opened and then never edited, because opening a group is exactly what takes its
own card off the screen. It is also what a file manager does — act on the
selection, and on the current folder when there is none — and the pair is now
hidden with nothing to act on rather than shown doing nothing.

A DANGLING PARENT AND A CYCLE BOTH HAD TO END UP SOMEWHERE REACHABLE. Neither is
prevented: a parent id may point at a group deleted on another machine, and two
clients can each re-parent A under B and B under A while offline, which no merge
can see because the pointer is inside the payload. EffectiveParents promotes
both to the outermost level, which is the same degradation the resolver's
visited set produces for inheritance. The repair for either is the group's own
editor and the editor is opened from its card, so a group drawn nowhere would be
a broken state with the fix locked inside it.

Three tests in HostGridTests: the split rule through the properties the cards
bind, the pointer gesture itself in two windows so that "one press still only
selects" is asserted separately from the pair, and a nested group opened,
emptied of cards and walked back out of one level. The last presses the trail as
it is actually rendered rather than calling the command, because a crumb reaches
the vault through a $parent binding — a string that compiles whether or not it
resolves, and would otherwise leave a row of buttons that do nothing. 85 layout
tests and 234 shell-flow tests pass.

Manual-checks 3.2, 7.6 and 7.7 follow the new gestures, and 3.2a and 3.4a are
new: nesting, and the two states above, both of which need two machines and
neither of which headless Avalonia can reach.
2026-08-03 16:16:18 +02:00
jaap-jan 38d8706784 Give the phone a way to enrol the fingerprint it already unlocks with
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 35s
ci / build and test (push) Successful in 1m36s
The Android device key store, the biometric gate and the lock screen's UNLOCK
WITH FINGERPRINT button have all shipped since this head was written, and none
of them could ever run: that button appears only when a device key exists, and
nothing on the phone could create one. `CanUnlockWithDevice` was false on every
launch of every phone. This is the missing half.

**The offer is on PREFERENCES**, which held a PendingScreen until it had a
setting on it. It is there rather than beside the button it turns on because
registering needs an unlocked keychain and a reachable server — the vault has to
be open to seal the bundle, and the wrap has to reach the account or a phone
somebody has lost could never be revoked. Neither is true on the lock screen.

One card, and exactly one of its three blocks is ever drawn: the offer, the
withdrawal, or the sentence saying this phone has nowhere to keep a key. That is
`CanRegisterDevice` / `CanForgetDevice` / `HasNoDeviceKeyOption`, which are two
flags and not one and its negation for the reason written where they are set —
a phone with no screen lock and a phone already registered are both "cannot
register", and only the second has anything to take back. The withdrawal has no
confirmation, deliberately, and the sentence above it carries what the desktop
puts in a tooltip this head has no room for. `StatusMessage` is on the screen
because it is the only feedback this head has once the system's own dialogue has
gone.

**Two things would have been wrong in the feature the moment it worked.**

`Environment.MachineName` answers `localhost` on Android, and registering names
the device — so every phone would have arrived in the account's device list as
another identical row, on the very screen a lost handset is revoked from.
`PhoneEnvironment.DeviceName` was already written and never called; the shell
now takes it as an optional constructor argument that the desktop does not pass,
and it reaches enrollment, registration and every connection log entry. That was
gap §7 of docs/android-port.md, and it is now closed.

And the status line said "Waiting for Windows…" over an Android biometric
prompt. `GestureWait` picks the sentence from the platform rather than from a
head, unlike the device name beside it: a device name is a fact about one
handset only the head can read, and which dialogue appears is a fact about the
operating system this assembly is running on.

Two tests cover the seam — the injected name reaching the account, and the
default still being this machine's own name — and `FakeVaultServer` records what
each device called itself, because the name is the only part of a registration a
person ever reads. The gesture itself is unreachable from any test process, so
Phase 13 of docs/manual-checks.md carries five checks, including that enrolling
a new fingerprint in Android's own Settings destroys the key. That one is the
property that makes this a fast path rather than a weakening of the passphrase.
2026-08-03 16:12:50 +02:00
jaap-jan 0c4bfea2a8 Merge branch 'claude/group-creation-hosts-dragdrop-56c6a3'
ci / build and test (push) Canceled after 1m6s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
2026-08-03 15:50:12 +02:00
jaap-jan f9d08b738c Take the group headings out of the host grid, and drop onto a group card
A first group turned the wrap of host cards into an accordion: SidebarRows
interleaves a full-width fold-away heading — chevron, name, count — between the
cards, and in a grid that reads as a dropdown somebody left open. The desktop
grid binds VisibleHosts now. The headings and the fold stay for the phone, whose
list has no room for the row of group cards the desktop draws above the grid.

What a heading said, the card says: HostRowViewModel.GroupLabel, resolved once
per reload like the tag names, drawn as an accent chip and absent from a host in
no group — or in one that has been deleted, which is the same thing to look at.

What a heading also was is the drop target, and that moves to the group cards.
Two things go with it. A host dropped onto another host card used to be filed
beside it, which was legible while a heading named the band of cards it landed
in and is guesswork now; it is refused. And UNGROUPED was how a host was dragged
back out of a group; the way out is the picker in its own editor, which is the
one place "no group" can be said in words.

A drag held at either edge of the grid scrolls it. Without that the gesture only
works for whoever can see both ends of it: the group cards are the first thing in
the scroller, the host may be the fortieth card down, and a drag cannot use the
wheel. A step per drag event rather than a timer, so it follows the pointer and
stops when it stops.

The two heading-shaped tests are replaced. TheHostsGridHoldsCardsAndNoGroupHeadings
asserts the grid's contents rather than only measuring them, because a heading
that came back would lay out perfectly cleanly. TheGroupCardsAreWhatAcceptsADroppedHost
raises a real DragOver over both kinds of card and checks the effects and the
mark — the nearest a headless test gets to a gesture no headless test can
synthesise. manual-checks 3.1-3.2 and 7.6-7.9 follow.
2026-08-03 15:49:58 +02:00
jaap-jan 562fb444a8 Merge main into the phone connections branch
ci / build and test (push) Canceled after 46s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
Main had already taken this branch's first two commits, so what merged is the
Connections work against three things that landed beside it. Four of the six
conflicts were prose about arrangements both sides changed; two were real.

**The phone hub gained a Teams row while this branch was moving the keychain
onto it.** Both are additions to `IsMoreSurface` and both belong: teams because
the desktop reaches them from its rail and the phone through the hub, the
keychain because a bottom bar is for the places a session moves between. The
membership test, the back gesture's first case and the hub's own arithmetic all
take the union. The distinction is now written down rather than implied — teams
is the design's count plus one, and the keychain is the only rearrangement of
it: the bar lost a slot to gain that row.

**`ConnectAndAnnounceAsync` was the real one.** Main gave it
`RememberTypedPasswordAsync`, which binds the password that just worked to the
host it worked on; this branch had replaced the `HostRowViewModel` that method
needs with a four-field `ConnectionTarget`. Keeping both meant deciding what a
manual connection does with a password that succeeded, and the answer was
already written on the screen it is typed into: nothing. There is no item to
bind a credential to and none to bind it on, and that path saves nothing by
design.

So `ConnectionTarget` carries the row again — as a nullable, in place of the
host id it had, with `HostId` derived from it. Two things read it and both are
things that can only be done to a keychain item rather than to an address:
naming the log entry, and keeping the password. Null is not missing data there;
it is the whole of what makes the manual path different, and having one field
rather than two keeps "was this a keychain host" a question with one answer.

The desktop's rail lost SFTP and S3 to the tab strip on main, so the README's
"a rail with nine slots has room" was true when it was written this afternoon
and is not now. It says the room rather than the number.

Phase 11's four new device checks and main's Phase 12 on teams were the same
conflict twice — two appends to the end of one file — and both are kept.

Verified after resolving: the solution builds, the Android head builds clean,
and 837 tests pass across the seven client suites, including main's own additions
(233 shell, 79 layout, 240 domain, 118 sync, 54 session, 74 terminal, 39
storage).
2026-08-03 15:35:49 +02:00
jaap-jan f5ffd1983d Make Connections the place a connection is made, and put the keychain away
Four changes to the phone, and the last one needed the connect path taking
apart.

**The bottom bar is three entries.** The keychain moved onto the hub, which is
now SETTINGS with a gear rather than MORE with a hamburger. A bottom bar is for
the places a session moves between, and keys, credentials and tags are managed
occasionally and then left alone — which is the shape of everything already
behind that hub. With the keychain on it, "more" stopped being a description of
what is there. `ShellScreen.Vault` joining `IsMoreSurface` is the whole of the
change: the tab that lights, the header that stands down and the back gesture's
first case all read that one property, which is why the switch mirrors it by
construction rather than by a second list. The keychain screen grew the header
every hub screen has, because the shell's own is not above it any more and
without one there would be no back arrow and nothing saying what the list is.

The desktop keeps its Keychain rail entry. A rail with nine slots has room, so
this is the second thing the two heads arrange deliberately differently, after
the hub itself.

**Terminal became Connections**, and the word does more work than a rename
usually does — see below. The enum member stays `ShellSurface.Terminal`, for
the reason the tab was never called Vault: the surface is a terminal, and the
word a user reads is the product's.

**The + puts the software keyboard away.** It sits above a terminal somebody is
typing into, so the sheet it raises was arriving underneath a keyboard covering
the half of the screen the sheet is on — and worse, laid out into the strip
left above it, since the keyboard's inset shortens everything this head draws.
Avalonia cannot do this and it is worth knowing why: `TopLevel.InputPane`
reports the keyboard and offers nothing that closes one, because the framework's
model is that it belongs to whatever has focus — and this keyboard was raised by
the `WebView`'s own text input, by a native view Avalonia's focus manager never
owned. Clearing Avalonia's focus leaves it exactly where it is. So
`Platform/SoftKeyboard.cs` asks `InputMethodManager`, off the decor view's
window token, and every step of it is allowed to be absent.

**With nothing open, Connections is a connect screen rather than an empty
state.** A box taking `user@host` or `user@host:port`, a password, and the
machines most recently connected to underneath. The box is the only path in this
product to a machine the keychain has never heard of, which is a real case it
had no answer for: an address somebody was handed five minutes ago. A typed
password and nothing else — offering the keychain's keys would be a second
binding resolution beside `TryBuildAuthentication`, and the argument against a
second one is written there at length. Nothing typed is saved, and the screen
says so: a machine worth keeping belongs on HOSTS, where it can carry a key, a
group's defaults and a name.

The recents come out of the vault's own connection log rather than a list kept
in this process, so they survive a restart and arrive on a new phone with the
keychain. Deduplicated by address, because this is a list of places and not of
events, and capped at six so the box stays above the keyboard. Emptied when the
vault is — they are decrypted entries naming where somebody works, and a lock
that left them on screen would be a list still readable after every key that
decrypted it was zeroed. Tapping one leads to whichever of two things it is: a
keychain host goes to that host's connect bar, where its key, its password box
and its refusals already live, and an address goes back into the box, without
the password, whose absence is the point of that path rather than a gap in it.

**The connect path was shaped like `HostRowViewModel` all the way down.** The
log entry, the identification, the failure record and the retry all took a row.
They take a four-field `ConnectionTarget` now, so a connection to an address
shares the ladder of refusals, the host-key question and the tab's lifecycle
rather than growing a second copy of them. `ConnectionRecorder.Record` and
`Identify` have always taken a nullable host id, so the log could already hold a
connection with no item behind it.

One behavioural change falls out of that and it is the one to know about:
**trusting a host key now retries the attempt that raised the question** instead
of re-running whichever host is selected. That was correct while a selected host
was the only way to connect; with a manual target it would dial a different
machine, or refuse with "choose a host first" over a key the user has just
agreed to trust. The test selects a host first, so a regression cannot pass by
connecting to the wrong thing successfully.

`LogsViewModel.ReloadAsync` split so the connections half can be read alone.
Reading the keychain's activity for a screen that offers neither would double
the decryption on the list that was already the expensive one.

Twelve tests: the parse grammar as a theory over seven refusals, the dialled
request, the retry, and both branches of tapping a recent row. The recents rows
are built by hand rather than connected-and-closed — what those tests are about
is which branch a row takes, and driving it through the recorder's queue would
test the recorder, which `DodoSSH.Client.Session.Tests` already does. What needs
a device is phases 11.6 to 11.9 of `docs/manual-checks.md`.
2026-08-03 15:26:47 +02:00
jaap-jan 1b7df47537 Merge main into the desktop redesign branch
ci / build and test (push) Successful in 1m26s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 20s
Four conflicts. Three were two people adding to the same spot, and one was a
real collision: main gave the connect bar a REMEMBER tick in the same pass that
this branch took the connect bar off the hosts screen.

REMEMBER is now in the drawer, beside the password box it qualifies. Nothing
about the feature changed — RemembersConnectPassword, its refusal to fire until
the remote has accepted the password, and the six tests over it are main's
untouched — only where it is drawn. The move improves it slightly and it is
worth saying why rather than claiming a merge was neutral: the bar had one row
and had to fit the box, the tick, the authentication note and CONNECT along it,
which is why the tick was a bare "REMEMBER" in tracked capitals. A column has
room to put it under the box as a sentence, where it reads as a property of the
password rather than as a fourth control in a row.

MainWindowViewModel: both sides added members after ShowTerminal — the desktop's
three fixed tabs here, the phone's connect menu on main. They do not interact,
so both are kept, each under its own heading.

TeamsScreen: main added the team's own RENAME, HAND OVER and ARCHIVE, a member's
LastActive, the role a new member arrives as, and the KEY HOLDERS list. This
branch had only bumped the file's font sizes a point. Resolved by taking main's
file whole and re-running the bump over it, so the new controls join the scale
rather than sitting a point below everything around them.

README: both sides described a different head's third pass in the same
paragraph. Both kept.

Two things checked rather than assumed, because this branch moved the furniture
the merged commits sit on. The chrome heights main's terminal work touched are
the phone's, not the desktop's — 44, 42 and 24 are unchanged, so the layout
harness's budget still describes the window. And main's keychain DELETE did not
reach VaultScreen.axaml, whose header this branch rearranged, so the five
buttons that overflowed at the larger type are still five.

2415 tests pass, up from 2369 by the 46 main brought.
2026-08-03 15:17:55 +02:00
jaap-jan 208443b932 Move SFTP and S3 into the tab strip, and the host list into a card grid
Four asks in one pass over the desktop head, and two of them are furniture
moving rather than anything new.

THE STRIP IS THE WINDOW'S NOW, not the terminal's. Vaults, SFTP and S3 sit at
its head and cannot be closed, and open terminals follow them. SFTP and S3 left
the nav rail to get there, which is the one semantic change: they are the two
destinations you stay in while something runs, and a rail entry is for
somewhere you go and come back from. So the rail belongs to the Vaults tab and
collapses with it, which also means SFTP, S3 and a terminal each get the full
1016 rather than the 826 a page gets.

The tab is expressed as "a page, and not one of the two the strip took" rather
than as a fourth ShellSurface. Both are still ShellScreen members and have to
be — that is what they are on the phone, where they are two rows in a hub — so
a surface each would have been a second way to say a thing Screen already says.
IsTransfersShowing and IsBucketsShowing light the other two tabs unchanged.
What is new is one field: the page Vaults comes back to, because it is the one
tab with sub-navigation and therefore the one that can return to the wrong
place. That is not the hidden field ShellSurface argues against — that one
would be a second copy of "which page"; this is a tab remembering its own.

THE HOSTS SCREEN IS A GRID, and the 268-pixel sidebar is gone. That column was
choosing among forty machines and editing one of them at two-thirds width, and
it was narrow so the editor beneath it could be a column at all. Cards took the
first job at full width; a 304-pixel drawer took the second and collapses when
nothing is selected. Pressing a group card narrows the grid; SHOW ALL is the
way back. The group editor moved into the drawer as well, which finally makes
IsEditingGroup mean the same thing on both heads — it was the phone's alone,
because the desktop's editor was a bar that was always on screen.

AreHostsExpanded and ToggleHosts went with the control that used them. They
folded the whole list away under one heading, an affordance that existed
because the column was narrow. Folding a single group is a different thing and
is still here.

THE TYPE SCALE IS A POINT LARGER and the text ramp is white. The base size was
never stated anywhere — a bare TextBlock took TextElement's default of 12 — so
raising the scale meant naming it, on Window and on UserControl. The second
selector is not redundancy: the layout harness hosts a UserControl in a window
it builds itself, and without it the suite would measure every screen a point
smaller than it ships, silently. A selector on TextBlock would have been the
obvious way and is wrong, because a style setter beats an inherited value and
would collapse every deliberate step back to one number.

#E3E7F4 is a blue-tinted white on blue-black surfaces, which costs contrast
twice — once for being darker than white and once for sharing a hue with what
it is drawn on. Pure white is 18.3:1 against the canvas where that was 15.5:1.
Every step below moved with the top, so the intervals the design chose are
kept and TextDim clears 9:1 against 6.4:1. The palette is shared, so the phone
has both changes too.

TWO DEFECTS THE HARNESS STRUCTURALLY CANNOT SEE, found by rendering the screen
rather than by measuring it, and both now covered.

The tile was 232 and was first written as 248, from arithmetic that left out
the scrolling stack's own margins. Every layout test passed — the harness asks
whether a control is inside the window, never how many fit on a line — so the
grid quietly became one column wide at exactly the minimum this application
guarantees, which is the shape cards exist to avoid.
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen counts columns
instead, and fails at 248.

And a card's text ran past its own border, because a horizontal StackPanel
measures children with infinite width: a TextBlock inside one never learns it
is short of room, so TextTrimming never fires. Both card rows are grids with a
star column that gives way and an Auto column that does not — a hostname with
its tail cut is still the machine you were looking for, where a badge or the
word naming an auth method is not.

The keychain header changed shape for the same class of reason. It was
Auto,Auto,*,Auto with the buttons last, so the slack column was the only thing
absorbing a change of width and five buttons fell off the right edge the moment
the type grew. That is how GENERATE lost the word KEY once already. The summary
sits in the star column and trims now, so the buttons always get their width.

HostSidebarTests became HostGridTests and moved to the grid with the gestures
it drives. docs/design-import-gaps.md gains a v3 section naming the five
toolbar controls in the design with nothing behind them — a view-mode switch, a
tag filter, a calendar, a share control and Serial — and manual-checks.md and
the README follow the controls that moved.
2026-08-03 15:08:48 +02:00
jaap-jan dbfe3a5a37 Merge branch 'claude/host-connection-top-bar-25d04e'
ci / build and test (push) Successful in 1m36s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 27s
2026-08-03 14:56:12 +02:00
jaap-jan a2f0d4813a Take a third off both of the terminal's bars, and centre the cross in a tab
The bar the last commit put above a shell opened at 52 and the accessory row
under it at 50, both inherited from the arrangement they replaced rather than
measured against the one they are in. Neither is carrying a title or a sentence
any more — the top one holds two icons and a row of pills, the bottom one a
line of keys — so a third comes off each: 35 and 33.

Every height inside them came down too. The pills go 44 to 30, the icon squares
34 rather than 44, the keys 38 to 30. A bar that shrank around contents that
did not would not have saved anything; it would have moved the clipping
somewhere harder to see.

Two of those numbers had arguments written against them and both arguments
change rather than disappear. The pill was 44 because it contains the one
control on this head that is destructive with neither confirmation nor undo,
and that is now carried by width — the cross keeps its full 44-pixel column,
and what it gave up is vertical slack in a row where nothing sits above or
below it to be hit by mistake. The keys were 38 for the same kind of reason,
and the 44 that mattered there was always the width: ten keys flexed across
360dp is 32 pixels each, which is what the horizontal minimum exists to refuse.
Both comments say what replaced the reasoning rather than quietly showing a
smaller number.

The close cross was not vertically centred, and it was not a rounding error.
`Button.row` sets `HorizontalContentAlignment` and says nothing about the other
axis, so the glyph sat against the top of its own column while the label beside
it was centred by the stack panel it lives in. On the control that ends a
session that reads as a misprint. Both alignments are now stated, on the button
and on the text.

The `+` loses the accent and becomes the same `Button.icon` as the arrow across
from it. The two are a matched pair at either end of one bar — one leaves this
surface, one adds to it — and an accented one ranked itself above the way out.
The accent fill belongs to the floating `+` on HOSTS, which is the only action
on its screen; this one is not.

Five pixels between the renderer and the keys, as a margin rather than a
border. The renderer is a native child view and nothing Avalonia draws can sit
on top of it, so a hairline there would have to be a row of its own — and a
terminal whose last line of output is flush against a row of grey keys reads as
one surface that has gone wrong rather than as two that are different things.

The four places that named the old bar height are corrected, including manual
check 11.5, which asserted a number that would now fail.
2026-08-03 14:54:16 +02:00
jaap-janandClaude Opus 5 7e4e068aab Merge main into the teams branch
Two conflicts, and both were two people counting the same things differently
rather than disagreeing about what the code should do.

PhoneShell's header comment. The branch made "the five hub screens"
numberless, because TEAMS made it six and a number in that sentence had
already gone stale once. Main corrected "three destinations" to "two" in the
same sentence, because giving a shell the whole phone took the terminal out of
the set the header is drawn on. Both are right and neither noticed the other:
the header now stays on the hub's screens and on the two top-level
destinations, which is Hosts and Keychain.

The manual checks. Both sides appended a Phase 10 — main added the software
keyboard and the phone's terminal surface as 10 and 11, the branch added
Teams. Nothing about them overlaps, so the resolution is to keep all three in
the order they were written and renumber Teams to Phase 12, its subsections
and the one cross-reference inside 12.1 with it. Main's two phases keep the
numbers they already carry in its history, since renumbering those would move
headings somebody may already have linked to.

Everything else merged without a conflict, and the two places worth checking
afterwards both held: IsMoreSurface and the first case of
PhoneShell.OnBackRequested each kept ShellScreen.Team alongside main's edits.
Those two are one fact in two places, so a merge that dropped Team from either
would have trapped the user on the teams screen with the MORE tab dark.

Verified after resolving: solution builds with no errors and no new warnings,
the Android head builds, and every suite passes — App 214, Layout 73, Api 162,
Infrastructure 34, Contracts 25, Session 54. App gained the three shell-flow
tests main brought with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:40:27 +02:00
jaap-jan 80ae586fc4 Give a shell the whole phone, and one bar to leave it by
A connected phone was drawing five rows of chrome around the thing the user
opened it for. The vault header at 56, the terminal's own tab strip at 52, a
connection line at 36, the shells strip at 46 and the four-entry bottom bar at
64: at 360dp that is about a third of the display, and every row of it was
about somewhere the user was not. What replaces them is one 52-pixel bar drawn
by the surface itself — back on the left, the session pills, and a `+` across
from them — and then the terminal.

Three of those rows belong to `PhoneShell` and each is now bound on
`IsShowingPages`. That is the same question asked once rather than three
conditions that could drift: the surface is either a page or a terminal, and
these are the chrome a page has. The header needed a wrapper because Avalonia's
bindings have no "and" and it already had a condition of its own; the strip
needed one for the same reason. The bottom bar had none and is bound directly.

The back arrow goes to the page the terminal was opened over rather than to
Hosts by name, because the system back gesture already picks that and an arrow
landing somewhere else would be the second of two answers to one question. The
bar's `+` raises a sheet offering the three connections this application can
make — a shell, a host's files over SFTP, a bucket — since SFTP and S3 used to
be two taps through the bottom bar's MORE and the bar is not on screen here. A
control that replaced it and led to one of the three would have quietly removed
the other two.

Two things moved rather than being dropped. The text-size buttons are pinned at
the right-hand end of the accessory key row, outside its scroller: the
connection line existed to keep them from scrolling out of reach, and being
outside the scroller answers that argument rather than abandoning it. The
dialled address moved onto the connecting card, which is the moment it is worth
reading — what is being connected to, before anything has answered — and after
that the shell's own prompt says it more accurately than a header derived from
the keychain ever did.

The sheet collapses the renderer rather than covering it. Whether Android's
`WebView` composites above Avalonia content the way Win32's child window does is
still unverified — `docs/android-port.md` has said so since the port — so this
follows the desktop's palette and gives up the rectangle outright, which is
correct under either answer. It collapses `IsTerminalShowing` and not
`IsTerminalSurface`, because the bar the sheet was raised from is part of that
surface and dropping it would take the bar, the tabs and the whole arrangement
with it, leaving the sheet floating over the page underneath.

`OnSurfaceChanged` is the one place the flag is lowered, and that is the load-
bearing half. Every way out of a terminal ends there — a destination, the files
screen, the palette connecting to a host, closing the last tab, a lock — and
each of them would otherwise leave a sheet set over a page. Not merely untidy:
the flag holds the renderer blank, so the next return to the terminal would
draw the menu again over a rectangle kept blank by it. Opening is refused off
the terminal surface for the same reason from the other direction.

The back gesture gains a guard above the switch, in the shape of the editor
guard that arrived with the phone's `+`. It is nearer than any of them: with no
header and no bottom bar, while the menu is up that gesture is the only way off
it other than the scrim and CANCEL.

The bottom bar's Terminal entry lost its `IsCurrent` binding. The bar is
collapsed on that surface, so the binding could only ever be read as false, and
a rule about a state the control cannot be in is a claim that it can.

Three tests in `ShellFlowTests`, which is where shared state-machine behaviour
for this head goes: the collapse and its recovery, the refusal to open over a
page, and the sheet lowering both by a menu entry and by a route it was never
wired to. Everything visual needs a device, so it is phase 11 of
`docs/manual-checks.md` — and 11.2 is the check that would finally settle the
compositing question this head has carried as unverified since the port.
2026-08-03 14:33:16 +02:00
jaap-janandClaude Opus 5 a43286ece8 Let a team change hands, and be joined by somebody with no account yet
M3 built teams and stopped short of the two operations that decide who
controls one. Both were written down as refusals rather than omissions:
ADR 0009 listed ownership transfer under "deliberately not built", and
design-import-gaps said an invitation needed "a token with a lifetime and an
outbound mail path". One of those reasons had expired and the other never
applied — an invitation does not need a token if it is not a thing anybody
presents.

Handing a team over is one write. The member you name becomes owner and you
become an admin, in a single transaction, because ownership is sole: promoting
first leaves the team owned twice, demoting first leaves it owned by nobody,
and there is nobody left with the authority to finish a transfer that stopped
in the middle. That is also why it is not two calls to the role endpoint, which
refuses Owner outright. The outgoing owner is demoted rather than removed —
removing them would revoke their vault key grants and flag every team vault for
rekey, which is a far larger act than the one asked for, and somebody handing
over a team is usually staying in it. It unblocks the thing that was impossible
before: an owner can now leave, by handing the team on first.

An invitation is a standing instruction rather than a message. This server has
no outbound mail path, so nothing is sent and there is nothing for the invitee
to present. The row says the next account signing in with that address joins
this team at this role, and telling them to sign in is the caller's job over a
channel this server does not carry. A link nobody can deliver would be worse
than none. It lives in its own table rather than becoming a membership with
MembershipStatus.Invited, and that member stays unwritten for the reason it
always was: team_membership.user_id is not nullable and carries a foreign key,
so somebody who has never signed in has nothing for that row to point at.
Widening it would make the unique index on (team, user) meaningless, because
PostgreSQL counts every NULL as distinct.

Verification is the security boundary, and nothing in this server read it
before. A claim requires the access token to assert email_verified. An
invitation decides what the server will serve, so one claimable by anybody able
to obtain a token carrying somebody else's address is a way into a team — which
is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it
would have been reintroduced by the back door. There is deliberately no setting
that relaxes it: a flag that exists is one somebody turns on for the afternoon
their provider is misconfigured. Absence is refused rather than trusted, and
logged, because a provider that never sends the claim otherwise leaves every
invitation pending with nothing anywhere saying why.

Claiming happens at just-in-time provisioning and again on an hourly sweep. The
sweep is what makes it recoverable rather than one-shot — an invitation issued
between an account being created and that person next signing in would
otherwise be stranded for ever — and it shares its rate with the last-seen
write because both are housekeeping nobody is waiting on.

Archiving is refused while a team owns a vault, and that refusal is the end of
the road rather than a step on it. A team vault is readable because of
membership, so archiving one that still owned vaults would take them away from
everybody holding a key, including the caller, quietly and all at once. Nothing
in this product deletes a vault, so no order of operations gets past it today —
which is stated with a count of what is in the way, for the reason the SFTP
layer refuses a recursive delete: a refusal is visible and a quiet removal is
not. It is owner-only, as handing over is; renaming is not, because a rename is
visible to everybody and reversible by anybody who can do it. The slug is not
renameable at all: it is unique only among live teams, so a rename could take
one an archived team is still holding, and that team could then never be
restored.

LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is
refreshed on ordinary authenticated requests, at most once per account per
hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency
token, so a read-then-write on the hot path would start losing races between
one user's own overlapping requests. An hour is the granularity the question is
actually asked at, and the interface draws it to the day rather than the minute
so it does not read as a precision that is not there. The remarks in Contracts
and in the view model that argued at length for the column's absence are
rewritten rather than extended; both had become false.

Two endpoints already existed and nothing called them. ChangeTeamMemberRole and
ListVaultGrants have been reachable since M3. The role picker refuses Owner
itself rather than letting the server do it, since the interface already knew
the rule; the key-holder list sits under the vault rather than beside the
member, because a grant is per vault and a count on a member row would imply
per-item sharing, which is M5. It lists withdrawn and stale grants and says
which they are — a list that dropped them would show a departed colleague as
merely absent rather than as somebody whose key was taken away — and staleness
is decided by comparing generations, since a grant can be Active and still open
nothing.

ADD MEMBER stopped being a dead end. An address the directory did not know used
to end at a sentence telling the user their colleague had to sign in first. It
invites them instead, from the same button, because which of the two applies is
a fact about the server's account table rather than about what the user is
doing; which one happened is reported afterwards, because that decides what
they do next. An address that merely has an account is invited rather than
refused: refusing would have made the endpoint an oracle for which addresses
have accounts here, answerable by anybody willing to create a team first.

The phone has a TEAMS screen, behind MORE, and it is the reverse of every other
row in design-import-gaps: a shipped screen the design had no slot for. It is
there because an invitation is claimed by signing in, so somebody told they are
now in a team is at least as likely to be holding a phone — and a membership
visible only on a head they never installed is one they cannot see. It draws
SHARE KEY and nothing that takes something away: wrapping a key is the one act
on that screen a server cannot perform at all, and the desktop guards its
revocations with a tooltip, which is a control a touch screen cannot show.

Two defects were found by an adversarial pass and both were green against the
whole suite at the time. The owner-only check on archiving and handing over had
been weakened to the admin check while their messages and comments still said
owner — and since nothing behind the archive endpoint re-checks it, an admin
the owner had promoted could have archived the team out from under them. And
the rename endpoint built its response with a hardcoded Owner role, so an admin
who renamed a team was handed a summary claiming they owned it, and a client
trusting that instead of re-listing would have offered them the two owner-only
buttons the server then refuses.

The new table gets its constraints tested rather than merely migrated: live
uniqueness per (team, address), the citext proof that an address typed by a
person matches one cased by a provider, and reissue after both revocation and
acceptance. The teams screen gets its first entries in the layout suite, at the
minimum window with every list populated and with each of the two states that
cover half of it — it had none, and it just grew four sections and a second
line in the member row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:31:43 +02:00
jaap-jan 35387b1c9d Tell the phone's keyboard these are secrets, and get it off the box
ci / api image (push) Successful in 21s
ci / build and test (push) Successful in 1m22s
ci / android head (push) Failing after 5s
Five boxes on this head take a secret and every one of them was drawing dots
and saying nothing. `PasswordChar` is a screen property: Windows has no opinion
about what is being typed into a text box, so the desktop head needs nothing
more. Android's software keyboard has an opinion, and left at its default it
read a vault passphrase as prose — completions offered in the suggestion strip
above the box, and the passphrase itself learned into the IME's dictionary.
Dots on screen with a word bar over them is the worst of both: hidden from the
person typing it and offered to the room. `TextInputOptions.ContentType` is the
property the Android backend maps onto `InputType`, and it is what turns both
off. Both attributes now live in one `TextBox.secret` class rather than being
repeated per box, because they are two halves of one fact and the next box
added would have got one of them.

The keyboard also went on covering whichever box had raised it. That is in
`PhoneShell` rather than on each screen: everything the phone draws is inside
its one root panel, so a bottom margin shortens all eleven screens at once, and
a screen added later cannot forget to handle something it never had to know
about.

Two mechanisms, and it matters that neither is a backstop for the other. Before
Android 15 the activity now declares `AdjustResize` and the platform shortens
the window itself; left unspecified Android chooses, and what it chooses for a
window whose entire content is one native view — which is what an Avalonia
surface is — is to pan, sliding the window by however much it thinks the
focused native view needs and leaving the box exactly where it was. That was
the bug. From Android 15 the attribute is ignored, edge-to-edge being enforced
and the window no longer resized for the keyboard at all, and the reported
inset is what there is. Each is dead where the other applies — where the window
resizes, the inset arrives already consumed and measures zero — which is why
the margin comes from the inset alone. Both added together would strand the
interface an entire keyboard above the keyboard.

Scrolling the box back into view keys off the size change rather than off
either mechanism. `ScrollViewer` already brings a newly focused child into
view; what it cannot know is that the visible region shrank after the focus,
and both ways of losing that region end in the same resize.

None of it is reachable by a test. The software keyboard is an inset the
platform reports and a headless top level reports none, so phase 10 of
`docs/manual-checks.md` is the whole of the verification — including the note
to run it on one device each side of Android 15, since a build exercised on
only one of the two will look correct and be half broken.
2026-08-03 13:55:08 +02:00
jaap-janandClaude Opus 5 16e0051e89 Draw the tags that have been storable and invisible since the domain landed
ci / build and test (push) Successful in 1m22s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 52s
`Tag` has been a full item kind for three commits — a table, a migration, a
codec, a merge, a cipher — and `HostSecret.TagIds` has merged per tag so two
people tagging one host both keep theirs. Nothing drew a chip. The tags a
client could store were ones nothing here could see.

Chips on host rows, both heads, from names resolved through the tag list rather
than ids: a tag that does not resolve is left out rather than drawn, because it
means the tag was deleted elsewhere or belongs to a vault this session cannot
read, and a host with one chip fewer is the honest answer where a host wearing
a GUID is not. The id stays on the host, so the chip comes back if the tag does.

The picker is chips that toggle, matching the chips on the row behind it. A
list of names to tick would make the user match an entry to a chip they can see
two inches away. The box under it creates a tag and puts it on straight away,
because that is when a tag is usually wanted — while tagging a host and finding
it does not exist yet. Unlike every other field in that editor it writes to the
keychain immediately, since a host can only name an id that exists; cancelling
therefore leaves the tag behind, which is honest rather than hidden. A name
that already exists is used rather than repeated: two tags called "staging" are
storable and must stay storable, because two people creating one offline is how
it happens, but typing it into a box beside a chip of the same name is a slip.

Renaming and deleting needed a home, or the picker fills with names nobody uses
and never empties. That home is a TAGS category on the keychain screen, where
every other item kind is managed — and renaming is the whole reason a tag is an
item rather than a string repeated inside twenty payloads: it is one write, and
no host is touched. The delete confirmation counts the hosts wearing it, which
is the difference between a tidy-up and losing a filter somebody relies on.

The desktop host editor now scrolls, and that is not a tidy-up. A picker's
height is a chip per tag in the keychain, wrapped, so somebody with fifteen
tags has an editor half again as tall as somebody with three; no fixed height
holds that, and trimming other fields to buy room only moves the failure to
whoever has sixteen. The layout suite caught it the moment its seeder grew tags
— which is why the seeder now creates ten rather than three, enough to drive
the pane onto its cap so the capped shape is what gets measured rather than one
no real keychain produces. The cost is named where it is paid: the harness
skips anything inside a ScrollViewer, so from here it certifies that pane fits
the column rather than that every field in it does.

Two smaller things fell out. Five buttons overflowed the keychain header by a
few pixels, so GENERATE lost the word KEY — its tooltip carries what the word
did. And TotalItemCount had been counting keys and credentials while ALL showed
four kinds; it counts all five now, because a number under a chip that
disagrees with the rows it opens is worse than no number.

An adversarial review of this change found two defects it had introduced, both
green against the full suite. NewTag filed into the "new items go to" picker
while the tag list only ever holds the active vault's — so with a team vault
selected a tag would be created, queued for push, reported as added, and then
invisible, with no row, no count, no picker entry and nothing able to rename or
delete it, because there is no active-vault switcher to go and find it with.
The comment on the host editor's own create path states that exact rule; this
was the one place that broke it, and NewObjectStore, whose list is likewise
active-vault-only, already ignored the picker. And the tag editor was the only
one of five that did not disarm a pending deletion when it opened, so arming a
key's deletion and then pressing + TAG left a live DELETE for an item the user
was no longer looking at, directly above the boxes they were typing into. Both
are fixed, both have a test, and the first was checked against the broken
version before being kept.

The same review caught a doc comment that had been inserted between
SnippetRowViewModel's summary and its declaration, silently taking it over.

Verified by the whole suite on a clean build: 1413 tests over nineteen
projects, none failing. Both heads build. The rectangles the layout suite
cannot reach are phase 9 of docs/manual-checks.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:42:43 +02:00
jaap-janandClaude Opus 5 4925dcf179 Give the phone the + it was promised, and the editors behind it
Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The
phone can now put things in a keychain rather than only read one.

The floating + arrives three designs after it was first asked for. It was
refused twice on the honest grounds that it would open an editor this head had
not got — and that stopped being true when steps 1 to 4 landed. It raises a
sheet rather than a form, because "add" on this screen has been two operations
since groups existed: a host, or a group to file hosts under.

Both editors are cards in the list's own row, swapped for the list rather than
stacked over it, following SnippetsScreen and for the reason written there: a
form on top of the list hides what it is about. There is still no dialog and no
editor screen anywhere on this head. The port and username boxes may be left
empty, and the greyed text inside each is what the host will actually use —
following the group picker as it moves, so the form says what leaving a box
blank will do rather than making it a guess.

The back gesture gains a guard above the switch rather than a case inside it.
An editor is not a screen and has no entry there, and it is strictly nearer:
the sheet sits over the editor's own screen, so back lowers whatever is topmost
before it considers moving between screens.

A group editor needs a way in, and the phone draws no groups panel — so the
heading in the host list gains a pencil. A + that adds groups with no way to
correct one is the same strange thing to ship as one that adds hosts with no
way to correct one, and worse: a group's defaults are what every host beneath
it falls back to.

Scouting step 6 turned up a bug worth more than the prose it was looking for.
ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation
while a group carried only a name — a teammate's host showed under UNGROUPED
and nothing else was lost. Since a group began lending a port, a username and a
binding, the same omission silently dropped all three: that host would dial 22
as nobody while the machine is on 2222 as deploy, with nothing on screen saying
why. The resolution map now spans every readable vault. The editable list stays
narrow, because a row shown across vaults needs a vault id for rename and
delete and two vaults may hold groups with one name — but the map needs none of
that, since it is only ever asked what an id says.

An adversarial review of this change found something I had written a comment
denying. The + was a child of the outer Panel rather than of the list's row, so
it anchored to the bottom of the screen and sat squarely over the new EDIT
button and the lower edge of CONNECT — and because a Panel hit-tests its
last-declared child first, tapping the right end of EDIT would have raised the
add sheet. The comment beside it claimed it was in the list's row precisely so
that could not happen, and manual-checks 8.1 names that exact failure. It is in
Grid.Row 2 now. The same pass caught a connect bar that was disabled rather
than hidden while its own comment said hidden, a scrim that would have flashed
its default pressed chrome across the screen, a fab comment claiming an accent
fill no other button had when Button.primary has it, and a back-gesture remark
naming two view-model properties that do not exist anywhere in the repo.

That last one came from this plan, which invented IsVaultsSurface and
IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has
never existed. Both are corrected in the plan rather than quietly worked
around, along with a fourth "groups are flat" site in README.md that step 1
missed.

The phone's rectangles are not measurable and structurally never will be: the
layout suite is net10.0, the Android head is net10.0-android, and Avalonia's
application is a one-shot process global — so a second head cannot share the
process even if the reference were possible. Everything the sheet and both
editors bind to is shared and is tested headlessly here; the pixels go to
docs/manual-checks.md phase 8, nine checks, which is where this project already
sends what it cannot assert.

Tags remain the one thing asked for that did not ship. Tag is a full item kind
and TagIds merges, encodes and resolves, but no screen draws a chip or offers
to add one, so the tags a client can store are ones nothing here can see. Both
editors carry the set through a save untouched so a client that can set them
does not lose them. Recorded as half-shipped in docs/design-import-gaps.md
rather than quietly left out.

Verified by the whole suite: 1402 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display — phase 8 is what that costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:30:00 +02:00
jaap-janandClaude Opus 5 3627021420 Give the desktop the second design too, and the window the size it now needs
The desktop v2 design is the other half of the one the phone took last commit, and this is its chrome: a
190-pixel labelled sidebar where the 54-pixel icon rail was, a titlebar with the search box centred in it,
and session tabs drawn as pills. The palette was already here — it is shared, and moved when the phone's
did — so what this changes is shape rather than colour.

**The window's minimum grew, and by exactly what v2 added.** The sidebar is 136 wider and the chrome 14
taller, so 880x560 became 1016x574. That is not a round number somebody liked: it leaves every screen the
same 826x464 it was designed against, which is the arithmetic the layout suite is built on. Four of the
tables stop fitting at 690 wide, so widening the sidebar and leaving the window alone would have broken
them somewhere no test was looking. LayoutHarness carries the new constants and the suite still passes at
the minimum, which is the whole reason it exists.

The rail's five-character abbreviations are gone with the width that caused them — PINS and SNIPS are Pins
and Snippets again — and each row gains a glyph and a count. A count is drawn only where one is real, so
SFTP, Logs and Preferences show nothing rather than a zero: a transfer queue's depth is not how many files
a screen holds, and a log has no total until it is read. The count beside Pins is the vault's own, not the
Pins screen's VisiblePins, which is the filtered list and would have made the sidebar count whatever
somebody had just typed into a filter box on another screen. Teams has no count for a related reason: they
are read from the server when that screen is opened rather than on unlock, so a number there would read 0
until somebody had already been to look.

One colour moved with it, finishing what the repalette started: the live-session summaries on the unlock and
sign-out cards were Info, so the two heads disagreed about a fact the phone paints green. They match again.

**Buckets became a destination rather than a mode**, which is what the design draws and what the phone
already does. The HOST / BUCKET pair inside the files screen is gone; ShellScreen.Buckets draws the same
TransfersScreen with the other picker, and the sidebar entry is what sets it. That also settles an old
disagreement rather than merely moving it: TotalItemCount is keys plus passwords and excludes buckets, so
the number beside the keychain used to disagree with the list under it, and now counts what that screen
shows.

There is one session behind both file destinations, so asking for the other kind while something is open is
refused rather than obeyed — and refusing means staying put. An earlier turn of this had it move anyway and
only decline to switch the picker, which put the S3 entry in the sidebar over a screen still listing an
SFTP host: two pieces of chrome disagreeing about where you are, which is worse than the navigation simply
not happening. The message that says so goes to Transfers.Status, which turned out to be drawn in the same
grid cell as the connected chip — survivable while it was mostly read before connecting, and not once a
refusal reports itself there. It has its own column now.

The design has nine entries' worth of screens and draws five. Pins, Teams, Import and Preferences are
built, working screens, so they keep their entries — the sidebar is labelled now and has the room, and
dropping an entry would have stranded a screen rather than simplified anything. The Team vault card the
design pins to the foot is not drawn: it is a second route to a screen already in the list, carrying a seat
count nothing here produces.

**The status bar survives the design that deletes it**, cut down to one thing. Two of the three facts it
carried moved into the titlebar with v2 — the sync word is beside its dot and the shortcut hint is inside
the box that uses it — so those are gone from it rather than printed twice. The third is Vault.Status, the
only channel this application has for saying a save failed or a merge picked a winner. The design is a
mock-up of an afternoon that goes well and has nowhere to put a sentence like that; dropping the bar would
have meant dropping the sentence or repeating it on nine screens.

What v2 draws and this does not is in docs/design-import-gaps.md, and it is the same list as the phone's
for the same reasons: the forwarding screen and both its chips, the host detail's fingerprint, tags and
last-session cards, the keychain's rotate button, the logs' FOLLOW pill and severity filters, and the
session footer's latency. The terminal is not inset behind a rounded frame either — it is a native child
window that composites above everything Avalonia paints, so the frame would clip nothing, which is the same
answer the phone gave.

**The light theme is not built.** Its accent is #6D5AE6, a different hue rather than a tint of the dark
one, so it needs every colour doubled, a variant to switch on, the renderer's own page switching with it,
and contrast checked twice. That is a piece of work rather than a setting, and it is separable from the
layout — which is why this commit is the layout.

The screens themselves are restyled through the shared vocabulary rather than rebuilt: corner radii,
chips, cards and the accent's ink, all in App.axaml, so every screen moves at once. Their layouts are left
alone deliberately. The design draws read-only detail panes and these screens carry the editors and forms
it has no equivalent of, so replacing a layout with the mock-up's would have lost the half that is
actually used.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the 68 layout cases
that stand up real Avalonia and measure every screen at the new minimum. Both heads build. Not run on a
machine with a display — see docs/manual-checks.md for what wants looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 19:29:21 +02:00
jaap-jan 4300d917a8 Stop making people wait for a handshake, and give the host list a pointer
ci / build and test (push) Failing after 3s
ci / android head (push) Failing after 2s
Connecting held the vault's busy gate, which meant a window that did nothing visible for
as long as a machine took to answer — and against one that is merely asleep, that is the
whole timeout. The gate is gone from that one command. A tab now appears in the strip in
the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's
rectangle draws a card naming the host and the address being dialled. Every other screen
stays usable, and two connections can be in flight at once.

That splits the vault's one connection event into three, carrying an attempt id, because
"which tab is this about" can no longer be answered by "the most recent one". The id also
buys the two kinds of not-connecting their different endings: a refusal stays in the strip
as a tab holding its reason, since by then the user is quite likely three screens away and
a status line they are not looking at is not where a failure should end; a host key
question takes the tab away and puts the window back on HOSTS, because the prompt is drawn
there and a tab claiming failure would be competing with the thing about to resume it.

ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than
tidying. A [RelayCommand] over a method that takes one generates a command that cancels
the previous execution's token on every invocation — so asking for a second machine
silently abandoned the first, measured as the first tab disappearing with "Cancelled." the
instant the second was asked for. Giving up on a connection is closing its tab, and a
session that lands after that is adopted rather than dropped: a shell running with nothing
naming it cannot be closed at all.

A tab is marked active on IsShowing rather than IsSelected. The selection survives
navigating away — that is what makes the strip a way back to a terminal instead of a way
to lose one — so a tab lit while preferences filled the window was a second "you are here"
mark pointing at something nobody could see. The nav rail's own entries have always made
this distinction.

The host list grows the two gestures it looked like it already had. A right click selects
the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is
on the list rather than in the item template, so its entries are the vault's own commands
and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a
heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it
out of a group; the write is one field of one host through the same repository a save
uses, refused while the editor is open because a drop is a gesture on the list and not on
a half-typed form.

Clicking a result in the palette connects, which is what a list of hosts under a search
box looks like it does. It went through the shell's own command, so the pointer and Enter
take one path.

And the files screen's two pickers followed the vault's lists once, at unlock: a host or a
bucket created afterwards could not be picked until the keychain had been locked and
opened again, with nothing on screen explaining why the machine plainly in the host list
was missing. They follow the collections now, re-finding the selection by id across the
rebuild a sync pass causes every minute.

165 shell tests and 69 layout tests green, including the connecting tab, both failure
endings, two connections at once, a connection in flight across a lock, and the right
click acting on the row under the pointer rather than on the selection. The drag itself is
in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform
drag, and a test that claimed to have dropped something would pass while confirming
nothing.
2026-07-31 22:59:33 +02:00
jaap-jan d07b336868 Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain
hid every open terminal and the strip that named them. A connection you had
opened was invisible from four of the five screens. The window now has two
surfaces rather than one: a nav rail that says which page you are on, and a
terminal strip that is always there and switches the whole content area to a
shell. Screen keeps meaning "which page" and never becomes a sixth kind of
page, which is why this is two properties instead of one enum with a terminal
member in it.

Every screen lives inside one wrapper panel that collapses when a terminal is
showing. That is not tidiness — the WebView hosts a Win32 child window that
composites above everything Avalonia draws, so a screen left visible over its
rectangle is a screen sliced in half, and this window has shipped that defect
once already. One decision point, IsTerminalShowing, and a nested panel rather
than five compound bindings nobody would remember to extend.

The focus choreography is the part no test in this repo can see. Every reveal
path now focuses in the same turn the WebView appeared, so all three of them
post at DispatcherPriority.Loaded and let the native control re-push its bounds
first. Going the other way had a real bug: the screen-changed branch called a
bare Focus() where it had to release the keyboard from the native child, so
switching from a terminal to Files silently ate the first keystrokes. Rare
before this commit and the primary gesture after it.

The tab strip grew a cross inside each tab, a plus that opens the quick-connect
palette, and middle-click close. Nested buttons are correct here: Avalonia
handles a left press on the cross and deliberately does not handle other
buttons, which is exactly what lets middle-click bubble up from the cross as
well as the tab. The test is PointerUpdateKind rather than
IsMiddleButtonPressed, because the latter reports button state and is also true
for a left press made while the middle button happens to be held. The handler
is on the tab and not the strip, so the background closes nothing by
construction. Plus opens the palette rather than a flyout, since a menu
dropping into the WebView's rectangle may or may not composite above a child
HWND and this repo does not make rendering claims it has not photographed.

Everything a user reads now says keychain. The wire, the database and the
cryptographic spec still say vault, deliberately: renaming those is a migration
and a protocol change for a word. That split is written down rather than left
to be rediscovered as an inconsistency.

Four things that were squeezed into the keychain's category rail, or into
nothing at all, now have screens. Pinned host keys get one, with fingerprints
never truncated and a filter that matches them, because comparing what you have
against what the operator published is the whole workflow; the approved date is
read out of the item's UUIDv7 rather than added as a column, and says so, since
it means first approval and not last use. Keys can be generated in the client,
which needed the openssh-key-v1 container written by hand — there is no BCL or
NSec helper, and the PKCS#8 route is unverified in the SSH library this uses.
The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is
Blowfish with a swizzle, in a project whose crypto is otherwise entirely
libsodium, for a protection the key's own remarks argue is redundant inside a
vault. Generation fills the existing editor and stops, so SAVE stays the one
thing that writes. ~/.ssh/config can be imported behind a preview that is
ticked per row and writes nothing until the button; IdentityFile records the
path and imports the key material only on an explicit opt-in, because reading
somebody's private key into a vault is precisely the act this product exists to
make deliberate. Match blocks and ProxyJump are reported rather than obeyed —
one cannot be evaluated statically and the other has nothing behind it to route
with, and a preview that implied otherwise would be worse than one that admits
it.

Files can be dragged in all four directions that are honestly available. Remote
to Explorer does not ship and is not pretended to: the shell wants the bytes
during the drop, which needs a virtual file and a native COM data object,
outside what Avalonia offers. Note for the next person that Avalonia 12
replaced the drag model outright — DataObject and DataFormats are no-op stubs
and IDataObject is not in the reference assembly, so every tutorial written for
11 does not compile here.

Hosts can be grouped, flat and never nested. A parent id merged as a scalar
lets two offline clients each re-parent A under B and B under A, producing a
cycle inside an encrypted payload that no server can police and every reader
would have to detect for ever. Membership lives in that payload rather than in
the one plaintext concession ADR 0001 allows, whose test is that the relay
cannot function without it — nothing on the server reads a group, so what
plaintext would hand over is a clustering of the estate for nothing. The
plaintext column reserved for it is dropped, provably always null, and the
server now refuses a client that sends one; it was never populated, was copied
on apply, and was not cleared on delete, so a group id would have outlived the
host it described.

Snippets insert through xterm rather than through the pump, because xterm is
the only thing that knows whether the remote has bracketed paste on, and that
is what makes a shell treat embedded newlines as text instead of as execute.
The host process moves opaque bytes and never parses output, so it would have
to guess, and guessing wrong runs every line. Running is off by default and the
copy says the text goes into whatever is there — the terminal has no notion of
being at a prompt, and may be in vi or at a password prompt with echo off, so
the Enter the user presses themselves is the entire safety property.

Connections and keychain changes are recorded as synced encrypted items, which
is what makes them auditable by a team later and costs the server knowledge of
connection rate and timing from row counts alone. ADR 0001 already concedes it
cannot hide that class of metadata; the trade is now written into it rather
than left implicit. A connection entry is written once, at close, which is what
makes a synced log tractable: nothing to merge, one outbox row, no chance of
colliding with itself. Live sessions come from memory, not from the log. The
write is void by contract and posts to a bounded channel, because putting an
encrypt-and-write on the teardown path of every session is how closing the
application comes to take four seconds. A ticket opened before a lock still
closes afterwards, since a shell outlives the vault. The activity log hooks the
one generic repository every kind writes through, so it cannot miss a caller —
which is also why the log kinds themselves declare they are not audited, or the
first entry would write an entry about writing an entry. It records the names
of the fields that changed and never their values; a log with an old password
in it would be a plaintext credential store with no vault around it. Retention
is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop
rather than on a second timer.

That log traffic then broke the status line, which is worth recording because
the fix is a shape and not a patch: background sync counted its own log rows as
pushed items, so the quiet rule stopped being quiet and every action's message
was overwritten a second later by a sync report. The report now separates log
rows from user items and the rule reads the latter.

S3 buckets appear as a remote in the file browser, behind the same interface an
SFTP session implements, so the queue and both panes did not have to learn what
they are talking to. Uploads go through a pipe, because the queue wants to
write and the SDK wants to read; memory is then bounded by the part size
instead of buffering a file to disk twice.

Finally, the Windows device key store moved out of the session project, which
was the one thing keeping it from being portable — everything else in it is
platform-neutral, and a Windows CNG dependency in the middle of the vault code
meant a second head could not reference it without dragging Windows along. The
seam that made the move free was already there. docs/android-port.md is the
audit behind that: what ports, what does not, in order of cost, the four
decisions taken, and an inventory of every screen and state the interface has
to carry, written so a design can be made from it directly.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1240 tests at zero warnings, including the end-to-end suite against real
containers. The manual checks that headless Avalonia cannot make — the drag
from Explorer, a generated key against a real host, twelve tabs at the minimum
window width — are listed in docs/manual-checks.md and are still outstanding.
2026-07-31 20:30:05 +02:00