Commit Graph
236 Commits
Author SHA1 Message Date
jaap-jan 805d81dbcc Merge branch 'claude/team-key-share-rotate-4b2619'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 21s
Two conflicts, and the second is worth recording. main's M4 bullet gained the
Android signing decision while this branch rewrote the M5 line either side of it;
both are kept.

The other is an ADR number collision: two sessions each took 0010, one for vault
key rotation and one for Android distribution, and both are now on main. ADR
numbers are identifiers — "see ADR 0010" appears in code comments as well as in
prose — so leaving two would make every such reference ambiguous. The rotation
ADR landed first and is referenced from crypto.md, the gaps document, ADR 0009
and the sync code; the Android one is referenced from README and android-port.md.
So the later and cheaper one moves: 0010-android-distribution.md is now ADR 0011,
with its title and both references updated. Nothing about either decision changes.
2026-08-04 10:23:46 +02:00
jaap-jan 5d447da532 Take a rotated vault's contents onto the new key as well
Rotating a vault re-keyed the vault and not its contents, which was the deal
struck last time: everything already stored stayed sealed under the generation it
was written with, every remaining member kept the older keys, and the guarantee
was narrowed to "nothing written from now on". That left one gap worth closing —
somebody who walked off with the old key could still open old ciphertext they
later got hold of — and the reason it was safe to defer is the reason it was
cheap to add. A vault at mixed generations reads perfectly well, so the pass that
moves items across can stop half way and be run again.

VaultResealer walks the vault and rewrites each item as an ordinary upsert
against the version the server holds. It never decodes the plaintext: an item is
opened and the same bytes are sealed again under a fresh data key, so an item
written by a newer client crosses a rotation untouched rather than being
re-encoded through this build's codec and quietly losing the fields this build
has no concept of. It also means nothing in the pass knows what an item is, which
is why one loop covers every type including the ones added after it. A conflict
is counted and skipped rather than merged — there is nothing to merge, since no
content changes — and the next pass picks the item up at the version the other
client left.

The half that a pass over stored items cannot see is a change queued before the
rotation and pushed after it, which would put a brand-new item into the vault
under the key the person who just left still holds. So the push path re-seals a
stale payload as it dispatches it, writing the revision back to the outbox first
so that a retry sends the same bytes rather than a fresh envelope. Between the
two, nothing reaches the server under a superseded generation at all. Queued
items are therefore deliberately left alone by the pass: rewriting one there
would overwrite the user's unpushed work with the version the server holds, which
is the one thing a re-keying pass must never do.

Removal runs it last, after a sync — a mirror that is behind produces a batch of
conflicts instead of a re-sealed vault — and the status line distinguishes the two
guarantees, because they are not the same: a vault fully re-sealed is closed to
the person who left, and one with items outstanding is closed only to what
happens next.

Six tests, and three mutations run against them: making the re-seal return the
payload unchanged fails five of the six, making the push path skip re-sealing
fails the queued-edit test and only that one, and counting conflicts as applied
fails the write-elsewhere test. One of the six was wrong before it was right — it
modelled a third-party write by re-pushing an existing payload at a bumped
version, which no real client would do, and it took reading the AAD to see that
the test was lying rather than the code.
2026-08-04 10:18:14 +02:00
jaap-jan cba6f435e9 Merge branch 'claude/vault-creation-sharing-62c0b6'
ci / build and test (push) Successful in 1m38s
ci / android head (push) Failing after 9s
ci / api image (push) Successful in 28s
# Conflicts:
#	README.md
2026-08-04 10:10:40 +02:00
jaap-jan 89f1f07992 Merge branch 'claude/main-page-group-hierarchy-3a3210' 2026-08-04 10:10:19 +02:00
jaap-jan ebb88c8ae4 Give the phone both pickers, and settle who signs the APK
The files screen could browse a remote and delete on it, and that was all: there
is no browsable local filesystem on Android for a second pane to show, so the
gesture the desktop is built around — choose on the left, press the arrow — has
nothing to stand on. What replaces it is the platform's own two pickers. ADD
FILES is ACTION_OPEN_DOCUMENT, so a document is pointed at wherever it lives and
goes to the directory showing; SAVE FILE is ACTION_CREATE_DOCUMENT for the
selected row.

Both stage through the application's cache, and that copy is a requirement
rather than a shortcut. android-port.md predicted a picked document would be a
third IRemoteFileStore beside SFTP and S3; it cannot be. FileTransferQueue seeks,
because an upload resumes from the byte the last attempt reached, and a
content:// URI has no path behind it, no length worth trusting, no promised seek
and no grant that survives the document being edited underneath it. Copying
first costs one class in the head and nothing at all in the shared layers, where
the alternative was every resume rule rewritten around a stream that cannot
rewind. The copy is deleted when the transfer completes, kept while it is stopped
so RESUME still has something to read, and swept at the next launch — which is
the one moment emptying that directory is provably safe, since nothing has
queued anything yet.

Coming out had a decision going in did not: when to ask where it goes. The save
picker is raised before the transfer, so the download runs into the same staging
directory and hands its bytes to a callback the head supplied, held against the
transfer id so a RETRY still lands where the person pointed. Asking afterwards
would put the picker minutes from the button that caused it and, on a phone,
usually while the application is backgrounded and Android will not show one at
all. The cost is that the picker creates its file when it is dismissed, so a
download that then fails leaves an empty one there; that is said on the screen,
in the README and in the manual checks rather than left to be discovered. A
delivery that fails keeps the staged bytes for the sweep instead of throwing away
the one copy of something just fetched over somebody's network.

The foreground service counts transfers now, which is the half of it that
matters most here: a shell survives backgrounding because somebody is looking at
it, and an upload has to survive precisely when nobody is. Queued counts as
active, so putting five files in and locking the phone moves five files. The
seam was built for this and wired to () => 0 because nothing could fill the
queue.

Alongside it, ADR 0010 answers the second question android-port.md left open,
and it had to be answered before the first release rather than at upload time: a
new Play app must use App Bundles and therefore Play App Signing, and an
installed app can only be updated by a package signed with the same key, so the
first release picks an identity for good. The project holds the key, offline and
never in CI — the workflow's package step now says so where somebody would break
it — and a DodoSSH deployment never serves the client, because a download link on
your own server hands the binary that holds the plaintext to the party the whole
threat model is about.

The README's M1 gap note was stale in both halves and is replaced by what is
actually true: credentials have an editor and a REMEMBER tick, and the device key
registers into the TPM under a CNG policy that makes the consent dialog a
condition of using it. What is left is the floor rather than a gap — no TPM, or
no Windows, means the passphrase on every launch.
2026-08-04 10:07:16 +02:00
jaap-jan 2a56ae3efb Put a host inside the group it is filed under, rather than beside it
The group cards were headings with a navigation gesture bolted on. Opening one
narrowed the grid to its hosts, but the level above already held every host in
the keychain — so a card could only ever subtract, filing something changed
nothing but a chip, and a keychain with forty machines was forty cards however
carefully anybody had arranged them. The trail said ALL HOSTS and meant it.

So the grid holds one level of the tree, the way a directory pane holds one
directory. A host filed under a group is inside that group and is not also on
the screen the group's card sits on; the outermost level is what nothing has
been filed into. A group is a place now, and the cards, the trail and the drop
target were already the vocabulary for saying so.

The find box is the exception and had to be one. Typed into, it searches the
open group and everything under it, which from the outermost level is every
machine in the keychain. A box scoped to the level it was typed on would answer
"no host matches that" about a host this keychain has got, and finding a machine
without first remembering where it was filed is most of what the box is for.
The accent chip on a card is what tells a searched-up host from one that lives
at this level.

Two things came free with the change and are handled rather than left.

The phone would have broken. Its list binds SidebarRows, which was a projection
over VisibleHosts, and it has no group cards and nowhere to open one into — so
level-scoping would have left it drawing only the hosts nobody had filed.
RebuildSidebarRows takes its own pass over the hosts now, narrowed by the vault
switches and the box and by nothing else, which is the whole tree flattened
under headings: exactly what it drew before.

And anything created inside a group disappeared the moment it was saved. The
host editor opens on the group the screen is about rather than only on a
selected card, and + NEW GROUP defaults its parent to the group that is open.
Deliberately not the selected card there: a highlighted card is what EDIT and
DELETE are aimed at, and reading it as "and the next group goes inside it" would
nest one because somebody had clicked something. The host editor takes both
because it always took the selection, and its picker shows the answer before
anything is written.

Dropping a host on a group card now takes the card off the grid, and the
selection goes with it — Connect, Edit and Delete all read that property and
none of them should be aimed at a card that has left the screen. The status line
is what says where it went, which is why the manual check now asks for it to be
read. Coming back out through the editor lands the host on this level again, and
there the selection survives.

An empty grid has two more things it can say: that every host is filed away,
which the level-at-a-time grid made reachable and which is not the same sentence
as "there are none", and that nothing under this group matches what was typed —
with ALL HOSTS named as the way to widen it.

Not changed, and next door: a group card counts the hosts filed directly under
it, so a group holding only subgroups reads "0 hosts". That was already true and
is more visible now that its subgroups' hosts are not spilled onto the level
above.

1522 tests pass. Four are new — the level rule and the phone's flat list
asserted together, the box reaching two levels down, the all-filed sentence, and
where a host and a group made inside a group end up. MovingAHostToAGroup was
asserting the old outcome and is rewritten rather than adjusted: it held that
the host stayed selected, and what it holds now is that the host leaves the
level it came from.
2026-08-04 09:51:55 +02:00
jaap-jan d5b1a73182 Move the keys when a membership changes, not just the flag
Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.

The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.

The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.

What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.

Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
2026-08-03 23:05:40 +02:00
jaap-jan e82a25c912 Merge branch 'claude/vault-creation-sharing-62c0b6'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 23s
2026-08-03 21:53:09 +02:00
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 aa06868b1e Add the member the directory cannot see, rather than inviting them
ci / build and test (push) Successful in 1m30s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 33s
ADD MEMBER quietly issued an invitation instead of adding anybody, for everyone
who had signed in here and not yet enrolled. The screen told them that address
had no account, the members list did not change, and the person only actually
joined on the next hourly sweep.

The client decided whether an address had an account by asking the public-key
directory, and the directory answers a narrower question than that. It drops
every account with no current key — deliberately, because an entry exists to be
wrapped to and one carrying no key is a check a caller forgets exactly once. An
account exists from its owner's first authenticated request and publishes
nothing until they choose a passphrase on their own machine, so every account is
missing from the directory for that whole window and some indefinitely. A miss
there is not an absent account, and reading it as one was the bug.

The server would have taken the add. TeamService.AddMemberAsync only requires
the account row, and TeamMemberSummary.IsEnrolled exists precisely so a member
with no key can be listed — added on Monday, enrolled on Tuesday. The client
never asked.

So the directory is still asked first and the miss is retried as an add by
address, and only a server saying there is no such account reaches the
invitation. AddTeamMemberRequest gained an Email used when UserId is empty. The
lookup-first ordering is kept because it is load-bearing for sharing and not for
this: the key verified before a vault key is wrapped is the one the lookup
returned, and nothing is wrapped by adding somebody. That is why resolving the
address server-side is safe here and would not be there.

NoSuchAccount is its own code rather than folded into InvalidTeam, because it is
the one add failure the caller can act on unprompted — there is nobody to add,
so invite them — and a code shared with a rejected role would leave them
guessing which had happened. It does answer whether an address has an account
here, which CreateTeamInvitationRequest deliberately does not. That is the
property traded for the fix; the exposure is bounded by the admin check the add
already needed, and it is the same fact the member list shows a moment later.
Adding by a user id that does not exist now answers 404 no-such-account rather
than 400 invalid-team, and nothing depended on the old pairing.

The two silent returns are gone. Offline and no-team-selected set nothing and
returned, so those failures were visible only as a flicker of the busy flag —
which reads as a button that does nothing at all. The success line reads the
enrollment flag too, because pointing an unenrolled member at SHARE KEY is
pointing at a button that will refuse; their row already says it holds no key.

Why nothing caught it. FakeVaultServer had one list, so it could not tell an
account that does not exist from one that exists and has not enrolled — the
distinction this whole path turns on — and every account it knew was enrolled by
construction. It grows an accounts list beside the directory and reports
IsEnrolled from whether the directory has them, rather than hardcoding true. The
regression test asserts Invitations is empty, which is what fails against the
old behaviour. Four tests: that pair in the shell suite, and in the API suite an
unenrolled account added by address after its own directory lookup comes back
empty, and an unknown address refused under the new code. 1495 tests pass.
2026-08-03 17:04:03 +02:00
jaap-jan 1f2607cc9a Merge branch 'claude/group-double-click-breadcrumb-7bf3f8'
ci / build and test (push) Successful in 1m18s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 22s
2026-08-03 16:32:27 +02:00
jaap-jan 01d54ba4fa Put the trail above the GROUPS label rather than under it
It was drawn as the transfers screen's is, under the heading of the list it
belongs to. That is right there and wrong here: over there a path describes the
one pane beneath it, and this one describes two. Opening a group narrows the
host grid as well as the row of cards, so a trail sitting under GROUPS reads as
one more fact about the groups while it is also the reason the grid at the
bottom of the screen is showing eleven machines instead of forty.

So it is the first thing in the section, and both headings below — GROUPS and
HOSTS — belong to it. Markup only: the trail moved, the note on it says why it
is not placed like its sibling, and the group buttons' note now points up rather
than down. 85 layout tests pass, including the one that presses a rendered crumb
and would have caught the $parent binding failing to resolve from its new place.
2026-08-03 16:32:09 +02:00
jaap-jan 68600dea07 Merge branch 'claude/fingerprint-enrollment-support-a6c2f3'
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 36s
2026-08-03 16:31:46 +02:00
jaap-jan fd6c4f5155 Merge branch 'claude/host-detail-pane-design-e98621' 2026-08-03 16:31:43 +02:00
jaap-jan a2c56de1f2 Read the selected team once per reload, so its owner is listed once
Creating a team drew its owner twice. ReloadAsync rebuilds the team list and
then reselects, and the selection handler answers that assignment by starting
its own read of the members, invitations and vaults — while ReloadAsync is
awaiting a read of exactly the same thing. Both clear the collections up front
and both append when their round trip answers, so everything below the team list
was drawn twice. On a team nobody has been added to yet, whose only member is its
owner, that read as the owner being in the team twice.

The rows are records, so the reselect only raises a change when something about
the team has actually moved — which is every reload that follows a change:
creating a team, adding a member, renaming one. A plain refresh looked fine, and
the screen doubled precisely after the acts people come to this screen to
perform.

isReselecting suppresses the handler for the length of the assignment rather
than deduplicating rows afterwards, because only one of the two reads is
awaited. A command that reloads and then reads Members has to be looking at the
reload's own read and not at a fire-and-forget one that may not have answered.

The generation counter is the other half, and it is a different bug with the
same cause: selecting a second team before the first has answered leaves two
reads in flight against the same collections and nothing decides which wins, so
team A's members could land in the list under team B's name. A superseded read
now drops its answer instead of appending it.

Why nothing caught this. The fake server answers from memory, so every read
completes before the next begins and the appends can never interleave — the
duplicate needs a round trip to hold two reads open at once. FakeVaultServer
grows a MemberReadGate for that, and the new test holds a read open and counts
the reads in flight, which is the only moment a second one is distinguishable
from the first. It fails against the old behaviour with two. 237 tests pass in
the app suite and 83 in the layout suite.
2026-08-03 16:26:52 +02:00
jaap-jan e3dfe5c371 Make the host pane something you ask for, and draw it as cards
THE DRAWER USED TO ARRIVE WITH THE SELECTION. IsDrawerOpen read "a host is
selected", so touching any card took 304 pixels off the grid — including every
card arrowed past on the way to the one somebody wanted. Choosing among forty
machines was charged the price of the pane for one of them. A pencil now appears
on the card under the pointer and on the selected card, and that is what opens
it; IsHostPaneOpen is the flag, and the grid's context menu gained Details… so
the pane is reachable without a pointer, which a hover-only control is not.

Once open it follows the selection rather than pinning the host it was opened
on: a pane about one host beside a grid marking a different one is two answers
to the same question. Losing the selection closes it and clears the flag, or a
filter matching nothing would leave the pane armed to spring open again on the
next card merely selected — which is the behaviour the pencil exists to remove.

The pencil is drawn over the card rather than in a column of its own. A column
would have cost the name 30 pixels of a 232-pixel tile, permanently, for a
control that is only there while the pointer is; the dot and the pencil stack in
the two corners of that edge instead. IsVisible and not opacity, because a
button at zero opacity still takes the click and the card underneath does not.

A HEADER, A BODY THAT SCROLLS, AND A FOOTER, which is the one structural change
in the pane. The header names what the drawer is about and carries the two
things true of every panel; the footer carries the one thing each panel is for —
CONNECT, or SAVE, or the question about deleting. Only the middle scrolls, so
the button somebody came here to press can no longer be below the fold, which
CONNECT could be on a host with fifteen tags. That also widens what the layout
harness certifies: it skips anything inside a ScrollViewer, and the control each
panel exists to offer is now outside one.

THE SAME THREE CARDS TWICE. Address, General, Connection — first as rows stating
what the host is, then as boxes for changing it. The detail pane's rows are
buttons that open the editor: the design draws every fact as a filled box, and
rather than draw an input that refuses the pointer, pressing one leads to the
same card with a real box in it. Nothing here saves as you type, and that is not
timidity — saving validates the key-or-credential exclusion and writes one
encrypted payload, so a box committing per keystroke would be a save per
character and a half-typed hostname on the wire.

Every value the pane prints is the resolved one, and says "inherited" beside it
where a group supplied it. The number is the same either way and the edit is
not: clearing a group's default moves every host that never overrode it.

A HOST CARD IS TWO LINES AND NO CHIPS. The subtitle is now "ssh, root, pci,
eu-west-1" — the transport, the resolved account, then every tag — replacing
both the user@host:port line and the wrapped row of tag chips under it. The
address went to the card's tooltip rather than nowhere: a card is read while
scanning forty machines, where the name and the kind of machine are what is
being looked for, and an address is what you read once you have found it.

"ssh" is a constant today and is printed anyway, which is the one thing here
that argues with this codebase's own rule about constants dressed as readings.
It is the first item of a list whose other items vary, and a list beginning with
the account on one card and a tag on the next has no shape to scan. The remark
on HostRowViewModel.Summary says so rather than leaving it to be discovered.

WHAT THE DESIGN DRAWS AND THIS PANE HAS NOT GOT: Share this host, Add Telnet,
"SSH ID, Certificate, FIDO2", the backspace-key mapping row, the vault picker's
chevron and Show more. Sharing is per vault and not per item, every session here
is an SSH channel, there are no identity or certificate item types, nothing
carries a terminal setting to the renderer, and an item cannot be moved between
vaults at all. Six controls with nothing behind them, listed in
docs/design-import-gaps.md with what ships instead, and none drawn disabled.

The credentials row is marked with ◆ rather than the ⚿ the nav rail uses for the
keychain. U+26BF is outside both faces this application substitutes for the
design's fonts, so it lands on whatever the platform's fallback has; every other
glyph in the pane is from Geometric Shapes, which both carry.
2026-08-03 16:26:43 +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 fd8497bb76 Merge branch 'claude/sftp-s3-connection-ui-b0730f'
ci / android head (push) Failing after 5s
ci / build and test (push) Successful in 1m23s
ci / api image (push) Successful in 24s
2026-08-03 15:51:19 +02:00
jaap-jan 61139bd469 Move the SFTP and S3 connection into the right-hand pane
THE CONNECT BAR IS GONE. It was a 44-pixel strip across the top of the file
screen holding a heading, a picker, a password box and a button — chrome
spanning both panes in order to configure one of them, drawn at full width
whether or not anything was ever going to be connected. Underneath it sat a
pane that was empty for exactly the same reason, saying so in a sentence
nobody had to be told twice.

So the pane says it instead. Disconnected, the right-hand half is an invitation
where the listing would be: what the screen is for, what pressing the button
costs, and one thing to press. SELECT HOST opens the picker in place — the same
combo, the same password box, the same CONNECT — and the sentence and the
button go away with it, because by then they have been read or they have not.

Two steps rather than a picker sitting open, and that is not decoration. The
reason the pane is empty is the question, and a combo box in the middle of it
is a form with no question above it. The two steps also keep the panel inside
its budget, which is the pane's height less whatever the queue has taken — 268
pixels with three transfers on it. Neither shape reaches the floor, and the
layout suite measures both.

IsChoosingRemote is cleared by everything that changes what the picker would be
picking: connecting, disconnecting, moving between the SFTP and S3 tabs, and
losing the vault. The last two are the ones that would rot quietly — a picker
surviving a hop to the other tab offers hosts on a screen showing buckets, and
one surviving a lock offers a list that has just been emptied because its rows
carried decrypted secrets. CANCEL takes the typed password with it, which is a
secret nobody asked to keep.

WHERE THE TWO CONNECTED FACTS WENT. The address chip and DISCONNECT are a strip
of their own inside the remote pane, above the listing — not three more cells in
the header beside UP, REFRESH and DELETE. That pane is 381 pixels wide at the
window's minimum and a fourth control in that row would have pushed one of the
three off the edge; the number is written into the markup so the next thing
added to either row is measured against it rather than tried.

The status line did not fit there either. What is left after a 170-pixel address
and a DISCONNECT is about eighty pixels, which turns every sentence into its
first word and an ellipsis, so while a session is open it is in the queue's own
strip at the foot of the screen, which spans the window. The other half of the
time it is inside the invitation, beside the button that provoked it. One home
in each state rather than two homes in one and none in the other.

The header label reads HOST or BUCKET now, which is the only thing on the screen
naming the kind — the bar that printed SFTP or S3 is gone and the tab in the
strip says it either way, whether or not this screen is showing.

The opening status text was "Choose a host and connect to browse its files",
which the invitation now says in a heading, a sentence and a button. It is
"Nothing is open yet.": a state rather than an instruction. That string is
shared with the phone, where it still reads correctly under the picker card
that head shows directly.

Desktop only, and the phone is unchanged rather than merely untouched. Its
FilesScreen is one pane at a time, so the picker *is* what it shows before a
connection exists; it binds none of the new members.

Four tests. Three in the layout suite — the picker open over a full queue, which
is the tall shape and the one that has to be measured with the panes at their
least; the bucket picker, which is a row shorter because an object store has
nothing to type; and a session open with a long enough address to prove the chip
gives way before DISCONNECT does. The fourth is the picker's lifecycle in
ShellFlowTests, over all four things that put it away. 302 tests pass across the
two suites.
2026-08-03 15:50:31 +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 52f7226ac2 Merge branch 'claude/connection-host-default-text-9e501d'
ci / build and test (push) Successful in 1m19s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 37s
2026-08-03 15:01:23 +02:00
jaap-jan 9801a744ba Merge branch 'main' into claude/angry-cray-f3d496
# Conflicts:
#	README.md
2026-08-03 14:58:57 +02:00
jaap-jan 7343c717da Bring the last three outlined marks over to the filled tile
ServerScreen, LockedScreen and PendingScreen were the only places left drawing
v1: a 44px square outlined in the accent with >_ in the accent inside it. Every
other place the mark appears — both headers, the desktop titlebar, the launcher
icon and the .ico — draws it filled.

The radius is derived rather than chosen. The mark is 6 at 20 and 8 at 26, a
third of a unit per unit of tile, which puts 44 at exactly 14 — and 14 is
already on Phone.axaml's radius ladder as the one for a block of monospaced
output, which is a fair description of a box with >_ in it.

PendingScreen takes the shape and not the colour. It is the screen that says a
surface is not built yet, and the accent is the shell's one "do this" colour; a
tile in Raised with the glyph left at TextFaint keeps the mark recognisable
while the screen goes on looking as inert as it is.
2026-08-03 14:56:40 +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 99d3ab386f Merge branch 'claude/connection-host-default-text-9e501d' 2026-08-03 14:56:07 +02:00
jaap-jan 5800a4bbd0 Give the keychain the DELETE its confirmation was already written for
The counted confirmation has been on this screen since the phone got its
screens, and nothing could raise it. PendingDeletion is armed by
DeleteSelectedItemCommand and its siblings, and the only Delete bindings
anywhere on this head were the files screen's remote delete and the snippets
one — so the card, and the header comment calling it structural, described a
question the phone could not ask. The button is what was missing rather than
anything behind it: the command, the counting and the wording were all already
shared with the desktop.

It sits in row 2 beside that confirmation rather than in a bar at the foot of
the screen, which is where HOSTS and FILES put what to do with the chosen thing.
ShowsItemActions is SelectedItemIsEditable and not-already-asking, so sharing
the row makes the question take the place of the button that asked it, and
DELETE cannot be pressed a second time underneath its own confirmation. A button
at the bottom and its question at the top would be one exchange drawn twice.

DELETE alone, without the EDIT the desktop's detail pane offers beside it,
because there is nothing on this head for EDIT to open. That is also why the
bar names the item rather than trusting the highlight: it is the only thing on
screen saying which row is about to go.

And the highlight had to be made to exist. HOSTS and FILES fill the selected
ContentPresenter, which works because their rows are bare Borders; a keychain
row is Border.card, and the card paints an opaque Sidebar over it. The selector
reaches the card itself instead, in the same Active the chosen host wears, so
the row a button now acts on looks different from the ones it does not.

The TAGS comment said renaming and deleting happened here. Half of that is true
as of this commit and the other half is not — a tag is still renamed on the
desktop, because no kind of item opens an editor on this screen.

The README's paragraph about what the phone is missing said there was no host
editor, which stopped being true when the floating + and both editors landed. It
now names what is actually absent: no local pane for file transfer, no keychain
editor, and no screen at all for pins, teams or import.
2026-08-03 14:54:19 +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-jan d6cd8825fc Let the launcher icon catch up with the mark v2 already drew everywhere else
TitleBar and PhoneShell have drawn >_ knocked out of a solid accent tile since
v2, and both say in a comment that the launcher icon draws the same thing so the
three cannot drift. The launcher was still v1: an outlined square in the accent.
The comments are true now.

On Android the tile is the background layer rather than a rounded rectangle in
the foreground, because the rounding a launcher applies is its mask. Drawing the
tile would put a second rounded shape inside the mask, clipped at the corners on
any device whose mask is not the one it was drawn for; leaving it to the mask
gets a squircle on one device and a circle on another for free. Windows has no
mask, so dodossh.ico still draws its own corners.

The monochrome layer keeps only the glyph. A themed icon drops the background,
which is where the tile now lives, and a layer filled to the edges to stand in
for it would tint to a featureless square.

The glyph is half the width of the safe zone, taken from the two headers rather
than invented -- both set >_ a little under half the tile across. It is also
markedly more legible small than the outline was: dark on accent at 16px is a
mark, where a sub-pixel hairline was a smudge.
2026-08-03 14:45:18 +02:00
jaap-jan 0d239bcd6c Merge branch 'claude/adding-hosts-phone-docs-e472b0'
ci / build and test (push) Successful in 1m16s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 45s
2026-08-03 14:41:18 +02:00
jaap-jan 84b198bda0 Merge branch 'claude/host-password-persistence-2c2c1f' 2026-08-03 14:41:13 +02:00
jaap-jan 718c6b11bd Merge branch 'claude/connection-host-default-text-9e501d' 2026-08-03 14:41:10 +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 b86ff6158b Keep the password that just worked, so the host stops asking for it
The vault has held credentials and host bindings since they landed, and the
only route from a typed password into one ran through two screens: add a
password under Keychain, open the host, bind it. The password box's own tooltip
instructed people to do exactly that by hand — which means typing the secret a
second time, into a screen that has no idea which host it is for, while the
screen that does have the password is the one being left behind. A tick beside
the box does it in one step.

What it produces is an ordinary CredentialSecret, named after the host, bound
through CredentialId with AsksForPassword cleared beside it. That is the whole
reason nothing else here had to change: it syncs, it merges, it appears in the
keychain, it can be renamed and deleted, and it can be bound to the other
nineteen machines that share the account. A password field on HostSecret would
have been a payload schema bump, a fourth place a secret lives, and a copy per
host to rotate and forget.

It waits for a handshake the remote accepted, and that is not caution for its
own sake. Binding on the keystroke would store whatever was in the box —
including the typo about to be refused — and the host would then stop asking,
leaving a machine nobody can connect to until they work out that the keychain
is where the wrong password now lives. For the same reason the password is read
from the credential as dialled rather than from the box, which stays typeable
throughout a handshake.

Off by default, because the typed box exists precisely for the passwords that
should not be in a synchronised vault — a one-off on a machine somebody will
never open again. The credential is written into the host's own vault rather
than the active one: in the personal vault, bound to a team's host, it would be
a binding every other member can see and none of them can resolve.

One thing is given up knowingly. The confirmation naming where the password
went is replaced a moment later by the auto-sync's own count, exactly as
SaveHostAsync's "Saved 'x'" is; this follows that rather than becoming the one
write that suppresses a sync line. The feedback that lasts is the row answering
"credential" and the box disappearing — which is also the only way to store a
password on the phone at all, since that head lists credentials but has never
had an editor to create one in.
2026-08-03 14:40:25 +02:00
jaap-jan c7c844f7bd Give the desktop head the mark the phone has carried all along
The window had no icon and the executable had no icon, so Windows drew its
generic one in the taskbar, in alt-tab and in Explorer, next to an application
that draws its own titlebar precisely so that nothing about it looks borrowed.

The geometry is the launcher vector's, mapped so the middle 72 of its 108-unit
viewport fills the canvas: that 72 is what an adaptive icon actually shows, and
rendering the full 108 would draw the mark at 39% of the width. Nine sizes,
because Windows asks for several and downsamples unkindly when one is missing,
and a floor of one pixel on the strokes so 16px is a mark rather than a smudge.

Windows takes an .ico and nothing else, so the raster the phone does not need
exists here. dodossh-icon.ps1 sits beside it and draws it from those same
numbers, so the two heads still have one geometry between them.
2026-08-03 14:34:39 +02:00
jaap-jan 8da58db5ce Merge branch 'claude/host-connection-top-bar-25d04e'
ci / build and test (push) Successful in 1m23s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 23s
2026-08-03 14:33:52 +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 6fe2b21781 Point every build at the hosted server, not just the ones that ship
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 16s
ci / android head (push) Canceled after 0s
The default was split on DEBUG so a clone would offer localhost and only an
installed build would offer ssh.dodotech.cloud. That protected development
launches from enrolling a device against production. It also meant the address
in the box depended on how the binary was built, which is not what was wanted.

Working against a local API now means typing http://localhost:5233 by hand.
2026-08-03 14:27:01 +02:00
jaap-jan 416f233657 Let the lock screen wear the theme instead of its own copy of it
ci / build and test (push) Successful in 1m13s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 23s
Four controls on this screen carried a full set of metrics each, and three of
them were a shared class rewritten by hand — UNLOCK is Button.primary down to
the font weight, the fingerprint button is Button.secondary, the passphrase box
is TextBox.field. Copies drift, and every difference between these and the
originals was drift rather than intent: corner radii of 6 from the first design
where v2's ladder says 9 for a button and 11 for a field, a label half a point
small, and the missing content alignment that put UNLOCK's word against the top
of its box and started this.

Two things fall out that are worth naming, because neither was the reported
fault. UNLOCK now has the pressed and disabled states the class carries, and
before it had neither — pressing it ran Fluent's default, which replaces the
accent fill with a theme grey, so the one button this application most wants to
look deliberate flashed the wrong colour under a thumb. And the passphrase box
is 44 rather than 48, which is what every other box on the phone is; the four
pixels bought it nothing that the button under it does not already give the
thumb.

RESET keeps two properties of its own against Button.danger, and they are the
two that carry meaning. That class is a DELETE beside a KEEP, where the red
outline says which of two offered actions costs something. Here there is no
pair, and a bordered red button under UNLOCK reads as the next thing to try
rather than as the last resort the screen means it to be.

The live-sessions disclosure is not a class anywhere — warn cards are plain
borders on all three screens that draw one — but its radius was 6 against the
12 the other two use, so it joins the ladder without joining a class.
2026-08-03 14:08:30 +02:00
jaap-jan 6fb82a111e Sit UNLOCK's word in the middle of the button it is drawn on
A button's content alignment defaults to Stretch and the Fluent template binds
it straight through, so a text block filling a 48-pixel button draws its line
along the top edge of it rather than across the middle. That is why all seven
button classes in Phone.axaml set VerticalContentAlignment, and it is why this
one did not: the lock screen is the only place on the phone whose buttons carry
their own metrics rather than a class, and it was written with the horizontal
half of the pair and not the vertical.

Its two neighbours are unaffected and not for the same reason, which is worth
knowing before this is read as a pattern: RESET has no fixed height and a
symmetric vertical padding, and the fingerprint button's content is a panel
that stretches and centres its own children. Only the one with a bare string in
a fixed-height box shows it.

Left as its own set of metrics rather than folded into Button.primary, which it
otherwise duplicates down to the font weight. The two differ in corner radius —
6 here against the 9 the v2 ladder gives a button — and changing that is a
design decision rather than a fix to the thing that was reported.
2026-08-03 14:04:58 +02:00
jaap-jan ce86a4ff72 Look the panel up, because the field the generator declares is never filled
ci / api image (push) Successful in 34s
ci / build and test (push) Successful in 1m17s
ci / android head (push) Failing after 5s
The last commit crashed the phone before its first frame. `PhoneShell` took the
`Body` field that Avalonia's name generator declares for `x:Name`, and that
field is assigned in the generated `InitializeComponent` — which no view on
this head calls, all of them loading their XAML directly instead. It compiles,
it is null at run time, and this control dereferences it in its constructor.

`TerminalScreen` has been looking its two named controls up with `FindControl`
since it was written, for exactly this reason. This does the same, and the
remark on the field now says why so the next person reaching for the shorter
spelling finds the answer before the device does.

Confirmed rather than guessed: the generator's output for this control is one
method, and the assignment is inside it.
2026-08-03 14:01:07 +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