Automations and webhooks for the AT Protocol airglow.run

fix(automations): keep focus and distinct names on the step move, indent and insert controls #61

Closed tyb.there-hugo.dev
Closed opened by tyb.there-hugo.dev

Problem #

The step controls added in #19 (8a07212) drop keyboard focus to <body> and all share the same accessible names. A keyboard or screen-reader user loses their place after every reorder or insert, and cannot tell which step a "Move step up" belongs to.

Evidence #

  • app/islands/AutomationForm.tsx:2813 moveTo reorders keyed cards. The hono/jsx reconciler moves the focused card's node (node_modules/hono/dist/jsx/dom/render.js:224-227, insertBefore / appendChild), which blurs the focused Move up/down button.
  • AutomationForm.tsx:1593 vs 1605: the closed "Insert step here" is a <button key="insert-…">, and the open state is a different <div key="add-…">. Opening, collapsing or picking a tile (setPickerFor(null), line 1009) unmounts the focused control.
  • AutomationForm.tsx around 2884: "Outdent step" is not rendered once canOutdent is false. After outdenting to the top level, the focused button disappears.
  • aria-label="Move step up", "Move step down", "Indent step" and "Outdent step" (2884-2919), and the "Insert step here" text, are the same on every step.
  • Raised in an earlier review on 2026-10-26 and not fixed on main.

Expected #

After any of these controls acts, focus lands on a sensible control:

  • the same button on the moved card, or the other direction's button when the first one has become disabled;
  • the open picker's first tile, or its toggle;
  • the new step's card after an insert;
  • the moved step's card (or its remaining tool button) after indent or outdent.

Each control's accessible name includes the step's name or position, for example "Move step 2 (Condition) up" and "Insert step before step 2".

Acceptance criteria #

Out of scope #

  • Moving a step between levels with up/down: this stays with indent/outdent.
  • The insert-control spacing and visual design.
  • Drag-and-drop behaviour.

Queued: the loop will work on this version of the issue.

loop:queued cid=bafyreidcdu2z7ornxd2l6fx672zizcuclxoosjvjek7catzyhylztvafb4

Working on this, attempt 1.

loop:claim run=contribute-20261030-103337-61 branch=loop/61 attempt=1 issue=61

fix(automations): keep focus and distinct names on the step move, indent and insert controls

The step card's Move up/down, Indent/Outdent and Remove buttons and the "Insert step here" / "Add step" controls now put keyboard focus back on a sensible control after they act, so focus no longer drops to <body>. Each of them also has an accessible name that says which step or list it belongs to, such as "Move step 2 (Condition) up" or "Insert step here, before step 3 (Send a webhook)", where before every step's buttons had the same name.

How it works #

  • A small focusAfterRender(...keys) helper in AutomationForm waits for the next frame, which comes after hono/jsx has re-rendered, then focuses the first control, in order of preference, whose data-focus-key matches and that is enabled. The same rAF approach is already used for #sub-name.
  • Move: focus goes to the same direction's button on the moved card. If that button is now disabled (the step reached the first or last place), focus goes to the other direction's button.
  • Indent / outdent: focus goes to the same button, then the opposite one, then Move up/down, then Remove. After an outdent to the top level the Outdent button is gone, so Indent gets focus.
  • Insert / Add step: opening or collapsing the picker keeps focus on the toggle (the slim "Insert step here" button and the open picker's toggle replace each other). Choosing a tile focuses the new step's first field, or its Remove button if it has no field.
  • Remove: focus goes to the add control that takes the step's place: "Insert step here" before the next step, or the list's "Add step" box when the removed step was the last.
  • Names: stepLabels numbers every step in reading order across the whole tree, plus its kind ("step 4 (Send a webhook)"). The visible text comes first in each name. Nested "Add step" toggles are named "Add step to the Then branch of step 1 (Condition)" or "Add step inside step 1 (Loop)".
  • Fixed in passing: the picker toggles' aria-expanded was written as "" when open and left out when closed, because hono/jsx/dom renders boolean attributes that way. It is now the string "true" / "false", as NsidCombobox already does.

Verified #

  • pnpm run check passes, and pnpm run test passes (181 files, 2799 tests).
  • New tests in app/islands/AutomationForm.test.tsx ("step control focus and names") press each control the way the keyboard does (focus it, then activate it) and assert on document.activeElement. With the focus helper turned into a no-op, all 7 focus tests fail.
  • I could not try this in a browser. The container has no .env, OAuth keys or PDS, so the dev server cannot start. The keyboard behaviour is covered only by the happy-dom tests.

Acceptance criteria #

Decisions you can reverse #

  • When the picker opens, focus stays on its toggle (the disclosure pattern; aria-expanded announces the change) rather than moving to the first tile. The issue allowed either.
  • Steps are numbered in reading order across the whole tree, not by position in their own list, so the number is unique on the page. The number is not shown on screen.
  • Also in scope as the same gap on the same card: the step's Remove button (name and focus) and the nested "Add step" toggles (names). The root "Add step" keeps its visible text as its name.

Follow-up, filed as a new issue on merge if the review agrees:

fix(automations): keep focus on the condition step's branch controls

"+ Add else branch" and "Remove else branch" on a condition step drop keyboard focus to <body>: each one is unmounted by the change it makes (the add button is replaced by the Else list, the remove button goes with the branch), and nothing puts focus back. "Remove branches" does the same when it turns a branch into a gate. Their names ("Add else branch", "Remove else branch", "Remove branches") are also the same on every condition step, so a screen-reader user cannot tell which condition they belong to.

How I know: in a happy-dom test rendering AutomationForm with one condition step that has a Then branch, focusing and clicking "+ Add else branch", then "Remove else branch", left document.activeElement as BODY after each, and both buttons were disconnected.

Where to start: renderConditionBody in app/islands/AutomationForm.tsx (the conditionRow buttons) and removeBranches. The focusAfterRender helper and data-focus-key tags added for the step move/indent/insert controls can be reused, and the stepLabels map gives each step a name such as "step 2 (Condition)" for the accessible names.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/61

loop:pushed run=contribute-20261030-103337-61 sha=c9061f6b2bec16506f98010d6447bd49ba60e263

Merge. The branch meets every acceptance criterion in #61. After Move up/down, Indent/Outdent, opening or collapsing "Insert step here", and choosing a tile, focus goes to a sensible control and no longer drops to <body>. Each move, indent, outdent and insert control (and the step's Remove) now has a distinct name that starts with its visible text, such as "Move step 2 (Condition) up" or "Insert step here, before step 3 (Send a webhook)". pnpm run check and pnpm run test both pass (181 files, 2799 tests).

What I checked and how:

  • Render timing: hono/jsx/dom runs a setState re-render in a microtask (render.js update, await Promise.resolve()). The requestAnimationFrame in focusAfterRender therefore runs after the DOM is patched, so the helper finds the moved or replaced control in real browsers too, not only under fake timers.
  • Criteria against the code:
    • moveTo focuses the same direction's button, or the other one when that is disabled at the first or last place.
    • shift falls back from Outdent/Indent to the opposite button, then Move up/down, then Remove. After an outdent the step always follows a loop or branched condition, so Indent is shown.
    • togglePicker refocuses the shared add:<slot> key across the <button key="insert-..."> / <div key="add-..."> swap.
    • addStep focuses the new card's first field.
    • The existing tests that queried aria-label="Indent step" and similar now use prefix/suffix selectors. No other file in the repo queries the old names.
  • Names: stepKindLabel uses the same actionTypeKey / ACTION_INFO_BY_TYPE lookup as the card header, so the kind in the name matches what is shown on the card. Numbering by reading order makes each name unique on the page. The new test checks that no two names repeat.
  • Real-use flows (throwaway happy-dom test in /work, since deleted), each started from the keyboard (focus, then activate):
    • From the root "Add step" box, I chose each of the 23 enabled tiles in turn. Every one left focus on an INPUT inside the new step's card.
    • With a condition gate followed by two actions, I opened "Insert step here, before step 3", which kept focus on the toggle, and chose "Variable", which focused the new card's input. I then removed step 1, and focus went to "Insert step here, before step 1 (Post to Bluesky)".
    • With BRANCH_SEED, removing the only step in the Then branch focused "Add step to the Then branch of step 1 (Condition)". Opening the Else branch's Add step box and choosing Loop focused the new loop's name input. Every name in the resulting tree was distinct.
  • Scope: the extra changes are the step's Remove button (name and focus) and the aria-expanded string fix (hono/jsx/dom writes true as "" and drops false). Both are small, on the same controls, and stated in pr.md. Nothing touches drag-and-drop or the visual design, which the issue puts out of scope.
  • Not tried in a browser: no dev server can run here (no .env, OAuth keys or PDS). The focus behaviour is covered by the happy-dom tests plus the render-timing check above.

Follow-up: file. I checked its claim in renderConditionBody:

  • "+ Add else branch" is replaced by the Else list when activated.
  • "Remove else branch" and "Remove branches" are removed along with what they remove, and removeBranches does not put focus back anywhere.
  • All three have the same name on every condition step.

These are different controls from the move, indent, outdent and insert controls that #61 names, so the follow-up does not finish this issue. It is a real gap of the same kind and worth its own issue.

loop:verdict run=review-20261030-105146-61 sha=c9061f6b2bec16506f98010d6447bd49ba60e263 verdict=merge followup=1

Merged main into the branch: 85c36edd3b2e4a57871f48643ef6ccdd9592ceb9.

loop:carry from=c9061f6b2bec16506f98010d6447bd49ba60e263 sha=85c36edd3b2e4a57871f48643ef6ccdd9592ceb9

Merged main into the branch: cfbbd0b0c3e289be191b0b103dc6a473d55833df.

loop:carry from=85c36edd3b2e4a57871f48643ef6ccdd9592ceb9 sha=cfbbd0b0c3e289be191b0b103dc6a473d55833df

Merged main into the branch: 134b2f1168cac07eb3a7e4fa61d10b18493fb947.

loop:carry from=cfbbd0b0c3e289be191b0b103dc6a473d55833df sha=134b2f1168cac07eb3a7e4fa61d10b18493fb947

Merged onto main as a36717445fddd6c43ddef9079f079156ca15df9f.

loop:merged sha=a36717445fddd6c43ddef9079f079156ca15df9f head=134b2f1168cac07eb3a7e4fa61d10b18493fb947

Filed the follow-up as #92, a new issue, not marked ready, from branch loop/61 (a36717445fdd): fix(automations): keep focus on the condition step's branch controls

loop:filed issue=92

Sign up or Sign in to add to the discussion.