Automations and webhooks for the AT Protocol airglow.run

fix(form): show why the form can't be submitted on the fields, with the shared error styles #89

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

Follow-up to #16.

Problem #

#16 lists why the submit buttons are disabled in a box right above them ("Before you can create this automation: • Give it a name."). The box has its own look: a neutral bordered card with secondary text, matching neither the shared Alert nor the form's own field errors. It also points at the problem from far away: the name field is at the top of a long form, unmarked, while the message about it sits at the bottom. And it doesn't cover every required field, so the form now validates two ways that look nothing alike.

Evidence #

  • app/islands/AutomationForm.tsx:3070: #submit-blockers, rendered above the create buttons and inside the sticky edit bar.
  • app/islands/AutomationForm.css.ts:324: submitBlockers, a one-off style (surface background, border colour, textSecondary), next to the form's own copies of the alert styles (alertError / alertInfo / alertWarning, l. 341+). app/components/Alert already provides these variants.
  • The form already has a field-level error pattern: aria-invalid + aria-describedby + s.errorText under the input (variable step name, AutomationForm.tsx:2466).
  • blockingReasons (AutomationForm.tsx:1608) checks the name, trigger events and an action step, but not the Lexicon NSID. An empty NSID leaves the buttons enabled, and NsidCombobox forwards required, so the browser's native bubble is what stops the submit.

Expected #

The form says what's missing where it's missing, with the styles the rest of the UI uses:

  • Each blocking field shows its own message under it with the existing errorText pattern and aria-invalid, for the name, the Lexicon NSID, the trigger events and the steps.
  • To avoid a fresh form opening covered in red, the field messages show once the user has tried to submit (or left the field). The buttons stay enabled; a submit with missing fields shows them and moves focus to the first one, instead of a disabled button with a list.
  • Next to the buttons, at most one short line in the shared style (an Alert variant, or the form's errorText), e.g. "2 things to fix before you can create this automation", not a bespoke box.
  • The Lexicon NSID goes through the same path as the other fields, not the browser's native bubble.

Acceptance criteria #

Out of scope #

Replacing the form's other local alert copies (alertError / alertInfo / alertWarning) with Alert everywhere: worth its own issue.

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

loop:queued cid=bafyreidjyckngx3w4nwmc7o3kftnutygae25rjmp4xbmpvllsimjrxo3ly

Working on this, attempt 1.

loop:claim run=contribute-20261102-182242-89 branch=loop/89 attempt=1 issue=89

fix(form): show why the form can't be submitted on the fields, with the shared error styles

The automation form no longer disables its submit buttons and lists the reasons in a one-off box above them. The buttons stay enabled. A submit with missing fields marks each one in place, with the form's errorText message under it, aria-invalid and aria-describedby, and moves focus to the first one. One Alert line beside the buttons says how many things are left to fix. The Lexicon NSID now goes through the same check instead of the browser's native required bubble.

What changed #

  • AutomationForm.tsx: blockingReasons is replaced by missingFields, which covers the name, the Lexicon NSID (new), the trigger events and an action step. A field shows its message once a submit was tried or the user has left it (blur for name and NSID, focus leaving the checkbox group for trigger events, submit only for steps). The message goes away as soon as the value is fixed.
  • A blocked submit sends nothing, focuses the first missing control (#sub-name, #lexicon, #trigger-create or the top-level "Add step" button) after the render that links its message, and returns before the existing reference and processor checks.
  • Name and NSID drop required for aria-required="true", so no native bubble shows. NsidCombobox gets optional ariaRequired, invalid, describedBy and onBlur props. Its other callers are unchanged.
  • Alert gets optional role ("alert" by default, or "status") and id props, as docs/design.md suggests. The summary line uses role="status" so it does not interrupt again each time the count changes.
  • AutomationForm.css.ts: submitBlockers and its ul global style are gone. stickySubmit is a row again (centred, space[3] gap), so the edit bar holds the line and the button side by side. No new style values were added.

States #

  • Fresh empty New form: no red, no aria-invalid, both create buttons enabled and described by the dry-run hint only.
  • Field left empty (before submit): that field alone shows its message under it.
  • Submit with missing fields: every missing field shows its message. Under name: "Give it a name.". Under NSID: "Enter the Lexicon NSID to watch.". Under the trigger checkboxes, after the hint: "Pick at least one trigger event.". Right under the top-level "Add step" button, above the open picker: "Add at least one action step.". Focus is on the first missing field. Above the create buttons, an error Alert says "4 things to fix before you can create this automation", and the buttons' aria-describedby adds it.
  • Fixing fields: each message clears as its field is filled. The count in the line drops, and the line goes away when nothing is missing.
  • Edit page: the same, with the line ("1 thing to fix before you can update this automation") beside "Update automation" in the sticky bar. The Alert (~47px) is no taller than the lg button (48px). On a very narrow screen its text can wrap inside the box, but the bar stays one row.
  • Submitting/loading: unchanged (the fieldset is disabled and the buttons show "Creating..."/"Updating...").

Verified #

  • pnpm run check: passes.
  • pnpm run test: 185 files, 2851 tests pass.
  • I rewrote the "blocking reasons" tests in AutomationForm.test.tsx as "missing fields". They cover: a fresh form shows nothing and its buttons are enabled; name and NSID are not required but are aria-required; an empty submit marks all four fields with linked messages, focuses the name and shows "4 things..."; a partly filled form focuses the first missing field (the NSID); fixing fields clears messages without another submit; blur and focus-out report a field; edit mode behaves the same, with the line in the sticky bar beside the button.
  • One autosave test used to submit a draft that had no action step. It only passed because the old check never blocked a programmatic submit. It now adds a step from the open picker first.
  • Keyboard: I could not open the page, so this comes from the markup and the tests. The buttons are never disabled for missing fields, so focus does not drop. Enter in the name field submits, then focus lands on the first missing control, which has its message as its description.

Acceptance criteria #

Decisions you can reverse #

  • The summary is an Alert variant="error" with the new role="status", not plain errorText. In the sticky edit bar it sits over scrolling content, so it needs the Alert's opaque background.
  • The "Add at least one action step." message, aria-invalid and aria-describedby sit on the top-level "Add step" button, the control a blocked submit focuses. ARIA 1.2 does not list aria-invalid for buttons, but the linked description is read either way.
  • Only an empty NSID is reported, as the issue asks. A malformed one is still left to the server.
  • In create mode the summary sits above the two buttons, where the old box was, not in their row, so the two equal-width buttons keep their layout.
  • docs/design.md "Known debt" still names submitBlockers (with createSubmitHint, #88). The file is owner-only, so I left it. With this branch merged that line can drop submitBlockers.

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

fix(form): report the steps' required fields on the field, not with the browser's bubble

After #89 the automation form checks its own required fields (name, Lexicon NSID, trigger events, an action step) and reports each under the field. The fields inside the steps still carry the native required attribute, so an empty one is stopped by the browser's validation bubble before the form's submit handler runs. The form therefore still validates two ways, which docs/design.md ("A form has one validation path") rules out.

How I know: rg -n "^\s*required$" app/islands/action-editors/ app/islands/AutomationForm.tsx lists about 30 controls, among them webhook.tsx:172, notification.tsx:107, bsky-post.tsx:57, record.tsx:55 (the Target Lexicon NSID, via NsidCombobox's required prop) and the step output name at AutomationForm.tsx (renderActionBody, "required: later steps can only read the answer through it").

Where to start: missingFields and fieldIssue in AutomationForm.tsx already show a message once the user has left a field or tried to submit, and move focus to the first missing one. The step editors would need to report their empty required values up to it (or give the form a way to read them), and NsidCombobox already takes ariaRequired, invalid, describedBy and onBlur for this.

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

loop:pushed run=contribute-20261102-182242-89 sha=dab7484c7fe9859ee81743f24766aee1bb75813e

Changes requested. The four top-level fields (name, Lexicon NSID, trigger events, an action step) now report correctly on the field, and check and test are green. But the form still validates two ways: every required control inside the steps keeps the native required bubble. Now that the buttons are always enabled, that bubble fires before the form's own submit handler runs. The sticky edit bar also grows past one row on a phone, which the acceptance criteria rule out.

What I checked and how:

  • pnpm run check passes. pnpm run test passes: 185 files, 2851 tests.
  • Read every changed line against the issue's criteria. submitBlockers, its ul global style and #submit-blockers are gone, and no new style values were added. Alert gained a role and an id prop, as docs/design.md asks. Name and NSID use aria-required with no native required. Each message uses errorText, aria-invalid and aria-describedby, and shows only after a blur or a submit. A blocked submit focuses the first missing control after the render that links its message. Messages clear while the user types. The top-level "Add step" (#steps-add) always renders, so the steps message and its focus target always exist. The rewritten tests and the autosave test change are sound.
  • Searched for the whole bug class ("the browser's native bubble stops the submit") rather than only the NSID the issue names: rg -n -w required app/islands/action-editors/ app/islands/AutomationForm.tsx app/islands/RecordFormBuilder.tsx. This is the gap behind request 1 below.
  • Sizes for request 2 are worked out from the tokens (Alert padding space[3]/space[4] + fontSize.sm; Button lg is 48px tall with 20px inline padding). No browser is available in this container, so I could not render the page.

Changes:

  1. Report the steps' required fields through the same path as the top-level ones, with no native bubble. The issue's problem is that the form "doesn't cover every required field, so the form now validates two ways". docs/design.md says "A form has one validation path", and the same native-required bug is still in these places:

    • app/islands/action-editors/: about 27 controls, e.g. notification.tsx:107,127, webhook.tsx:172,231, bsky-post.tsx:57, ai.tsx:151,175, bsky-list-add.tsx:107,183,261,290, patch-record.tsx:66,84,155,182, record.tsx:55,98, follow.tsx:43, collect-links.tsx:53,75, clear-scheduled.tsx:41, semble-save.tsx:41, calendar-rsvp.tsx:51, kipclip-annotation.tsx:51, margin-bookmark.tsx:49;
    • the step output name at AutomationForm.tsx:2888;
    • the required={required} fields in RecordFormBuilder.tsx (lines 551-633 and the inputs they render), used by the record and patch-record steps.

    Real flow that fails today: name left empty, a "Send notification" step added with no title, then "Create automation". The browser shows its bubble on the step's Title, no submit event fires, and the name's message and the summary never appear. Once the title is filled, a second submit shows the form's own red messages: two styles, and two rounds, for one form.

    What to build: each of these controls drops required for aria-required="true". The step editors report their empty required values up to the form, for example a per-action missingFields(draft) in the editor registry returning { id, message }, plus props such as issueFor(id) / onLeave(id) passed down through EditorBlockProps. The form adds them to missingFields in page order, so a blocked submit focuses the first missing control on the page and the summary count includes them. Each one renders the same errorText message under its control, with aria-invalid and aria-describedby, shown after a blur or a submit and cleared as soon as it is filled. NsidCombobox already has ariaRequired / invalid / describedBy / onBlur for record.tsx and patch-record.tsx. Add a test with an empty name plus an empty notification title: both are marked, focus goes to the name, and the summary says "2 things to fix...". Non-required native constraints (pattern, number min/max) are outside this issue and can stay as they are.

  2. Keep the edit page's sticky bar one row high on a narrow screen (acceptance criterion "stays one row high"; docs/design.md says to check below mq.sm). "1 thing to fix before you can update this automation" in an Alert is about 400px wide, and "Update automation" at lg is about 168px, so the row needs about 580px. At a 375px viewport the Alert gets about 160px and wraps to two or three lines, so the bar is 70-90px tall over the form. Shorten the line in the sticky bar to fit beside the button at 375px (for example "1 thing to fix" / "4 things to fix"; the field messages already say what each one is). Or find another way that keeps it to one line there, and say in the PR how you checked it at 375px.

Follow-up: drop. followup.md is right: the step fields still use the native bubble, which I confirmed with the search above. But that work finishes this issue rather than sitting outside it, so it is request 1 above instead of a separate issue.

Not requested: the docs/design.md "Known debt" line naming submitBlockers is owner-only, so updating it is left to the owner.

loop:verdict run=review-20261102-183210-89 sha=dab7484c7fe9859ee81743f24766aee1bb75813e verdict=changes-requested

Fix round 1: the review asked for changes.

loop:fix run=contribute-20261102-183655-89 round=1 issue=89

fix(form): show why the form can't be submitted on the fields, with the shared error styles

The automation form no longer disables its submit buttons and lists the reasons in a one-off box above them. The buttons stay enabled. A submit with missing fields marks each one in place, with the form's errorText message under it, aria-invalid and aria-describedby, and moves focus to the first one on the page. One Alert line beside the buttons says how many things are left to fix. Every required control is now checked this way, inside the steps as well as at the top, so the browser's native required bubble never shows on this form.

This round (review fix 1): the steps' required controls now go through the same check as the top-level fields, and the line in the edit page's sticky bar is short enough to stay one row on a phone.

What changed #

  • AutomationForm.tsx: blockingReasons is replaced by missingFields, which covers the name, the Lexicon NSID (new), the trigger events and an action step. A field shows its message once a submit was tried or the user has left it (blur for name and NSID, focus leaving the checkbox group for trigger events, submit only for steps). The message goes away as soon as the value is fixed.
  • Step fields (this round): a new app/islands/required-fields.ts holds RequiredFieldsContext and a useRequiredField(id, missing, message) hook. The form provides the context around each step editor. Each required control in a step calls the hook, which reports its empty value to the form and returns the control's aria-required, aria-invalid, aria-describedby and onBlur; FieldError renders the message in errorText. The form adds these, plus the step output name it renders itself, to its check: the summary count includes them, and a blocked submit focuses the first missing control in page order. The file is .ts because honox treats every exported PascalCase name in an island .tsx as an island, which broke the context.
  • The native required is gone from every control in app/islands/action-editors/ (notification title and body, webhook callback URL and body template, Bluesky post text, AI prompt and model, add-to-list list / AT URI / who to add / subject DID, record and patch-record target NSID and hand-written template, patch-record base URI, follow subject, collect-links source and output name, clear-scheduled identifier, Semble, Margin, Kipclip and calendar RSVP fields) and from the AI step's name in AutomationForm.tsx. rg -n -w '^\s*required$' app/islands/action-editors app/islands/AutomationForm.tsx finds nothing now. A control that is only shown in some modes (the webhook body template, the add-to-list "Other" URI, the hand-written record template) only counts while it is shown.
  • A blocked submit sends nothing and returns before the existing reference and processor checks.
  • NsidCombobox has optional ariaRequired, invalid, describedBy and onBlur props, now used by the trigger NSID and the record and patch-record target NSIDs.
  • Alert has optional role ("alert" by default, or "status") and id props, as docs/design.md suggests. The summary line uses role="status" so it does not interrupt again each time the count changes.
  • AutomationForm.css.ts: submitBlockers and its ul global style are gone. stickySubmit is a row (centred, space[3] gap). No new style values.
  • The webhook's "invalid JSON" message now has an id and is listed in the body template's aria-describedby with the missing message, so both are read.

States #

  • Fresh empty New form: no red, no aria-invalid, both create buttons enabled and described by the dry-run hint only. Step fields are aria-required only.
  • Field left empty (before submit): that field alone shows its message under it. This is the same for a step's field ("Enter a title.").
  • Submit with missing fields: every missing field shows its message. Under the name: "Give it a name.". Under the NSID: "Enter the Lexicon NSID to watch.". Under the trigger checkboxes: "Pick at least one trigger event.". Under the top-level "Add step": "Add at least one action step.". Under each empty step field, its own line ("Enter a title.", "Enter the callback URL.", "Name this step.", and so on). Focus is on the first missing control on the page. Above the create buttons an error Alert says, for example, "2 things to fix before you can create this automation".
  • Fixing fields: each message clears as its field is filled, without another submit, and focus stays in the field. The count drops, and the line goes away when nothing is missing.
  • Edit page: the same, with "N to fix" (e.g. "1 to fix") beside "Update automation" in the sticky bar.
  • Submitting/loading: unchanged (fieldset disabled, "Creating..."/"Updating...").

The sticky bar at 375px #

I had no browser here, so I measured with the app's own font: public/fonts/OverusedGrotesk-*.ttf through the opentype.js copy in node_modules, at 14px. At a 375px viewport the bar has 293px: 375 minus the 16px page gutter on each side, the Card's 24px padding on each side and its 1px border. "Update automation" at semibold is about 118px of text, so the lg button is about 165px with its 20px padding and 2px border. With the 12px gap, that leaves 116px for the Alert, or 82px of text after its 16px padding and 1px border. "4 to fix" is 39px and "12 to fix" is 43px, so the line fits even with a two-digit count and a wider fallback font. The reviewer's suggestion, "4 things to fix", is 79px and "12 things to fix" is 83px: one pixel over. That is why the edit line drops "things". Create mode keeps the full sentence, because there it sits above the buttons and not beside them.

Verified #

  • pnpm run check: passes.
  • pnpm run test: 185 files, 2853 tests pass.
  • New tests in AutomationForm.test.tsx:
    • An empty name plus a "Send notification" step with an empty title: the title is aria-required, not required. The submit sends nothing and marks both the name and the title. Focus goes to the name, and the summary says "2 things to fix before you can create this automation". With the name filled, the next submit focuses the title. Typing in the title keeps focus there and clears its message and the summary.
    • A step field is reported on blur before any submit.
  • Updated tests: the edit-mode summary text, the AI name and add-to-list checks (aria-required instead of required), and three tests that submitted a notification step with an empty title and body. jsdom's synthetic submit skipped the native check; a real browser would have stopped those submits. They now fill both fields.
  • Keyboard: from the markup and the tests, since I could not open the page. The buttons are never disabled for missing fields. Enter or a click submits, then focus lands on the first missing control, which has its message as its description.

Acceptance criteria #

A review point I did not change #

The review asked to report "the required={required} fields in RecordFormBuilder.tsx (lines 551-633 and the inputs they render)". Those required props only draw the label's * (FieldLabel/requiredMark). No <input>, <select> or <textarea> in RecordFormBuilder.tsx has a native required, so there is no browser bubble there to replace. Checking those fields would add client validation the form never had, for schema-driven fields: nested objects, unions, arrays and blobs, with field-${name} ids that repeat across steps. That goes beyond this issue. Record and patch-record still report their target NSID and their hand-written template.

Decisions you can reverse #

  • The step editors report their empty values through a context and a hook (useRequiredField), not through a per-action missingFields(draft) in the registry. Each control knows when it is shown and what it holds, such as the add-to-list "Other" field or a template that is only hand-written when no schema resolves. The form still owns the count, the "shown after a blur or a submit" rule and the focus.
  • The step messages are short and specific ("Enter a title.", "Choose a list."). Whitespace-only counts as empty, as it does for the name.
  • The edit page's line says "N to fix" rather than "N things to fix", so it fits beside the button (see the measurements above).
  • The summary is an Alert variant="error" with role="status", which gives it an opaque background in the sticky bar.
  • The "Add at least one action step." message, aria-invalid and aria-describedby sit on the top-level "Add step" button, the control a blocked submit focuses.
  • Only an empty NSID is reported. A malformed one is still left to the server, as are non-required native constraints (pattern, number min/max).
  • docs/design.md "Known debt" still names submitBlockers. The file is owner-only, so I left it; once this branch merges, that line can drop submitBlockers.

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

loop:pushed run=contribute-20261102-183655-89 sha=389271afd56cbbe1b474d0d859d56f19ebdcf04d

Changes requested. Round 1's two requests are fixed: the steps' required controls now go through the form's own check, and the sticky edit line ("N to fix") fits in one row. Check and test are green. But the way step editors report their empty fields can leave a stale entry behind. After some step moves, the form refuses every submit with "2 things to fix", marks no field and sends nothing, even with every field filled. The user cannot save without reloading.

What I checked and how:

  • pnpm run check passes. pnpm run test passes: 185 files, 2853 tests.
  • Round 1 request 1: git grep -E '^\s*required(=\{[^}]*\})?$' HEAD -- app/islands app/components finds no native required left on a control in the automation form. The only hits are SecretsManager (a different form) and RecordFormBuilder props. I checked the contributor's pushback on RecordFormBuilder and it is right: its required props only draw FieldLabel's *, and none of its inputs carry a native required. So there is no bubble there, and I am not asking for it again. Each step editor's hook, FieldError (errorText), aria-invalid/aria-describedby, and the mode-dependent cases (webhook body template, add-to-list "Other", the hand-written record and patch templates) read correctly.
  • Round 1 request 2: the edit bar is one row (stickySubmit is alignItems: center, gap: space[3]). The line is "N to fix", and the new test checks that it sits beside the button. The Alert is about 46px tall, under the 48px lg button.
  • Design rules: no new style values. Alert gained a role prop as docs/design.md says, and the error messages sit under their fields and show after a blur or a submit. The branch is behind main (#69, security headers) but merges cleanly. That diff is not part of this branch.
  • Real flows, as throwaway tests in AutomationForm.test.tsx with the file's own helpers (renderForm, submitVia, the "Move step N (...) down" buttons):
    • Removing a step, moving a step once, or adding two steps, removing the first and filling the second all count correctly and submit.
    • The failing flow is request 1 below.

Changes:

  1. Key the step reports by the step, not by the control's index-based DOM id. useRequiredField reports under id, and every editor's id is action-${flatIndex}-..., so the id changes whenever a step moves, is indented or outdented, or a step before it is removed. hono/jsx then runs the hook's report effect with the old id after its cleanup has already run, so reportedStepIssues keeps the old id for good. I logged report calls in required-fields.ts and saw cleanup action-1-notification-title, then report action-1-notification-title true, then report action-2-..., with no second cleanup.

    Repro: render the create form with steps [notification {title: "", body: "B"}, webhook {callbackUrl: ""}, bsky-post {textTemplate: "x"}]. Click "Move step 1 (Send notification) down", then "Move step 2 (Send notification) down". Fill the title and the callback URL, then submit. Today: nothing is POSTed, the line says "2 things to fix before you can create this automation", no control has aria-invalid, and focus stays on the move button. Merging the two effects into one effect with a cleanup does not fix it; I tried.

    What does fix it, tried on every flow above:

    • RequiredFieldsApi gets a scope?: string, and the form wraps each EditorBlock in <RequiredFieldsContext.Provider value={{ ...requiredFieldsApi, scope: d.id }}>.
    • The hook reports under a stable key = \${api?.scope ?? ""}|${id.replace(/^action-\d+-/, "")}`, with the value { id, message }. Both effects depend on keyinstead ofid, and the cleanup reports (key, null)`.
    • reportedStepIssues becomes Map<key, { id, message }>, and stepIssues builds id → message from its values.

    With that change the repro POSTs. The form's existing tests and the rest of app/islands/ still pass (37 files, 516 tests).

    Add a regression test with the repro above: it must POST, and it must show no #submit-issues. Add a remove case too: two steps, both with an empty required field. Remove the first, fill the second, submit, and expect a POST.

  2. Make the "left the field" state follow the step too. leftFields holds the same index-based ids. Repro: two notification steps, both with empty titles. Blur step 1's title, then move step 1 down. The "Enter a title." message stays on the notification now at the top (the step the user never touched), and the moved one loses it. Key leave/shows by the same stable key as in 1, for example by passing the key to api.leave and api.shows. Add a test that the message moves with its step.

loop:verdict run=review-20261102-190645-89 sha=389271afd56cbbe1b474d0d859d56f19ebdcf04d verdict=changes-requested

Fix round 2: the review asked for changes.

loop:fix run=contribute-20261102-191317-89 round=2 issue=89

Fix round 2 failed: the session ended: stalled.

loop:failed run=contribute-20261102-191317-89 round=2

Fix round 3: the review asked for changes.

loop:fix run=contribute-20261102-195331-89 round=3 issue=89

Fix round 3 failed: the session ended: exited.

loop:failed run=contribute-20261102-195331-89 round=3

The branch used every fix round (3) and still needs one. Set the label back to ready, or reply on the issue, to have the loop pick it up again, past its caps.

loop:awaiting-owner run=none why=rounds after=2026-11-02T21:21:56.000Z

Keep going, try with 3 more rounds

Fix round 4: the owner commented.

loop:fix run=contribute-20261102-213139-89 round=4 issue=89

fix(form): show why the form can't be submitted on the fields, with the shared error styles

The automation form no longer disables its submit buttons and lists the reasons in a one-off box above them. The buttons stay enabled. A submit with missing fields marks each one in place, with the form's errorText message under it, aria-invalid and aria-describedby, and moves focus to the first one on the page. One Alert line beside the buttons says how many things are left to fix. Every required control is now checked this way, inside the steps as well as at the top, so the browser's native required bubble never shows on this form.

Earlier round (review fix 1): the steps' required controls now go through the same check as the top-level fields, and the line in the edit page's sticky bar is short enough to stay one row on a phone.

This round (review fix 2): a step's empty fields and its "left the field" state are now keyed by the step, not by the control's position-based DOM id. Before, moving, indenting or removing a step could leave a stale entry behind, so the form refused every submit ("2 things to fix") with nothing marked, and a blurred field's message stayed at the old position instead of following its step.

What changed #

  • AutomationForm.tsx: blockingReasons is replaced by missingFields, which covers the name, the Lexicon NSID (new), the trigger events and an action step. A field shows its message once a submit was tried or the user has left it (blur for name and NSID, focus leaving the checkbox group for trigger events, submit only for steps). The message goes away as soon as the value is fixed.
  • Step fields (review fixes 1 and 2): a new app/islands/required-fields.ts holds RequiredFieldsContext and a useRequiredField(id, missing, message) hook. The form provides the context around each step editor, with the step's id as its scope. The hook reports under a key that stays the same while the step lives (<step id>|<control id without its "action-N-" prefix>), with the control's current DOM id and message, and the "left the field" state uses the same key. So a step that moves, is indented or outdented, or has a step before it removed keeps one entry, and its message moves with it. Each required control in a step calls the hook, which reports its empty value to the form and returns the control's aria-required, aria-invalid, aria-describedby and onBlur; FieldError renders the message in errorText. The form adds these, plus the step output name it renders itself, to its check: the summary count includes them, and a blocked submit focuses the first missing control in page order. The file is .ts because honox treats every exported PascalCase name in an island .tsx as an island, which broke the context.
  • The native required is gone from every control in app/islands/action-editors/ (notification title and body, webhook callback URL and body template, Bluesky post text, AI prompt and model, add-to-list list / AT URI / who to add / subject DID, record and patch-record target NSID and hand-written template, patch-record base URI, follow subject, collect-links source and output name, clear-scheduled identifier, Semble, Margin, Kipclip and calendar RSVP fields) and from the AI step's name in AutomationForm.tsx. rg -n -w '^\s*required$' app/islands/action-editors app/islands/AutomationForm.tsx finds nothing now. A control that is only shown in some modes (the webhook body template, the add-to-list "Other" URI, the hand-written record template) only counts while it is shown.
  • A blocked submit sends nothing and returns before the existing reference and processor checks.
  • NsidCombobox has optional ariaRequired, invalid, describedBy and onBlur props, now used by the trigger NSID and the record and patch-record target NSIDs.
  • Alert has optional role ("alert" by default, or "status") and id props, as docs/design.md suggests. The summary line uses role="status" so it does not interrupt again each time the count changes.
  • AutomationForm.css.ts: submitBlockers and its ul global style are gone. stickySubmit is a row (centred, space[3] gap). No new style values.
  • The webhook's "invalid JSON" message now has an id and is listed in the body template's aria-describedby with the missing message, so both are read.

States #

  • Fresh empty New form: no red, no aria-invalid, both create buttons enabled and described by the dry-run hint only. Step fields are aria-required only.
  • Field left empty (before submit): that field alone shows its message under it. This is the same for a step's field ("Enter a title.").
  • Submit with missing fields: every missing field shows its message. Under the name: "Give it a name.". Under the NSID: "Enter the Lexicon NSID to watch.". Under the trigger checkboxes: "Pick at least one trigger event.". Under the top-level "Add step": "Add at least one action step.". Under each empty step field, its own line ("Enter a title.", "Enter the callback URL.", "Name this step.", and so on). Focus is on the first missing control on the page. Above the create buttons an error Alert says, for example, "2 things to fix before you can create this automation".
  • Fixing fields: each message clears as its field is filled, without another submit, and focus stays in the field. The count drops, and the line goes away when nothing is missing.
  • Moving, indenting or removing a step: a step's messages and its place in the count go with the step. A message shown after a blur stays on the field the user left, wherever that step now sits, and a removed step leaves nothing in the count.
  • Edit page: the same, with "N to fix" (e.g. "1 to fix") beside "Update automation" in the sticky bar.
  • Submitting/loading: unchanged (fieldset disabled, "Creating..."/"Updating...").

The sticky bar at 375px #

I had no browser here, so I measured with the app's own font: public/fonts/OverusedGrotesk-*.ttf through the opentype.js copy in node_modules, at 14px. At a 375px viewport the bar has 293px: 375 minus the 16px page gutter on each side, the Card's 24px padding on each side and its 1px border. "Update automation" at semibold is about 118px of text, so the lg button is about 165px with its 20px padding and 2px border. With the 12px gap, that leaves 116px for the Alert, or 82px of text after its 16px padding and 1px border. "4 to fix" is 39px and "12 to fix" is 43px, so the line fits even with a two-digit count and a wider fallback font. The reviewer's suggestion, "4 things to fix", is 79px and "12 things to fix" is 83px: one pixel over. That is why the edit line drops "things". Create mode keeps the full sentence, because there it sits above the buttons and not beside them.

Verified #

  • pnpm run check: passes.
  • pnpm run test: 185 files, 2856 tests pass.
  • New tests in AutomationForm.test.tsx:
    • An empty name plus a "Send notification" step with an empty title: the title is aria-required, not required. The submit sends nothing and marks both the name and the title. Focus goes to the name, and the summary says "2 things to fix before you can create this automation". With the name filled, the next submit focuses the title. Typing in the title keeps focus there and clears its message and the summary.
    • A step field is reported on blur before any submit.
    • This round, under "when steps move or go" (all three fail on the previous head and pass now):
      • The reviewer's repro: steps [notification {title: "", body: "B"}, webhook {callbackUrl: ""}, bsky-post], "Move step 1 (Send notification) down", "Move step 2 (Send notification) down", fill the title and callback URL, submit: it POSTs and there is no #submit-issues.
      • Two notification steps with empty titles: remove the first, fill the second, submit: it POSTs, no #submit-issues.
      • Two notification steps with empty titles: blur step 1's title, move step 1 down: "Enter a title." is on the moved step (now action-1-...) and not on the one now at the top.
  • Updated tests: the edit-mode summary text, the AI name and add-to-list checks (aria-required instead of required), and three tests that submitted a notification step with an empty title and body. jsdom's synthetic submit skipped the native check; a real browser would have stopped those submits. They now fill both fields.
  • Keyboard: from the markup and the tests, since I could not open the page. The buttons are never disabled for missing fields. Enter or a click submits, then focus lands on the first missing control, which has its message as its description.

Acceptance criteria #

A review point I did not change #

The review asked to report "the required={required} fields in RecordFormBuilder.tsx (lines 551-633 and the inputs they render)". Those required props only draw the label's * (FieldLabel/requiredMark). No <input>, <select> or <textarea> in RecordFormBuilder.tsx has a native required, so there is no browser bubble there to replace. Checking those fields would add client validation the form never had, for schema-driven fields: nested objects, unions, arrays and blobs, with field-${name} ids that repeat across steps. That goes beyond this issue. Record and patch-record still report their target NSID and their hand-written template.

Decisions you can reverse #

  • The step editors report their empty values through a context and a hook (useRequiredField), not through a per-action missingFields(draft) in the registry. Each control knows when it is shown and what it holds, such as the add-to-list "Other" field or a template that is only hand-written when no schema resolves. The form still owns the count, the "shown after a blur or a submit" rule and the focus.
  • The step messages are short and specific ("Enter a title.", "Choose a list."). Whitespace-only counts as empty, as it does for the name.
  • The edit page's line says "N to fix" rather than "N things to fix", so it fits beside the button (see the measurements above).
  • The summary is an Alert variant="error" with role="status", which gives it an opaque background in the sticky bar.
  • The "Add at least one action step." message, aria-invalid and aria-describedby sit on the top-level "Add step" button, the control a blocked submit focuses.
  • Only an empty NSID is reported. A malformed one is still left to the server, as are non-required native constraints (pattern, number min/max).
  • docs/design.md "Known debt" still names submitBlockers. The file is owner-only, so I left it; once this branch merges, that line can drop submitBlockers.

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

loop:pushed run=contribute-20261102-213139-89 sha=abed96b9abc3beaa60080c597ad4db399286d756

Merge. Round 2's two requests are fixed exactly as prescribed. A step's reports and its "left the field" state are now keyed by the step's id plus the control's id without its action-N- prefix, so moving, indenting, outdenting or removing a step no longer leaves a stale entry that blocks every submit. Check and test are green, and round 1's fixes still hold.

What I checked and how:

  • pnpm run check passes. pnpm run test passes: 185 files, 2856 tests.
  • Read abed96b, the only commit since the last review, which touches required-fields.ts, AutomationForm.tsx and the tests:
    • RequiredFieldsApi gets a scope, and the form passes d.id as the scope around the one EditorBlock render site.
    • The hook reports under a stable key with { id, message }. The cleanup effect depends only on the key. leave/shows use the same key.
    • stepIssues still maps DOM id to message, so the focus order and the count work as before.
  • Checked that every editor's control id starts with action-${index}-, so the prefix strip covers all of them. Step ids come from mintStepId(). The step output-name ids (step-${d.id}-name) were already stable.
  • Round 1 still holds:
    • git grep finds no native required left on an automation-form control. The remaining hits are RecordFormBuilder label props (pushback accepted last round), SecretsManager (another form), and NsidCombobox's forwarding prop, which no caller sets.
    • submitBlockers is gone.
    • The sticky bar's line is unchanged.
  • Ran three throwaway tests in AutomationForm.test.tsx, then reverted them. All passed:
    • Indent a webhook with an empty callback URL into a loop, then outdent it. The blurred message follows the step. A submit says "1 thing to fix" and focuses the field. Filling it clears the line, and the form POSTs.
    • Three notification steps: move down, down, up, submit ("2 things"), remove two steps, fill the rest. The line clears and the form POSTs.
    • Move a step inside a condition's then branch. The count is right and clears once the fields are filled.
  • The branch's base is current main (51a528a).

Not requested: the docs/design.md "Known debt" line still names submitBlockers. That file is owner-only, so the owner can drop the name after this merges.

loop:verdict run=review-20261102-213900-89 sha=abed96b9abc3beaa60080c597ad4db399286d756 verdict=merge

main moved and no longer merges cleanly into the branch, so a fix round will merge it.

loop:carry from=abed96b9abc3beaa60080c597ad4db399286d756 conflict=1

The branch used every fix round (3) and still needs one. Set the label back to ready, or reply on the issue, to have the loop pick it up again, past its caps.

loop:awaiting-owner run=none why=rounds after=2026-11-02T21:42:44.000Z

Run as many additional review rounds as you need

Fix round 5: the owner commented.

loop:fix run=contribute-20261103-115822-89 round=5 issue=89

Fix round 5 failed: the branch has no new commits.

loop:failed run=contribute-20261103-115822-89 round=5

Merge main and resolve the conflicts in AutomationForm.tsx, .css.ts and .test.tsx, keeping both sides. Keep #88’s “Start in” choice and its toggle hint in place of the old dry-run paragraph, and point the create buttons’ aria-describedby at whatever #88 left there. Keep #87’s watched repos block. Keep this branch’s per-field errors, the summary Alert and the step keys. Then run check and test and make sure every state in the PR description still holds.

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

loop:queued cid=bafyreidjyckngx3w4nwmc7o3kftnutygae25rjmp4xbmpvllsimjrxo3ly

Working on this, attempt 1.

loop:claim run=contribute-20261103-132133-89 branch=loop/89 attempt=1 issue=89

fix(form): show why the form can't be submitted on the fields, with the shared error styles

The New and Edit automation forms no longer disable their submit button behind a bespoke "Before you can create this automation" box. The button stays enabled; a submit with missing fields marks each one in place (aria-invalid plus an errorText message linked by aria-describedby), moves focus to the first, and shows one short line in the shared Alert (error variant, role="status") beside the button. The Lexicon NSID and the steps' own required controls now go through the same path, so the browser's native required bubble no longer fires anywhere in the form.

This round: merged main (#87's watched repos block, #88's "Start in" choice) as the owner asked, keeping both sides. The create button is single now (from #88), and its aria-describedby points at the chosen start mode's hint (start-dry-run-hint or start-live-hint), followed by submit-issues after a blocked submit. #88's createSubmitHint and this branch's submitBlockers are both gone, and the tests were adjusted to the single button.

What changed #

  • AutomationForm.tsx: blockingReasons/formInvalid replaced by missingFields (name, Lexicon NSID, trigger events, an action step) plus stepIssues (required controls inside steps, keyed by step id so a moved step keeps its report). Messages show after a submit attempt, or for a field once the user has left it (blur, or focus leaving the trigger-events group).
  • required-fields.ts (new): a context plus useRequiredField/FieldError that the action editors use to report their required controls to the form, in place of required.
  • NsidCombobox: ariaRequired, invalid, describedBy, onBlur props, so the form can mark it without native validation.
  • Alert: optional role (alert by default, status here) and id props, so the summary line can be referenced by aria-describedby.
  • AutomationForm.css.ts: submitBlockers removed; stickySubmit lays the line and the button out in one row.

States #

  • Fresh empty form (create): no messages, no aria-invalid, no summary line. The button "Create automation" is enabled and described by the selected start mode's hint.
  • Empty submit (create): nothing is posted. Name, Lexicon NSID, trigger events and "Add step" each show their message beneath them with aria-invalid="true". Focus moves to the name field, and the line "4 things to fix before you can create this automation" shows above the button. The button's aria-describedby is start-dry-run-hint submit-issues, or the live hint when Live is chosen.
  • Field left empty before any submit: only that field shows its message, and there is no summary line.
  • Fixing a field: its message and aria-invalid clear straight away and the count drops. Once everything is fixed the line goes and aria-describedby goes back to the start hint.
  • Step's required control left empty: it is reported like the top-level fields, counted in the line, and focused when it is the first in page order.
  • Edit page: same behaviour on "Update automation". The line reads "N to fix" and sits beside the button in the sticky bar, on the same row.
  • Submitting: unchanged. The fieldset is disabled and the button shows "Creating..."/"Updating...".

Verified #

  • pnpm run check: passes.
  • pnpm run test: 186 files, 2781 tests pass.
  • The "AutomationForm missing fields" tests cover the fresh form, the empty submit (messages, focus, summary, aria-describedby), focus going to the first missing field, a message clearing without another submit, report on leave, steps' required controls (including after a move or remove), and the edit page's sticky row. #88's "Start in" tests and #87's watched-repos tests pass unchanged.

Acceptance criteria #

Out of scope, and left alone: the form's own alertError/alertInfo/alertWarning copies.

Decisions the owner can reverse #

  • Messages show after a submit attempt or once the field is left. A form that has never been touched shows none.
  • The edit bar's line is just "N to fix", short enough to fit beside the button on a 375px screen. The create line is the full sentence.
  • The summary uses role="status" instead of Alert's default alert, so it does not interrupt the screen reader. Focus has already moved to the first field.
  • The merge with main is a merge commit on top of the earlier branch history, not a rebase.

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

docs(design): drop the submitBlockers and createSubmitHint debt line

docs/design.md (owner-only) still lists, under "Known debt", line 107:

- `submitBlockers` and `createSubmitHint` in `AutomationForm.css.ts` break the field and explanation rules above
  (#88, #89).

Both styles are gone once #89 merges: #88 removed createSubmitHint with the dry-run paragraph, and #89 removes submitBlockers with the box above the submit button. rg -n 'submitBlockers|createSubmitHint' app/ finds nothing on the branch; the doc is the only place left that names them.

The edit: delete that bullet (both lines) from the "Known debt" list in docs/design.md.

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

loop:pushed run=contribute-20261103-132133-89 sha=3e47db48a89dedb1906b4e4ea3e6ced22908bc11

Merge. The owner asked for main to be merged in, keeping both sides, and the merge commit (0e1a8e6) and the test fix after it do that. #88's "Start in" choice and its hints replace the old dry-run paragraph, and #87's watched repos block is unchanged. This branch's per-field errors, the summary Alert and the step-keyed reports are all still there. The single create button's aria-describedby is the chosen start hint, plus submit-issues after a blocked submit. Check and test are green, and the head merges cleanly onto current main (75bd49e, #96, server-only).

What I checked and how:

  • pnpm run check passes. pnpm run test passes: 186 files, 2781 tests. The count is lower than in round 3 because main pruned tests in ae514a7.
  • I read the conflict resolution with git show --remerge-diff 0e1a8e6:
    • submitBlockers (main) and createSubmitHint (old base) are both gone. createSubmitBlock keeps only {submitIssues} and the one button, which has disabled={submitting}.
    • startHintId comes from #88, and createDescribedBy comes from this branch.
    • No submitKind, formInvalid, blockingReasons or create-dry-run-hint is left in app/.
    • The "Start in" fieldset and the payload's dryRun: startDryRun are untouched by the branch's diff against the merge base.
  • Round 1 and 2 fixes still hold:
    • git grep finds no native required on a control in the automation form or the step editors.
    • Step reports and the "left" state are keyed by step id.
    • The edit bar's line is "N to fix", beside the button.
  • I tried the merged flow in a throwaway test in AutomationForm.test.tsx, then reverted it. I chose Live, submitted with an empty name, and nothing was posted. Focus went to the name field, and the button was described by start-live-hint submit-issues while Live stayed checked. Once the name was filled, the line went away, the button was described by start-live-hint again, and the submit POSTed dryRun: false.
  • I searched the repo for the deleted names (submitBlockers, submit-blockers, blockingReasons, "Before you can create"). The only hit left is docs/design.md:107.

Follow-up: file. I checked it against the code. Once this merges, the "Known debt" bullet in docs/design.md names submitBlockers and createSubmitHint, and neither exists in app/ any more. docs/design.md is owner-only, so the contributor cannot make the edit. The edit is right: delete that two-line bullet.

loop:verdict run=review-20261103-132753-89 sha=3e47db48a89dedb1906b4e4ea3e6ced22908bc11 verdict=merge followup=1

Merged main into the branch: af9890e74d4697cd0833d0b6cc534bb10ad11861.

loop:carry from=3e47db48a89dedb1906b4e4ea3e6ced22908bc11 sha=af9890e74d4697cd0833d0b6cc534bb10ad11861

Merged onto main as 1c6369ca33a479d6234c7cd4a310576e50684881.

loop:merged sha=1c6369ca33a479d6234c7cd4a310576e50684881 head=af9890e74d4697cd0833d0b6cc534bb10ad11861

Filed the follow-up as #106, a new issue, not marked ready, from branch loop/89 (1c6369ca33a4): docs(design): drop the submitBlockers and createSubmitHint debt line

loop:filed issue=106

Sign up or Sign in to add to the discussion.