Twinned from https://github.com/cfsh/ninefold/issues/385 by tools/beads/import_github.py's reconcile pass.
Filed from @aedanpope's playtest question on #335 (2026-08-02):
> "There's still some tiny moments where going up through focus and going down through focus don't quite do the same thing. I don't understand if there's an architectural issue going on there, because we should be sharing code between up and down, and I don't see why it would be different sometimes."
Direct answer: the traversal itself is already symmetric; the asymmetries came from two other layers. Worth writing down because the question will recur.
Layer 1 — the neighbour graph (symmetric, shared). TonapseHome.WireFocusChain sets both directions of every chain link from one place, re-applied after every rebuild. There is no separate up-path and down-path to diverge; a missing link shows up as *both* directions broken, not one. #335's D2/D4 were exactly that (a stop nobody linked to), and they read as symmetric failures.
Layer 2 — remembered state (was asymmetric; fixed in #335). "Which cell do I return to" was written on ONE path (grid-side cell focus) and read on several, so entering by another route left it stale. Fixed by deriving the target from the pattern that is actually open rather than from focus history. This class recurs wherever a widget remembers a position: the fix pattern is *derive from real state, don't accumulate from one entry path*.
Layer 3 — deferred re-grabs (still unaudited, the real remaining risk). There are 27 CallDeferred(GrabFocus) sites across 7 files (TonapseHome, SongGrid, SoundPicker, PatternPicker, PianoRoll, SoundEditor, TonapseMenu), each restoring focus after a rebuild. They run a frame later, in an order nobody arbitrates, and they can land on a control the traversal did not choose. #335's playtest item 2 was this shape — one keypress produced two focus_change events 4 ms apart, the second overriding the first. That one is fixed at its cause, but nothing prevents the next.
Why it presents as up-vs-down: a re-grab races the traversal only when a rebuild happens to fire on that transition, and the transitions that trigger rebuilds are not symmetric (selecting a sound rebuilds; leaving it does not).
Proposed work
1. Inventory the 27 sites: which are "restore after MY rebuild" (legitimate) vs "grab because I think I should have focus" (a policy decision that belongs to the router).
2. Give restores a single arbitration point — a deferred restore should no-op if focus already moved to a control the user's input chose this frame (the same-frame idea #335's window stop now uses locally).
3. Add an expect_focus leg per rebuild-triggering transition (select sound, delete sound, rename, mode cancel, modal close), asserting focus lands where traversal says rather than where the last re-grab says.
Cheap detection now that the playtest journal exists: any two focus_change events <20 ms apart with one input event is a re-grab race — that pattern is greppable in every future session log.
Not claimed — filed for whoever picks up focus work next. #335's four point-fixes are in and green; this is the systemic follow-up behind them.
None.
No comments.