CodeMirror markdown editing, and images as PDS records #
Both halves of the goal, built on the approved plan. Branch radial/impl-da42ce2a5b79,
commit d33626e5facfc569e6327bb57c77138d2482efc1.
The wire type #
packages/lexicons/lexicons/com.disnetdev.radial.image.json — a tid-keyed record with a
required blob and createdAt, and optional bounded alt and name. The blob accepts four
raster containers (image/png, image/jpeg, image/webp, image/gif) with a 10 MB ceiling,
matching the existing artifact blob limit. SVG is deliberately absent: through an <img> it
is still a document, with script, external references and an unbounded decode.
Both values are read back out of the lexicon at runtime (IMAGE_LIMITS in @radial/core), so the
upload form, the writer, the OAuth scope and the validator cannot check four different numbers.
The locator, and what a reader may fetch #
packages/core/src/image.ts is the pure protocol half: format, parse, catalogue, blob URL. A body
refers to a picture as  — a record URI, never
an address — for two reasons stated in docs/design.md §4:
- atproto reclaims a blob no record references, so the record is what makes an upload storage. The record is therefore written before the markdown is inserted, and a failure between the two is reported as "the image was uploaded but not saved, nothing was inserted" rather than as a partial success.
- a
getBlob?did=…URL pins whichever PDS the author was on that day; a record URI names the author, and the DID is resolved to its current PDS at read time.
Parsing is strict and closed: only an image-collection at:// URI with a DID authority and one
key-safe path segment — no query, no fragment, no userinfo, no second segment, no other scheme.
The blob CID always comes from the record that was read and validated, never from the markdown, so
this is not an arbitrary-fetch primitive with a Radial scheme painted on it.
The fold #
MaterializedIndex.images holds every trusted member's image records in the usual
(createdAt, uri) order, bound to a space by membership exactly as an agent record is. It exists
so a reader can resolve a locator without a second round trip, and nothing downstream reads it.
The golden scenario now carries three image records (two trusted, one from the since-removed
member); materializer.test.mjs asserts they are order-independent, that the removed member's is
dropped like everything else they wrote, that none of them collects an "unresolved" diagnostic —
and, as the real gate, that removing every image record leaves the rest of the index and the
convergent digest byte-identical.
Writing #
radial image create --blob JSON [--alt] [--name]in the sidecar: the record half only. It reads no files — a browser has none, and teaching the single shared write path to take binary would have meant teaching the turn socket to take it too. The socket's allowlist already refuses it, which is asserted.Credentials.uploadBlobin the UI, backed byRepoWriter.uploadBlobrather than a second HTTP call.uploadImage()inwrite.tsvalidates locally, uploads, writes the record through the normalrunClipath, folds the acknowledged record optimistically, and returns the URI plus the markdown.- File checks are local and happen before any network I/O: the container's own magic number (not
the browser's filename-derived MIME type), then the lexicon's allowlist and ceiling. A JPEG named
.pnguploads as a JPEG; an SVG named.pngis refused. RADIAL_WRITESgains the image collection, and the scope gainsblob?accept=image/gif&accept=image/jpeg&accept=image/png&accept=image/webp— the four formats and no more, so an SVG is refused by the grant as well as by the writer. Existing sessions get the usual "sign out and sign in again" scope-recovery message until reauthorized.
The editor #
MarkdownEditor.svelte is the only integration point, over editor.ts (the whole CodeMirror
assembly, behind one dynamic import()). It keeps the contracts a textarea gave for free:
- the bound value is a plain string, so
$state, draft-holding,trim(), submit-disable and the command arguments above it are unchanged; - it degrades to a textarea — both are in the DOM, one hidden, so the swap carries the text, the caret and the focus; if the chunk never arrives the field still works;
- Escape leaves the field and ⌘↵ submits, as explicit keymaps, because a contenteditable inherits neither. Everything else (Enter, Tab, arrows, IME) is left to CodeMirror.
disabledreconfigures a compartment rather than rebuilding, so a busy state does not drop the undo history; the view is destroyed on unmount.
Migrated: NewGoal, Composer, UnitDetail's reply, NewRequest, AskReview, AskAnswer,
NewType, Verdict. Titles, paths, line numbers, output descriptions and the space description
stay native controls — they are not markdown. Image upload is enabled on goal bodies, messages and
replies, and request briefs; left off for artifact-type templates and review findings, where
what a picture means downstream has not been specified. Submit is held while a file is in flight.
Reading #
Runs.svelte checks the locator before safeImage, which still refuses it, and renders through
RadialImage.svelte. Resolution (image-resolve.ts) is one cached promise per record, dropped when
the tab changes space; it renders through an ordinary public blob URL in an <img src> with
loading="lazy" and referrerpolicy="no-referrer" — no object URLs, no fetched bytes through the
app. A locator that resolves to nothing draws the existing placeholder with the reason in its title.
External https: images and every existing refusal (data:, javascript:, relative) are unchanged.
Downstream #
A turn's bundle carries prose verbatim, so a locator reaches an agent as text. bundle.md now ends
with an ## Images section listing each image the bundle's prose refers to, once, with the two
steps that turn a record URI into bytes — so a screenshot pasted into a brief does not disappear
from turn context.
Tests and checks #
pnpm codegen (clean git diff after), pnpm typecheck, pnpm lint, pnpm test, pnpm build
all pass. 194 core, 32 atproto, 20 ingest, 51 sidecar, 544 daemon, 421 + 25 UI.
New coverage: locator round trips and a list of malformed/hostile forms that must not resolve;
MIME sniffing and the upload refusals; image-record permutation and non-effect on the fold and the
digest; image create including the turn-socket refusal; the OAuth blob scope; a UI write
integration (upload → record → prose record → cold re-read → out-of-order ingestion) against
fake-pds.ts, extended with uploadBlob and getBlob; the resolver against that same PDS; and a
new component suite that mounts the editor for real.
Two testing changes worth knowing:
vitestis now two projects —pure(Node, no DOM) oversrc/**/*.test.ts, andcomponents(jsdom,svelte's browser condition) oversrc/**/*.svelte.test.ts.jsdomis a new dev dependency of@radial/uionly.test/browser-bundle.test.mjsgains a rule rather than a byte budget: every@codemirror/@lezerimport undersrcmust be erased or dynamic, and onlysrc/lib/editor.tsmay reach for the library. The built bundle bears this out — the eager chunk is 6 kB and CodeMirror is 196 kB in a chunk nothing on the read path loads.
Docs #
docs/design.md §4 (image-record ownership, locator resolution, blob retention, allowed formats,
the explicit non-effect on the fold, availability following the author's PDS),
packages/lexicons/README.md (additive compatibility and the blob-reference rationale),
packages/ui/README.md (the new modules and which surfaces take images), DESIGN.md (the editor as
a field rather than a code editor), CLAUDE.md/AGENTS.md (the two vitest projects), and the
RepoWriter.uploadBlob comment that said Radial uploaded no blobs of its own.
Remaining risks #
- Not verified against a live PDS. Every claim above is held by the automated suite against
fake-pds.ts; the plan's manual acceptance list (a real upload, a reload in a signed-out browser, a moved PDS, keyboard-only image insertion, narrow-screen layout) has not been walked. - Orphaned blobs are accepted for v1. An upload whose record write fails is reported honestly and left for the PDS to reclaim; an image record whose markdown is later deleted is left standing, because records are immutable by convention and reclamation is a retention design.
- Old sessions must re-authorize before they can add an image — the scope grew.
- One more collection to poll.
radialCollectionsnow includes images, so a cold sync costs one extralistRecordsper member repo. - jsdom is not a browser. The component suite mounts CodeMirror with layout stubbed out
(
jsdom-layout.ts), so it proves the wiring — value, reset, disabled, teardown, keymaps, selection insertion, paste/drop — and proves nothing about measurement, mobile keyboards or real IME.