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>
This commit is contained in:
2026-08-03 12:42:43 +02:00
co-authored by Claude Opus 5
parent 4925dcf179
commit 16e0051e89
13 changed files with 1253 additions and 35 deletions
@@ -170,6 +170,25 @@
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
</StackPanel>
<!--
The design's tag chips, and the row is where they were always meant to be — what a machine
*is*, under what it is called and where it answers. Wrapped rather than trimmed: a host
wearing four tags on a 360dp row takes a second line, which is the honest shape.
-->
<ItemsControl ItemsSource="{Binding TagLabels}" IsVisible="{Binding HasTags}"
Margin="0,2,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="tag" Margin="0,0,4,3">
<TextBlock Text="{Binding}" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!--
@@ -252,6 +271,40 @@
</ComboBox.ItemTemplate>
</ComboBox>
<!--
◆ The tags this host wears. Chips that toggle, matching the chips on the row behind this card —
a list of names to tick would make the user match an entry to a chip they can see.
The box under them creates one and puts it on straight away, which is where a tag is usually
wanted: while tagging a host and finding it does not exist yet. It writes to the keychain
immediately, unlike every other field here, because a host can only name a tag that has an id —
so cancelling this editor leaves the tag behind. Renaming and deleting are on the keychain
screen, with every other item kind.
-->
<TextBlock Classes="label" Text="TAGS" Margin="0,4,0,0" />
<ItemsControl ItemsSource="{Binding EditorTagChoices}" IsVisible="{Binding HasTagChoices}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagChoice">
<Button Classes="chiptoggle" Classes.worn="{Binding IsWorn}" Margin="0,0,6,6"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).ToggleEditorTagCommand}"
CommandParameter="{Binding}">
<TextBlock Classes="mono" FontSize="11.5" Text="{Binding Label}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Classes="field" Text="{Binding EditorNewTag}"
PlaceholderText="new tag" />
<Button Grid.Column="2" Classes="secondary" Height="44" Width="72" Content="ADD"
Command="{Binding AddEditorTagCommand}" />
</Grid>
<!--
◆ The one control here that publishes something. Turning it on copies this host's address and
port into plaintext columns the server can read, which is the single deliberate concession in
@@ -60,6 +60,20 @@
</StackPanel>
</RadioButton>
<!--
Tags. The one category holding nothing secret — a tag is a name — and here because renaming one is
one write instead of twenty, and a rename needs somewhere to happen. Making one usually happens in
a host's editor instead; this is where they are renamed and deleted.
-->
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsTags, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Tags}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="TAGS" />
<TextBlock Text="{Binding Tags.Count}" />
</StackPanel>
</RadioButton>
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsBuckets, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Buckets}">