Automations and webhooks for the AT Protocol airglow.run

Record form builder shows optional blob fields as raw JSON boxes #59

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

Problem #

Blob fields (a profile's avatar and banner, an image embed's image) render as a "raw JSON" textarea, even when they are optional. A blob needs a reference to a file already uploaded to the user's PDS, which nobody can type by hand. The box only invites a value that can't work, and it makes an optional field look like something to fill in.

Split out of #26, which fixed optional objects and deprecated fields but left this bullet.

Code: lib/lexicons/schema-tree.ts sends blob to the default case, so it becomes unknown and renders through UnknownField in RecordFormBuilder.tsx.

Fix direction #

  • Give blobs their own node type in schema-tree.ts instead of unknown. unknown also covers cycles, depth cutoffs and missing refs, which must keep their raw JSON editor.
  • Collapse optional blob fields behind "+ Add ", as #26 does for optional objects. Hiding them would make an optional blob impossible to set. Once added, show the raw JSON editor with a hint that the builder can't upload files yet. A template that already holds a value for one opens expanded, so old templates stay editable.
  • Required blob fields keep the raw JSON editor, with a hint that the builder can't upload files yet.

Out of scope here: uploading files. The builder makes a template that runs later, and a PDS deletes uploaded blobs that no record points to. So a file uploaded while building the template would be gone before the automation writes the record. A real blob input needs the template to keep a source (a file stored by Airglow, or a URL), and the action to upload it at run time, the way bsky-post does for link-card thumbnails with uploadBlob. That needs a design decision and gets its own issue.

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

loop:queued cid=bafyreiczknu7joeorleby2qi6sfxizrsx3tmfpuoz2iolj25ofhr6uvtjm

Working on this, attempt 1.

loop:claim run=contribute-20261028-164426-3mysmqqojup22 branch=loop/3mysmqqojup22 attempt=1

fix(form): collapse optional blob fields in the record builder

Blob fields (a profile's avatar and banner, an image embed's image) now get their own blob node type in lib/lexicons/schema-tree.ts, instead of falling into unknown. The record builder shows an optional blob collapsed behind a "+ Add <name>" button, the way #26 does for optional objects. A required blob, or one you add, gets the raw JSON editor with a hint that the builder can't upload files yet. unknown still covers cycles, depth cutoffs, missing refs and other unmodelled types, and keeps its raw JSON editor.

What changed #

  • lib/lexicons/schema-types.ts / schema-tree.ts: new BlobNode ({ type: "blob", description? }), built from lexicon blob properties.
  • app/islands/RecordFormBuilder.tsx: blobs serialize and deserialize the same way unknown did (raw JSON text, so saved templates load as before). The new BlobField:
    • optional and not set: label, description, and a "+ Add avatar" button;
    • added or saved: a textarea, the description, the upload hint, and "Remove avatar" when optional;
    • required, inside an array item, or ticked in patch mode: the textarea and the hint, with no Add or Remove.
    • Focus: Add moves focus to the new textarea, and Remove moves it back to the Add button, so it never drops to <body>.
  • lib/lexicons/record-path.ts: a path under a blob (avatar.mimeType) is still open, as it was when blobs were unknown, so the undeclared-path warnings don't start flagging valid blob paths.

Verification #

  • pnpm run check: passes.
  • pnpm run test: passes (2746 tests).
  • New tests:
    • schema-tree.test.ts: a blob becomes a blob node, while cid-link stays unknown.
    • field-types.test.ts: a path under a blob is open.
    • RecordFormBuilder.test.tsx, new "blob fields" block:
      • an optional blob is collapsed and not emitted;
      • an optional unknown keeps its textarea;
      • Add → type JSON → emitted, then Remove → dropped;
      • focus moves to the textarea on Add and back to the Add button on Remove;
      • a saved blob opens expanded with its value;
      • a required blob in an image item shows the editor and the hint, with no Add or Remove;
      • patch mode opens the editor as soon as the field is ticked.
  • Keyboard behaviour is covered only by the happy-dom tests above. There is no browser in this container, so I could not try it in a real one.

Acceptance criteria #

Decisions you can reverse #

  • After "+ Add avatar", focus goes to the new textarea, because that is where you type next. Optional objects send it to their Remove button instead.
  • An added optional blob shows the editor and the upload hint, so it looks like a required one apart from the Remove button.
  • BlobNode leaves out the lexicon's accept and maxSize, because nothing uses them yet.

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

loop:pushed run=contribute-20261028-164426-3mysmqqojup22 sha=f82b62f154a79f4b3ac3b83141bc4dc2aa2595fa

Verdict: changes-requested. The branch does what #59 asks: a blob node type, optional blobs collapsed behind "+ Add <name>", required blobs on the raw JSON editor with an upload hint, and unknown left as it was for cycles, depth cutoffs and missing refs. Check and tests pass. One thing needs fixing: the new hint tells users to "enter the blob's JSON or a placeholder for one", but a placeholder typed into that editor gives a template that saves as valid and then fails on every run.

What I checked and how:

  • I ran pnpm run check (exit 0) and pnpm run test (178 files, 2746 tests, all pass).
  • I listed every reader of SchemaNode / node.type:
    • RecordFormBuilder.tsx: initState, initItem, isBareSlot/stepInto, deserializeNode, serializeNode, FieldRenderer.
    • record-path.ts, and through it record-path-warnings.ts and AutomationForm's undeclared check.
    • AutomationForm.tsx: walkArrayLevels and flattenItemFields. A blob child is now listed with type "blob" rather than "unknown". That only feeds chip descriptions, so it is harmless.
    • self-pattern.ts, field-examples.ts, and typeFromSchemaField, which sends anything that is not a primitive to its default case.
    • load-record-schema.ts. Its cache stores the raw lexicon, so the tree is rebuilt fresh and no stale unknown nodes come back.
    • None of these misses a case.
  • Patch mode, union variants and array items get optional=false or optional=!isRequired correctly.
  • Focus: Add moves to the textarea and Remove moves back to the Add button, never to <body>. The accessible names ("+ Add avatar", "Remove avatar") tell neighbouring fields apart, and the textarea is labelled through FieldLabel htmlFor.
  • Saved templates still load the way unknown did.
  • Real-use check: the likeliest use of a blob slot is copying an existing blob, such as a profile's avatar from the trigger record or a fetched record. In a throwaway happy-dom test I used the builder to add avatar, typed {{event.commit.record.avatar}}, and passed the emitted template through validateTemplate and then renderTemplate, with the event record carrying a real blob object.
    • The builder emits "avatar": "{{event.commit.record.avatar}}" (quoted), and validateTemplate reports it valid.
    • renderTemplate then throws "Failed to parse rendered template: Expected ',' or '}' after property value". A quoted placeholder that resolves to an object is pasted in without escaping, so the JSON breaks.

Changes:

  1. Make the blob hint in BlobField (app/islands/RecordFormBuilder.tsx) true. Do one of these two:

    • (a) Drop "or a placeholder for one" from the hint, so it only says the builder can't upload files yet and the blob's JSON goes here.
    • (b) Keep the wording and make a placeholder work. In serializeNode, give case "blob" its own branch: const str = toStr(value).trim(); if (LONE_PLACEHOLDER_RE.test(str)) return markBareValue(str); return parseRaw(str);, leaving case "unknown" as it is. In isBareSlot, also accept node?.type === "blob" so the bare placeholder loads back into the form instead of falling to the raw-template editor.

    I tried (b) in a throwaway test, starting from a fresh builder:

    • Add avatar, then type the placeholder. The builder emits "avatar": {{event.commit.record.avatar}}, validateTemplate accepts it, and renderTemplate returns the real blob object.
    • Mount again from that saved template. The form loads, not the raw-template fallback, with the placeholder in the avatar textarea.
    • Remove, then "+ Add avatar" again, then type the placeholder. The same template comes out.
    • vp test app/islands lib/lexicons still passes (488 tests).

    With either option, add a test: for (a), the hint text; for (b), that a placeholder in a blob emits bare, renders to the blob through renderTemplate, and round-trips through initialTemplate.

loop:verdict run=review-20261030-092920-59 sha=f82b62f154a79f4b3ac3b83141bc4dc2aa2595fa verdict=changes-requested

Fix round 1: the review asked for changes.

loop:fix run=contribute-20261030-093327-59 round=1 issue=59

fix(form): collapse optional blob fields in the record builder

Blob fields (a profile's avatar and banner, an image embed's image) now get their own blob node type in lib/lexicons/schema-tree.ts, instead of falling into unknown. The record builder shows an optional blob collapsed behind a "+ Add <name>" button, the way #26 does for optional objects. A required blob, or one you add, gets the raw JSON editor with a hint that the builder can't upload files yet. unknown still covers cycles, depth cutoffs, missing refs and other unmodelled types, and keeps its raw JSON editor.

What changed #

  • lib/lexicons/schema-types.ts / schema-tree.ts: new BlobNode ({ type: "blob", description? }), built from lexicon blob properties.
  • app/islands/RecordFormBuilder.tsx: blobs serialize and deserialize the same way unknown did (raw JSON text, so saved templates load as before), with one difference. A blob holding nothing but a placeholder, such as {{event.commit.record.avatar}}, is emitted bare ("avatar": {{event.commit.record.avatar}}), so the blob object it resolves to renders into the record. isBareSlot accepts a blob slot, so the saved template loads back into the form. The new BlobField:
    • optional and not set: label, description, and a "+ Add avatar" button;
    • added or saved: a textarea, the description, the upload hint, and "Remove avatar" when optional;
    • required, inside an array item, or ticked in patch mode: the textarea and the hint, with no Add or Remove.
    • Focus: Add moves focus to the new textarea, and Remove moves it back to the Add button, so it never drops to <body>.
  • lib/lexicons/record-path.ts: a path under a blob (avatar.mimeType) is still open, as it was when blobs were unknown, so the undeclared-path warnings don't start flagging valid blob paths.

Verification #

  • pnpm run check: passes.
  • pnpm run test: passes (2747 tests).
  • New tests:
    • schema-tree.test.ts: a blob becomes a blob node, while cid-link stays unknown.
    • field-types.test.ts: a path under a blob is open.
    • RecordFormBuilder.test.tsx, new "blob fields" block:
      • an optional blob is collapsed and not emitted;
      • an optional unknown keeps its textarea;
      • Add → type JSON → emitted, then Remove → dropped;
      • focus moves to the textarea on Add and back to the Add button on Remove;
      • a saved blob opens expanded with its value;
      • a required blob in an image item shows the editor and the hint, with no Add or Remove;
      • patch mode opens the editor as soon as the field is ticked;
      • a placeholder typed into an added blob is emitted bare, passes validateTemplate, renders to the real blob through renderTemplate, and loads back into the form (not the raw-template editor) with the same template emitted. This test fails without the serializer change.
  • Keyboard behaviour is covered only by the happy-dom tests above. There is no browser in this container, so I could not try it in a real one.

Fix round 1 #

The review found that the upload hint ("enter the blob's JSON or a placeholder for one") was not true: a placeholder was emitted quoted, so the template saved as valid, then failed on every run when the rendered blob object broke the JSON. I took the review's option (b) and kept the wording: a lone placeholder in a blob is now emitted bare and round-trips, and a test covers it. unknown fields are unchanged.

Acceptance criteria #

Decisions you can reverse #

  • After "+ Add avatar", focus goes to the new textarea, because that is where you type next. Optional objects send it to their Remove button instead.
  • An added optional blob shows the editor and the upload hint, so it looks like a required one apart from the Remove button.
  • A blob holding only a placeholder is emitted bare (the review's option (b)). The other choice was to drop "or a placeholder for one" from the hint and leave placeholders unsupported.
  • BlobNode leaves out the lexicon's accept and maxSize, because nothing uses them yet.

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

loop:pushed run=contribute-20261030-093327-59 sha=1c4479d0b34fe5f991e45c7cfcda5bae53f6aff0

Verdict: merge. The branch does what #59 asks. Blobs now have their own blob node type, optional blobs are collapsed behind "+ Add <name>", and required blobs keep the raw JSON editor with a hint that the builder can't upload files yet. unknown still covers cycles, depth cutoffs and missing refs, and keeps its raw JSON editor. Round 1's one change is fixed using its option (b): a lone placeholder in a blob is now emitted bare, so the hint's "or a placeholder for one" is true. pnpm run check and pnpm run test both pass.

What I checked and how:

  • Check and tests: I ran pnpm run check (exit 0) and pnpm run test (178 files, 2747 tests, all pass).
  • Round 1's fix: I read the diff from f82b62f to 1c4479d.
    • serializeNode has its own case "blob". A lone placeholder goes through markBareValue, and anything else goes through parseRaw.
    • isBareSlot accepts blob.
    • case "unknown" is unchanged.
    • The new test covers emitting the placeholder, validateTemplate, renderTemplate returning the real blob, and reloading through initialTemplate.
  • Earlier work: the round-1 fix leaves the round-1 findings intact: the readers of SchemaNode.type, the open status in record-path.ts, and the Add/Remove focus handling. I grepped every "unknown" and node-type reader again, and none of them misses a blob case.
  • Real use, in a throwaway happy-dom test (since deleted):
    • Image embed: I used app.bsky.feed.post with an embed union → app.bsky.embed.images → images[] → image blob, and loaded a saved template with a bare {{event.commit.record.embed.images.0.image}} in it. The form loaded, not the raw-template fallback. stepInto resolves the union variant and the array item, the textarea holds the placeholder, and the emitted template is identical to the saved one.
    • Older templates: I loaded a template saved in the unknown era, with a quoted "avatar": "{{event.commit.record.avatar}}" (which used to fail on every run). It loads, and is now emitted bare, so the change repairs these templates rather than breaking them.
  • Keyboard and focus: both controls are native buttons. Add moves focus to the textarea and Remove moves it back to the Add button, never to <body>. The accessible names ("+ Add avatar", "Remove avatar") tell neighbouring fields apart, and the textarea is labelled through htmlFor.
  • Scope: nothing outside the issue changed: no owner-only files and no migrations.

One thing I noticed that does not block this branch. If the record behind a bare placeholder lacks the value (for example, a trigger profile with no avatar), the rendered JSON is "avatar": }, and that run fails. Bare integer and boolean slots on main already behave this way. It is not new here, and fixing it would mean changing how renderTemplate treats a missing bare value. That is outside #59, so the owner may want a separate issue for it.

loop:verdict run=review-20261030-094012-59 sha=1c4479d0b34fe5f991e45c7cfcda5bae53f6aff0 verdict=merge

Merged main into the branch: c36f86c48833ac4409b459e3ced87d3fb2f41326.

loop:carry from=1c4479d0b34fe5f991e45c7cfcda5bae53f6aff0 sha=c36f86c48833ac4409b459e3ced87d3fb2f41326

Merged main into the branch: e61d13126a72edd4fceb091447098f1c46425f0c.

loop:carry from=c36f86c48833ac4409b459e3ced87d3fb2f41326 sha=e61d13126a72edd4fceb091447098f1c46425f0c

Merged onto main as 9b090a8c912351348f4bc44094d272e08cd9fce1.

loop:merged sha=9b090a8c912351348f4bc44094d272e08cd9fce1 head=e61d13126a72edd4fceb091447098f1c46425f0c

Sign up or Sign in to add to the discussion.