Skip to content

feat(gui): the UI explains itself - #50

Merged
Behnam-RK merged 19 commits into
mainfrom
feat/ui-explains-itself
Aug 23, 2026
Merged

feat(gui): the UI explains itself#50
Behnam-RK merged 19 commits into
mainfrom
feat/ui-explains-itself

Conversation

@Behnam-RK

Copy link
Copy Markdown
Owner

Stacked on #49. Review that one first; this PR's diff is against it.

Three changes to one problem: controls that ask the reader to already know what they do.

Per-key documentation anchors

The ? beside a setting already deep-linked, but internal/config/schema.go defined only four anchors — #fields, #control-block, #vpn-block, #advanced-tunables-vpnadvanced — shared by roughly forty keys. Every click landed in the same four places, which is why it felt like "always opens the configuration doc".

  • internal/help/markdown.go gives each documented key an id on its table row, derived from the row's leading code span (vpn.redialWindowkey-vpnredialwindow). First occurrence wins: eight keys appear in both the field reference and the presets comparison, and duplicate HTML ids are invalid besides.
  • config.docAnchorFor derives each key's anchor from the key rather than restating forty of them by hand — the same argument the defaults table is built on.
  • TestEveryTunableDocAnchorResolves now accepts row anchors, so a key that loses its documentation row fails the build by name instead of silently degrading into a link to the top of a section.
  • TestKeyAnchorSlugMatchesTheRenderer pins config.anchorSlug to help.KeyAnchor. The two derive the same fragment from opposite ends of the same link and cannot import each other (internal/help imports internal/config to check its own work), so without this a divergence would break every contextual link at once while both packages' tests still passed.
  • The advanced tunables table names its rows in full (vpn.advanced.redialBudget rather than redialBudget), matching the other three tables. This was the only change needed in the docs — the tables are otherwise untouched, which was the point of choosing row anchors over restructuring.

53 unique key anchors in usage/config.md; every Tunable resolves.

Short action titles, one caption line

The Overview's row is now Block / Unblock / Switch VPN… / Pause / Guard down / Panic…, with a single caption line beneath it carrying the sentence each title used to hold inline. It follows the pointer and the keyboard focus and falls back to the posture headline; the same string is still the tooltip, so the two cannot drift.

This matters most for Pause, whose old title "Pause — use my real IP" was the only place that warning appeared. A tooltip alone would not do: it needs a hover and a delay, so the warning would arrive after the decision to click. ActionCaption (in DezhbanCore, with tests) owns the fallback rule, including that the line never goes blank — an empty caption collapses its own height and reflows the row under the pointer.

Two places deliberately keep long titles, for the same stated reason: the menubar dropdown (a menu has no caption line to delegate to) and the guided empty states' panic button (no action row there either).

The (?) tooltip

Was "Read about <label> in the documentation" — a description of the button, wasting the one surface that could answer the question without navigating. It is now the key's own one-line help. The generic phrasing survives as the accessibility label, where it is correct.

Verification

  • go build ./..., go vet ./..., go test ./... — pass.
  • swift test — 197 tests pass (3 new in ActionCaptionTests).
  • build-app.sh produces a bundle whose usage-config.html carries the row ids, and dezhban config schema --json emits e.g. "docAnchor": "usage/config.md#key-blockedcountries".

On-host checks added to docs/contribute/testing.md: that the ? lands on the key's own row (including one under Advanced) and shows the key's help as its tooltip; that hovering Pause puts the real-IP warning in the caption line before the click; that keyboard focus drives the caption too; and that the caption never blanks or changes height.

🤖 Generated with Claude Code

Three changes to the same problem: controls that ask the user to already know
what they do.

Per-key documentation anchors. The (?) beside a setting resolved to one of four
section anchors shared by roughly forty keys, so every click landed in the same
place. The help renderer now gives each documented key an id on its table row
(first occurrence wins, since several keys appear in both the field reference
and the presets comparison), and config.docAnchorFor derives each key's anchor
from the key rather than restating forty of them by hand.
TestEveryTunableDocAnchorResolves accepts row anchors and therefore fails by
name when a key loses its row, and TestKeyAnchorSlugMatchesTheRenderer pins the
two slug rules together across the package boundary they cannot import across.
The advanced table's rows are qualified in full to match every other table.

Short action titles with one caption line. Block / Unblock / Switch VPN… /
Pause / Guard down / Panic…, and the sentence each title used to carry inline
now lives in a single caption line under the row, following the pointer and the
keyboard focus and falling back to the posture headline. This matters most for
Pause, whose old title was the only place the real-IP warning appeared. The
menubar keeps its long titles — a menu has no caption line to delegate to — and
so does the guided empty state's panic button.

The (?) tooltip is the key's own help text rather than a description of the
button, which wasted the one surface that could answer without navigating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Behnam-RK
Behnam-RK force-pushed the feat/ui-explains-itself branch from 142f5ef to 80dc5ba Compare August 22, 2026 07:11
Behnam-RK and others added 7 commits August 22, 2026 11:06
…ide it

First review round on #50, thirteen findings.

The per-key anchor replaced the section anchor instead of joining it, which
broke the reader it was not written for. `config schema` prints the anchor for
someone who will open docs/usage/config.md on GitHub, and row ids exist only in
the HTML tools/helpgen renders — so every CLI user got a fragment resolving
nowhere, to fix a granularity problem only the app had. It also cost the app
its middle step on version skew: a CLI newer than the bundled help found no row
id and fell all the way back to the top of a forty-key reference. Tunable now
carries both, the CLI prints the heading anchor, the app prefers the row and
falls back to the section, and the Go test checks each field against the
anchors that field is allowed to name.

The caption line had four defects, all in the direction of saying something
untrue. It was one truncated line, so every long caption lost its tail — which
is exactly where routineHint appends the password expectation, so Pause read
"Will ask for your pass…" and dropped the warning; it wraps to a reserved
second line now, the same trade the panic caption already made. The hint was
captured at hover-enter and never refreshed, so a daemon going unreachable
under a parked pointer left the caption and the tooltip disagreeing — the one
thing the wrapper exists to prevent. Hover outranked focus unconditionally, so
tabbing with the pointer parked elsewhere moved the focus ring and the Space
key while the caption described another button; focus supersedes a parked
pointer now, and moving the pointer takes it back. And a control replaced under
a stationary pointer never received a hover-exit, so the caption described a
button that no longer existed until the mouse moved.

The Cancel button stopped distinguishing an automatic redial window from a
manual switch window, and hovering it hides the posture headline that carries
that distinction — so the caption names which window it closes.

The ? tooltip was a duplicate: schemaField and schemaToggle already put the
key's help on the control, and schemaToggleWithCaption shows it visibly. It now
says what the button does, which is the affordance a pointer user had lost —
"opens the documentation" survived only in the accessibility label.

Plus stale text: two comments asserting the old design in the functions whose
behaviour changed, two checklist titles a tester would file as failures
("Resume now", "Switching VPN…"), and a CHANGELOG entry omitting the window
controls' renames. The checklist also gained the three cases this round found —
the truncated tail, focus versus a parked pointer, and a caption outliving its
control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second review round on #50, six findings, and the medium is a test I broke
last round.

TestKeyAnchorSlugMatchesTheRenderer read DocAnchor, which after the split is a
*section* anchor and therefore never among the rendered row anchors — so every
iteration hit `continue` and the test could not fail, while its doc comment
claimed to be the pin against exactly the divergence it had stopped checking.
The reviewer demonstrated it by diverging anchorSlug and watching the test pass.

Reading DocKeyAnchor instead was not enough, and the negative test is what
showed that too: gating on "is this anchor in the bundle" skips precisely the
keys that have diverged, since a wrong slug is by definition not among the
rendered ones. The derivations are now compared directly — whether the anchor
exists is the resolution test's question — and a `checked == 0` guard fails the
build if this ever goes inert a third time. Verified by tampering: it now names
blockedCountries and pollInterval.

The Swift fixture had the slug rule wrong in the same direction: it mapped "."
to "-" and asserted key-vpn-endpointrefresh, which the pipeline never emits —
Go drops dots, so the real anchor is key-vpnendpointrefresh. Nothing failed
because the test only exercises decoding and ordering, but the fixture is the
app-side reference for a cross-language contract and it stated that contract
wrongly. It now mirrors the Go rule explicitly.

Focus clearing hover left the pointer unable to take the caption back without
leaving the control and re-entering, because onHover fires only on enter and
exit — which is not what "move the pointer and it takes over again" means, and
that is the step the checklist asks for. onContinuousHover re-arms on movement.

Also: openHelp(docAnchor:) was left with no callers and without the resolution
step the new path exists for, so the shorter-looking overload would silently
restore landing at the top of the page; a HelpIndex comment still attributed row
anchors to docAnchor, which is the collapse schema.go warns against; and the
checklist still asserted the ? tooltip shows the key's help, which this PR
deliberately moved off that button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ming it is

Third review round on #50, four findings.

KeyAnchor's comment promised that prefixing with `key-` made collision with a
heading anchor impossible. It does not, and the counterexample is already in the
tree: docs/usage/cli.md has a "## Key flags" heading, which slugs to `key-flags`
— exactly what a row for a key named `flags` would claim. Verified in-package:
Anchor("Key flags") == KeyAnchor("flags"). Two elements with one id is invalid
HTML and the browser jumps to whichever came first, so the reader lands on the
heading and the contextual link is silently wrong, which is the outcome the
comment called impossible.

Render now resolves it in the heading's favour — that being the anchor markdown
itself generates and the one written links depend on — and *notes* the row
rather than skipping it quietly, so Unsupported fails the bundle build naming
the page. Verified by adding a colliding row: TestBundleBuilds fails with "a
key row and a heading both claim the anchor key-flags". Heading anchors are
pre-scanned because a heading can appear after the row it collides with, while
r.Headings is filled in the same single pass.

The resolution test accepted a DocKeyAnchor that matched a *heading*, which
defeated its own promise: a key whose row was deleted would still pass whenever
some heading slugged to the same fragment — and such headings exist — so the app
would deep-link to a heading instead of the test failing by name. It checks key
rows only now, while DocAnchor keeps checking headings only.

onContinuousHover re-armed on any `.active` phase, and `.active` is also
reported when a tracking area is rebuilt. These controls re-measure constantly —
a window's countdown retitles "Cancel (m:ss left)" every second, re-placing the
row — so a parked pointer could silently reclaim the caption a second after the
keyboard took it, defeating "most recent interaction wins" in precisely the
scenario the new checklist step describes. It compares the reported location and
ignores a report at an unchanged point.

Also the comment above the four section anchors still called them a fallback for
keys documented in prose and pointed at a function this PR renamed — a
description true only of the intermediate design that was reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth review round on #50, nine findings, all low — and the first three are
all last round's `onContinuousHover` fix, which is the signal that it was the
wrong shape rather than nearly right.

The location it reports is in the view's *local* space, so a banner appearing
above the action row moves a control under a stationary mouse and reads as
movement — silently taking the caption back from the keyboard, the exact thing
the guard was added to prevent. `lastHoverPoint` was also never cleared in
onDisappear, so the middle slot swapping identity (Pause → Cancel → Pause) had
the returning control compare against a point captured before the swap and treat
it as movement. And it wrote `@State` on every mouse-move event over any action
control, invalidating the whole Overview body — including ActionRow's re-measure
of five controls — at pointer-move frequency, for a value never read while
rendering.

So the re-arm is gone. Focus supersedes a parked pointer, and the pointer takes
the caption back by *entering* a control, the same one or another. Every version
of "the mouse moved a little" read a proxy for movement rather than movement:
`.active` fires when a tracking area is merely established, and these controls
re-measure every second because a window's countdown retitles them. Jiggling
inside the control you are already on aims at nothing new, so nothing is lost —
and the checklist now says re-entering is what hands it back.

Shortening the titles also cost VoiceOver the explanation, since the caption
carrying it is accessibilityHidden to avoid being read twice — leaving "Cancel"
with no way to know which window it closes. Each control now carries its
sentence as an accessibility hint, which is where a consequence belongs.

The heading pre-scan added last round accepted any trimmed line starting with #,
so `#nospace`, seven hashes, and a shell comment inside a fenced block all
registered as phantom headings — and one that slugged onto a real row's anchor
would refuse that row and fail the whole app build naming a heading nobody can
find. It goes through headingRe outside code fences now, verified against a
fenced "# Key vpn.redialWindow".

Also: a dead `anchors` map in the test, which existed only to be passed back to
the check whose laxity was just removed; three leftover doc lines describing the
deleted openHelp overload's behaviour above the new one; HelpBundle.bundled()
re-read and fully decoded on every ? click, now decoded once and kept; and a
docdrift comment describing the advanced table as using bare field names, which
this PR fully qualified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifth review round on #50, five findings, and the medium is last round's own
fix leaking through an edge the comment claimed it covered.

The heading pre-scan detected fences on the trimmed line but headings on the
raw one, while the main pass matches the trimmed form — and this renderer
honours markdown's up-to-three leading spaces. So "  ## Key flags" rendered as
a heading the pre-scan never saw, the colliding row was claimed anyway, and the
build stayed green with two elements sharing one id: exactly the failure the
guard exists to turn into a named error, and the comment asserted the two
passes agreed. Confirmed by probe before fixing, and pinned by a test that
fails if the indented line ever stops rendering as a heading, so it cannot
quietly stop testing anything.

claimKey's comment promised a key anchors to "its definition". It implements
"the first row on the page wins", which lands on the definition only because
config.md is ordered that way — an assumption about the document that the
function cannot check, and that nothing noticed: a duplicate is refused
silently, because refusing loudly would fail the build on every legitimately
repeated key, and the resolution test passes as long as *some* row carries the
anchor. Move the Presets comparison above the field tables and every ? for
those keys would deep-link to a presets row. The comment now says what the rule
actually is, and a new test asserts each key's first row falls under one of the
definition headings — verified to fail, naming the key and the section it would
land in.

Also three pieces of misplaced prose. The four-paragraph rationale for
openHelp(preferring:) had ended up documenting the cache property inserted
between it and the function, leaving the function undocumented and the
property's own line stranded after it. Two paragraphs inside
onChange(of: focusedAction) both justified the same single statement, the first
written for the mechanism removed last round. And the checklist listed Panic…
among the row's captioned titles, where hovering it changes nothing by design —
a tester following it would have recorded a false failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sixth review round on #50, two findings, both in what I added.

claimedKeys was a set, so claimKey could not tell "the same key documented in
two tables" — benign, and the reason the refusal is deliberately silent — from
"two different keys whose slugs collide", which is a silently wrong deep link.
Anchor lowercases and drops dots, so a rename changing only case
(vpn.armAtBoot / vpn.armatboot) or removing a dot (vpn.pauseMax / vpnpauseMax)
produces one; hyphens survive, so the pair I first reached for was not actually
a collision, which the test told me before the comment did. Since the "Renamed
keys" table precedes the definition tables and lists the OLD name, such a rename
would hand the anchor to the old row and send every ? for the live key there,
with a green build. It is now anchor -> owning key, and a different owner is
reported the way a heading collision already was. Both directions are pinned:
the collision is reported, a legitimately repeated key still is not.

The ordering test added last round used a looser row rule than the renderer:
first-cell code span rather than a *lone* code span. So a row written
"| `vpn.redialWindow` (renamed) | … |" — the natural way to annotate that table
— would have been counted as the key's first row and failed the build even
though claimKey had anchored the definition correctly: a spurious failure on the
one test that exists to catch a silent one. It now mirrors help.rowKey, and it
reads the page out of DocKeyAnchor rather than hardcoding config.md, so a
tunable documented on another bundled page is checked against that page instead
of reported as having no row. Verified both ways — fails on a reordered
reference, stays quiet on an annotated cell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seventh review round on #50, five findings, and the first removes a whole class
rather than an instance.

rowKey fired on any table row opening with a lone code span, on every bundled
page — so concepts/modes.md minted key-1006410 and key-fc007 from its
private-range table and usage/cli.md minted key-panic and key-run from its
subcommand tables, contradicting Rendered.Keys' own description ("one per
documented config key"). Verified by dumping what the renderer actually emits.
The cost was not untidiness: the collision guard aborts the whole app build, and
cli.md already has a "## Key flags" heading, so adding a row named `flags` to one
of its flag tables would have failed `task gui:build` complaining about a key row
on a page with no config keys in it.

A table now defines keys only if its first column is headed "Field". That also
settles definition-versus-summary structurally, which two earlier rounds had
settled by argument: config.md heads its four defining tables "Field" and its
presets and retired tables "Key", so a summary row can no longer claim an anchor
however early it appears. The ordering test stays as a second line — two
Field-headed tables naming one key is the case the renderer still cannot settle —
and its doc comment no longer claims to be the only guard. Verified both ways: a
second Field table moved above Fields fails it by name; a Key-headed table moved
there does not.

That test was also re-implementing the renderer's row rule and getting it wrong
again: it counted any pipe-prefixed line, with no separator row and no header
check, so a pseudo-table or prose line above the definitions would have failed
the build over a row claimKey ignores. It now applies the same two conditions
renderTable does.

Two caption fixes. The resting text was PostureUI.humanPosture, which is exactly
the headline the hero renders in title2 a hundred points above — so in the normal
state, pointer off the row, the pane said "Traffic cut" twice; it is a prompt now.
And Block/Unblock described actions a greyed-out button will not perform, which
mattered little as a tooltip and matters now that it is the row's primary visible
explanation; they say why they are disabled, as the Pause branch already did.

On the advanced table's prose I did not expand the bare names: the table is what
was requalified, its anchors derive from that, and qualifying eight keys mid-
sentence hurts the paragraph. The shorthand is now stated explicitly instead, so
it reads as deliberate rather than as drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Behnam-RK Behnam-RK added the run-ci Run CI on this PR (gates .github/workflows/ci.yml) label Aug 22, 2026
Behnam-RK and others added 11 commits August 22, 2026 14:17
… once

Eighth review round on #50, five findings.

Changing the resting caption to a prompt last round left four places claiming
it falls back to the posture headline: cli.md, the CHANGELOG, ActionCaption's
own API doc, and the manual checklist — where it matters most, since an
operator moving the pointer off the row would see the prompt and mark a passing
build as failed. All four now say what ships, and the checklist also picks up
the disabled-button wording added last round.

switchMenu wrapped each of its two shapes in its own `captioned("switch", …)`.
state.profiles arrives asynchronously, so the Button→Menu swap happens after
launch: the outgoing branch's onDisappear cleared the caption state for an id
the incoming branch owns, and the incoming one gets no hover-enter under a
stationary pointer — so pointing at "Switch VPN…" while profiles loaded dropped
the caption to the resting prompt until the mouse moved, and did the same to a
keyboard user's hint. One wrapper around both shapes means the modifiers never
disappear, only their content changes.

docKeyAnchorFor derived the row's page from the key's *section* anchor, coupling
two things that need not agree: point one key's DocAnchor at a section on
another page and its row anchor named that page too, so the resolution test
failed claiming nothing there carries the anchor — when the key does have a row,
in config.md. The only escape was to declare the key documented in prose, which
is the wrong statement about it. The row page is now named once, where the rows
actually are; verified by pointing a key's section at concepts/modes.md and
watching the row anchor stay put.

Also the menubar item list in cli.md still advertised Block now/Unblock, which
AppDelegate explicitly does not add and says why. Pre-existing, but in the hunk
this PR edits, and it now lists what the menu really contains — including Pause
and hold-the-line, which it had omitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ninth review round on #50, five findings, all low.

Focus outranking a parked pointer was implemented by setting hoveredAction to
nil, which throws the pointer's position away rather than deprioritising it. Tab
into the row and back out to anything outside it and both inputs were empty, so
the caption fell to the resting prompt while the pointer was physically on a
button, until it moved off and back on. ActionCaption now takes which input the
user reached for last and orders the two candidates by it — which satisfies both
directions at once, where clearing could only ever satisfy one. Three tests pin
it, including the parked-pointer-survives-blur case that was broken.

Two leftover parameters. actionCaption still took the Snapshot it stopped reading
when the resting text became a prompt, implying the caption is snapshot-derived
when it is not; Swift warns about neither. And docKeyAnchorFor took the section
anchor only to ignore it — the argument the comment right above it explains must
not be consulted, which left the next edit one `_` away from reintroducing the
coupling that comment exists to prevent.

Two comment blocks had been spliced by successive edits: the claimKey paragraph
ran straight into an orphaned "anchor -> the key that claimed it" with no
declaration between them, and the headingAnchors block ended in a dangling
fragment. Both rewritten as one argument each — and the split
TestKeyRowsAnchorToTheirDefinitionSection is greppable again, which is the point
of naming a test in a comment.

The rewrite also removed two false claims I had left in them. The collision
example cited vpn.pause-max, which does not collide (hyphens survive Anchor;
only dots are dropped) — the real pairs are a case-only rename and one that
removes a dot. And both that comment and the collision test still described the
"Renamed keys" table stealing an anchor by appearing first, which last round's
header gate made impossible: that table is headed "Old name", so it can never
claim one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tenth review round on #50 found no correctness defect in the shipped code —
the first clean pass on this branch. Two documentation findings, both mine.

The manual step verifying that a hovered caption tracks a live hint asked the
tester to stop the daemon. That cannot work: `state.isLive` goes false, Overview
renders its guided "stopped" layout, and the action row and its caption line are
gone before anything could be observed. `routineHint` keys on
`controlIsReachable`, which is the control socket rather than the posture, so
the socket has to be the thing that changes — `control.enabled=false` and a
restart. As written this was the only coverage for that fix and would have been
silently skipped by anyone following it.

The README still advertised Block now / Unblock as menubar items, which this
PR's own cli.md hunk now states they deliberately are not, and which
AppDelegate has never added. Doubly stale, since the Overview button is "Block"
now. It lists what the dropdown actually has, and says where Block went and why.

Also softened the new cache comment rather than letting it overstate: keeping
one decoded HelpBundle is right for resolving a target, but HelpView builds its
own in a @State initialiser, so this is not the app's only decode and the
comment should not imply it is. Sharing the two is worth doing and is not this
change's business.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleventh review round on #50, two findings, both low, and the second is the
cost of round eight's fix.

Wrapping switchMenu once — so the caption modifiers survive the Button→Menu swap
when profiles arrive — put `.focused($focusedAction, equals:)` on the if/else
rather than on the control inside it. `.focused(_:equals:)` binds focus for a
focusable *view*, and a _ConditionalContent wrapper is not one. If it is a no-op
there, tabbing onto "Switch VPN…" leaves focusedAction nil, so the caption shows
the resting prompt for the single control in the row a keyboard user is most
likely to reach — while the checklist asks the tester to confirm focus drives the
caption across the whole row.

I could not confirm on-host either way, so rather than depend on whether it
happens to work, the binding now goes on the concrete Menu and Button inside each
branch, and `captioned` skips its own for that caller. The skip is a ViewModifier
rather than an `if` in the builder, so it does not change the view's identity —
which is exactly what wrapping once was protecting. The id is one constant shared
by the wrapper and both branches so they cannot drift.

Also cli.md still listed Panic… among the row's captioned titles. testing.md was
corrected six rounds ago to say the opposite — that hovering Panic changes
nothing, correctly, since it sits below the row with its own fixed caption — so
two docs disagreed about one behaviour and a tester following cli.md would file
the correct behaviour as a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing it

Twelfth review round on #50, three findings, all in what I added.

The ordering test matched its header with a literal regex on the raw cell, while
renderTable strips inline markup and case-folds. So `| **Field** |`, `` | `Field` | ``
or `| FIELD |` make the *renderer* mint row anchors from a table this test stops
treating as a key table — the guarantee it exists to pin silently stops covering
it, and the checked == 0 fatal does not help because it fires only when every key
falls out, not a subset. It now applies the same rule; verified against a
**Field**-headed table, which the old pattern skipped in silence.

Copied rather than called, deliberately: internal/help's own tests import this
package, so importing help from here would close an import cycle. The comment
says so, since a copied rule is exactly the thing that drifts.

The caption reserved two lines, which was enough at a comfortable width and not at
a narrow one — the pane resizes, ActionRow is built to wrap, and the longest
caption is ~137 characters, so the ellipsis landed back on the password clause
precisely when the window was small. That clause is what the checklist requires to
be readable, so it reserves three now, and the checklist asks for the narrow case
explicitly.

And FocusBinding's comment claimed a modifier avoids the identity change an inline
`if` would cause. It does not — `if let` in a ViewModifier body produces
_ConditionalContent either way. It is safe only because `focusable:` is a
compile-time constant at each call site, which is the actual constraint to keep:
make it state-dependent and the branch swap destroys the wrapper's onHover and
onDisappear state, which is the caption loss switchMenu was restructured to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
My own review pass rather than a reviewer agent's, aimed at the two defect
classes this branch has actually produced: claims that no longer match the code,
and guards that cannot fail.

The app decodes `config schema --json` by field name, and `docKeyAnchor` is read
with decodeIfPresent so an older CLI degrades to the section anchor. That
tolerance is what would make a Go-side rename invisible: every key silently loses
its row anchor, the ? goes back to landing on section headings, and nothing fails
— least of all the Swift test, which builds its fixture by hand. Now pinned, and
verified by renaming each tag in turn: both are caught, 43 keys each.

Written against schemaEntry rather than config.Tunable, because that is the wire
shape — the CLI wrapper embeds the Tunable and adds `preset`. My first attempt
marshalled the Tunable alone, which both missed a name the app decodes and failed
for a reason that was not a defect; running it is what showed that.

Three claims corrected. The CHANGELOG still said the caption "wraps to a second
line", which stopped being true when it went to three reserved lines; it now also
records the most-recent-input rule and the VoiceOver hint, neither of which it
mentioned. actionCaption's doc still called itself one line high. And captioned's
doc comment had been spliced again by the focusable change, running one paragraph
into the next without a break.

The checklist gains the case round nine's fix was for and the list never asked
for: with the pointer parked on a button, Tab *out* of the row and the caption
must return to describing what the pointer is on, rather than falling to the
resting prompt. That was the bug — focus outranks a parked pointer, it does not
erase where the pointer is — and nothing on the list would have caught it coming
back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A parked pointer could take the caption back from the keyboard about a second
after tabbing. `aim = .pointer` was set on every `onHover(true)`, and that fires
when a tracking area is re-established under a stationary mouse — which these
controls cause constantly, since a window's countdown retitles "Cancel (m:ss
left)" every second. So the focus ring and the Space key sat on one button while
the caption described another, breaking exactly what
ActionCaptionTests.theKeyboardWinsAfterTabbing and the checklist promise. It is
the same layout churn the movement re-arm was deleted for, arriving through the
other door; `aim` now moves only when this control was not already the hovered
one.

The wire test I added an hour ago claimed to cover "every name in
ConfigSchema.swift's CodingKeys" and omitted three, all omitempty. The
consequential one is restartReason: rename that tag and appliesLive — which is
`(restartReason ?? "").isEmpty` — returns true for every key, so Settings tells
the user a restart-required key applies live, and nothing fails. The omitempty
names are now asserted per tunable that has a value, both directions, with a
guard that fails if any of them stops being carried at all. Verified: 21 keys
catch a restartReason rename.

Two manual steps could not be performed as written. Opening a switch window "from
the menubar" with the pointer parked on Pause requires moving the pointer to reach
the menubar, which fires the hover-exit that clears the state the step exists to
observe — it now says to trigger it from an already-focused terminal, and adds the
countdown-tick case the bug above would have failed. And the password-clause step
implied waiting was enough; refreshServiceState() is not on the 1-second timer,
which polls the state file and repaints only, so it takes an action sequence. The
comment in the code claimed the same wrong thing and now says where
controlIsReachable actually changes.

Also dropped the menubar half of the passwordless Block/Unblock check: they are
deliberately not in the menubar, which this branch had already corrected in the
README and cli.md while leaving the standing checklist asking for a check that
cannot pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three rounds of review over the branch, and what they found was not the
renderer or the schema wiring but the sentences and the state machine the
shortened titles now depend on.

A contextual help link undid its own scroll. The anchor is one-shot: it is
spent by the navigation it triggered and cleared on the next runloop turn,
which re-evaluates the view and asks for the same page with no fragment a
moment later. That differed from the loaded URL only by the dropped
fragment, so it loaded again and put the reader back at the top of a
forty-key reference. HelpNavigation.shouldLoad now decides this in the
testable target, keyed on the fragment rather than on equality: an anchored
target is an explicit "scroll here" and is always honoured — including a
repeat of the anchor already showing, since the reader may have scrolled
away and clicking the same search hit again is how you ask — while a bare
target naming the page on screen is suppressed.

Unblock described three different releases as one. It is offered while the
guard holds a downed tunnel and while egress is cut by a standing block, and
the daemon's handler branches on AutoArm && !tunnelUp && !standby without
looking at why egress was cut — so a full block over a downed tunnel drops
to STANDBY exactly like the guard case does. "Resumes monitoring" was the
opposite of what happens there. PostureUI.unblockConsequence asks the tunnel
question before the posture question, and claims neither manual nor
automatic for the block itself: postureName derives "full-block" from
blocked alone, so nothing on the wire tells the two apart. The sentences
are also short enough to fit the caption's three-line reservation, which
the first attempt at this overran by 40% — putting the truncation back on
the password clause the reservation exists to protect.

The caption followed a button that moved under a resting hand. The guard
against a re-established tracking area only caught the same control
re-firing; Cancel replacing Pause arrives with a new id, so aim went back
to the pointer while the focus ring sat elsewhere. The signal the earlier
attempt lacked was named in its own comment — it compared the hover point
in local space, which a banner shifting the row makes move under a
stationary mouse. NSEvent.mouseLocation does not, so ActionCaption.hoverIsAim
asks whether the hand went anywhere. A control inserted already holding
focus also seeds its own hint now: onChange does not fire for a view born
matching the value it observes, so Cancel kept Pause's sentence.

Panic lost its explanation when its title was shortened. It is the one
destructive control on the pane and it had neither tooltip nor
accessibility hint, only an adjacent label no pointer user and no VoiceOver
user reaches from the button. It now carries the sentence both ways, with
the visible copy hidden from VoiceOver — the arrangement the action row
already used.

Two fixtures were also lying about the contracts they exist to pin: the
Swift anchor-slug helper still diverged from Go on spaces and non-ASCII
digits, and the Snapshot builder wrote an empty tunnel array for nil, so
the test for an absent tunnel list exercised the empty one another test
already covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pointer-movement guard added last round asked the right question
against the wrong reference. It compared the mouse's position to where it
was at the previous hover event, and `onHover` fires on enter and exit
only — so that reading is the last boundary crossing, not where the hand
actually is. Both directions were wrong because of it. A hand that drifted
a few points inside a control while reading produced no hover event, so the
next control arriving underneath compared against a stale point, read as
movement, and took the caption off the button the focus ring was on: the
exact failure the guard exists to prevent, surviving it. And a fast flick
across the gap between two buttons dispatches the exit and the following
enter from one mouse-moved event, so both sample the same location, which
read as stillness and silently refused to hand the caption back to the
pointer at all.

The reference is now the moment the keyboard took over — the mouse's
position when focus moved, in screen coordinates — which is the baseline
the question was always about: has the hand gone anywhere since? It is
cleared once the pointer proves it has, so a later entry that happens to
land on the same pixel is not swallowed.

Opening the Help pane on a deep link also loaded it twice. Honouring every
anchored request, which is what lets a repeat click scroll back to an anchor
the reader has scrolled away from, does not distinguish a repeat from the
duplicate `NSViewRepresentable` produces by design: `updateNSView` runs
immediately after `makeNSView`, in the same turn, while the anchor is still
pending because it is cleared asynchronously. The second load cancelled the
first mid-flight. The coordinator now records the anchor it served and drops
that record on the bare follow-up call, which is the one call that can only
arrive after the anchor was cleared — so the same-turn duplicate is refused
and a genuine repeat still navigates.

And headsAKeyTable, which mirrors the renderer's "Field" header gate, still
stripped less than stripInline does: a header cell written as a link would
make the renderer anchor that table while this test quietly stopped covering
it, with the checked == 0 fatal no help because it only fires when every key
falls out at once.

Left alone, deliberately: focus arriving on a control that was inserted
under the focus ring flips aim to the keyboard without the user touching a
key. Unlike its mirror image above, the caption then describes the control
that actually holds first responder — which is where the Space key goes, so
the caption and the keyboard still agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestVPNHoldsFullBlockOnProbeError asserted a literal seven-call sequence,
which made it depend on a race it does not test. fakeMonitor.Once cancels
the context while handing out the last result, and the run loop then has
both ctx.Done() and a 1ms geo ticker ready at its next select — which picks
uniformly among ready cases, so one extra probe cycle is a coin flip
whenever processing the previous one took longer than the interval. On a
fast machine it never does; under -race on a loaded CI runner it does, and
the job failed with one extra "apply-guard apply-fullblock" pair.

The comment in fakeMonitor states the assumption that breaks, in as many
words: "let the loop process it, then exit on ctx.Done next select". Nothing
guarantees that.

What the test is for is unchanged and is now stated directly: after the
startup guard and the escalation, the calls are whole lift-and-recut pairs
ending in cleanup, so a probe error never leaves the guard standing where a
full block should be. An extra probe cycle satisfies that as fully as the
expected number did; a lifted block fails it at any count, including the
sequences that end on a lift, that stop escalating, or that never reach
cleanup — each checked against the new assertion before this landed.

Not a CHANGELOG entry: no user-visible behavior changes, and the run loop is
untouched. The alternative fix, having the loop decline to start a
lift-and-probe once the context is cancelled, is a real improvement to
shutdown — it would stop momentarily lifting FULL BLOCK on the way out — but
it belongs in its own change, not smuggled in under a flaky test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ding

A geo tick taken while FULL BLOCK stands is not a passive read. When no
provider addresses resolved — so the tunnel-scoped pass cannot be built —
probe's fallback path lifts the guard, looks up the exit country, and
re-cuts. That is the sanctioned bounded leak, and it is justified by a
FULL BLOCK that must be able to observe its way out.

None of that justification survives a shutdown. Cleanup is deferred and
about to remove every rule, so the reading can change nothing, and the lift
paid for it opens egress through the exact forbidden-country exit the block
exists for. The loop could take one on the way down: with the context
cancelled and a geo tick pending, select had two ready cases and chose
between them at random, and the ticker is ready whenever the previous tick's
work outran the interval.

So the geo tick now returns when the context is done, which is what the
ctx.Done() case beside it already does — the change is only that the answer
no longer depends on which of the two select picked. It is the same rule the
manualBlock and panic-disarm branches state a few lines below: when recovery
cannot act on what it learns, it must not pay a lift to learn it.

TestShutdownTakesNoProbeTick reaches the state on purpose rather than
hoping for it, stalling inside the monitor past the interval so the tick is
pending rather than merely possible. Verified in both directions: with the
check disabled it failed on the first iteration, reporting the extra
apply-guard/apply-fullblock pair.

The privileged counterpart is in testing.md, because the Go test can only
show the decision — that the rules never leave FULL BLOCK for the teardown
needs pfctl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Behnam-RK
Behnam-RK merged commit e4bf3cd into main Aug 23, 2026
6 checks passed
@Behnam-RK
Behnam-RK deleted the feat/ui-explains-itself branch August 23, 2026 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Run CI on this PR (gates .github/workflows/ci.yml)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant