A collaborative coding-agent orchestrator for atproto radl.app
README.md

@radial/ui #

The Radial web app: a SvelteKit SPA that indexes records in the browser. Its layout follows the approved comp (docs/mock/phase6-goals.html).

pnpm --filter @radial/ui dev       # vite dev server on 127.0.0.1
pnpm --filter @radial/ui build     # static bundle in build/, plus the radial-ui binary in dist/
pnpm --filter @radial/ui preview   # serve the built bundle with vite
pnpm --filter @radial/ui start     # build, then serve it with radial-ui on 127.0.0.1:4321
pnpm --filter @radial/ui og        # redraw static/og.png, the default link-preview card

It is a pure SPA with no view server (docs/phase6-ui-plan.md §1): adapter-static emits one shell, ssr = false, and every route resolves in the browser. The UI is a second implementation of Radial rather than a client of one — it runs the same materialize() the daemon runs, the same timeline() fusion, and writes through the same runCli() the radial CLI uses, so a browser tab and a daemon that saw the same records compute the same answer. test/browser-bundle.test.mjs fails if a node:* import creeps back onto that path.

Where things are #

src/app.css The design system: tokens and primitives, ported from the comp. Deliberately global — components carry structure, this file carries appearance.
src/lib/space.ts The Space type a view is drawn from, and lookups over it. Knows nothing about where records came from.
src/lib/space-navigation.ts The navigation boundary for spaces: URL changes may open a fold, while successful picker choices replace the URL with that fold's neutral route.
src/lib/session.svelte.ts Which space this tab is looking at: opening one, the visibility-aware poll loop, the remembered space list. The only module that touches the network for reads. A private space runs the same loop over this browser's own replica: the poll is the device directory, the catch-up is over this tab's own endpoint (private-transport.ts), and a URI counts as private only when there is a replica of it here. A replica no peer has answered yet is waiting rather than failed — the loop keeps running and the tick finishes the open (docs/adr-private-mode-iroh.md §24). Visibility splits on the private path: a hidden tab that other members can reach keeps a reachability beat — refreshAddress() and a directory poll, no fold, no repaint — because the directory it polls is what authorizes its peers (§25).
src/lib/auth.svelte.ts, write.ts The OAuth session, and runCli() over a RepoWriter built on it. The only two modules that write. In a private space the same runCli() runs over an EnvelopeWriter and the local resolver instead, so no command knows which bus it is on — including writeGenesis, which seals a space into the replica that was opened to hold it (private-mode.ts founds one).
src/lib/store.ts, src/lib/idb.ts RecordStore and SyncStateStore over an in-memory mirror hydrated from IndexedDB. Synchronous reads, background writes, one database per space. Two provenances of one version reconcile through core's shared mergeVersion, so a tab folds signed records exactly as the daemon does. A flush writes every pending table in one transaction, and Durability says what a refused write means: cache on the public path (a cold scan), required on the private one (a lost record, so settle() rejects).
src/lib/private-store.ts A private space's durable half in this tab (design §18): EnvelopeStore, WantList and PrivateBlobStore, the third implementation of each beside memory and the daemon's SQLite. An envelope and its projection are queued by one put into the record store's own queue, so they land in one transaction or not at all. openPrivateSpaceStores() refuses a browser that will not persist rather than degrading to memory — the envelopes are the only copy there is (docs/adr-private-mode-iroh.md §17).
src/lib/devices.ts This browser's private-mode signing keys (design §18): core's DeviceKeyStore over IndexedDB, with non-extractable keys — the handle is persisted, never a serialized secret. The rules (derived key ids, and rotation retiring both the local key and its published address) live in core; only where the key lives is here. Refuses to mint one when the browser will not store it, rather than publishing a device record that stops existing on reload — the same refusal private-store.ts makes about a replica.
src/lib/private-space.ts The replica itself (design §18): accepting a ticket, opening one, and the writer that writes into it. The browser's space.json is one row in the space's own database, carrying the ticket's pin; the directory poll reads members' PUBLIC repos for device/deviceAddress and nothing else; the resolver and the join bookmark are @radial/sidecar's, shared with the CLI rather than reimplemented. Opening one attaches it to this tab's endpoint, which is where catch-up comes from; LocalOnlyBus stands in when there is no endpoint, and localOnly says so rather than reporting an unasked bus as an empty space (docs/adr-private-mode-iroh.md §22).
src/lib/private-transport.ts This tab's transport endpoint (docs/adr-private-mode-iroh.md §22): the lazily-imported WASM binding, the endpoint identity kept in one IndexedDB row, and refreshAddress() — which publishes a deviceAddress only when this tab has actually moved, so a per-tick call costs no PDS round trip. The lifecycle itself is core's PrivateEndpoint, the same one the daemon runs; only which binding and where the secret lives are here. One endpoint per tab, memoised on the promise so two spaces opened in one tick cannot bind two endpoints with one identity — and one transport per profile beneath them (private-tabs.ts).
src/lib/private-tabs.ts One transport per browser profile, shared by every tab of it (docs/adr-private-mode-iroh.md §26). The endpoint identity is a per-profile IndexedDB row, so two tabs binding it independently produced two endpoints with one node id and a relay routed to whichever registered last. navigator.locks elects one tab to bind; one BroadcastChannel relays the others' dials, frames and inbound-connection offers through it. The holder owns the transport and not the replica — every tab keeps its own PrivateEndpoint, bus, connections and ingestor over its own stores. A promotion re-reads the identity so the next holder is the same node id; relayed links are orphaned so they throw and the buses above redial. MemoryTabNetwork is several tabs in one process, for the tests.
src/lib/private-mode.ts Private mode as a person meets it (docs/adr-private-mode-iroh.md §19, §23): the four-point privacy disclosure ADR §3 requires before anything is published, the device rows — this browser's key store joined to the directory the fold read, including whether each device is addressed, retired, or has never said, which is what lets the space page keep the retired ones in a closed disclosure of their own — and the session-wired actions (accept a ticket, found a space, mint a ticket, rotate, retire). Founding runs sidecar/private-cli.ts's sequence step for step and bookmarks last, so a create that could not seal the genesis announces nothing; minting reads the fold and writes nothing, and always names the founder's published key. The rules stay in core; what is here is the wiring, the explicit no-device-revocation compromise, and the one action that answers something going wrong — retiring a device withdraws its published address so no replica connects to it, and the surface says in as many words that it disconnects rather than un-counts (docs/adr-private-mode-iroh.md §29).
src/lib/identity.ts DID → PDS, DID → handle and DID → profile picture resolution, cached. A handle is shown only when it resolves back to the DID that claimed it. cachedAvatars() reads that cache and asks nobody, which is how the sign-in field puts a face on a remembered identity from a screen with no session.
src/lib/handle-suggest.svelte.ts What a sign-in field offers while somebody types: this browser's remembered identities, then the handles a public appview's typeahead names for the prefix. One debounced, abortable ask per field, kept out of the sequence a stale answer could win, and visibleSuggestions for which of the merged list the text in the field is about. A row is a handle, the DID it belongs to and the profile picture published for it — all three already in the answer being made, so the face beside a suggestion costs no second request, and a merged row keeps its place while taking the picture the other half of the list knew. It is a dropdown and never a decision — signing in still resolves the handle through auth.svelte.ts, nothing here is fetched as a record, and a DID or a PDS address is never sent. PUBLIC_RADIAL_HANDLE_TYPEAHEAD=off turns the directory half off; both surfaces disclose it while it is on.
src/lib/units.ts Presentation over timeline(): a row's text, what quick find searches it by, its badges, its status disc, and the two cross-target capture relations no single target's index can see. Also the tip/open-request split — requestState() and the isClaimed/isAssigned/isOpen/isAwaiting predicates every list groups and counts by, because UnitView.state describes what LANDED and stays judged while a successor runs.
src/lib/requests.ts, verdicts.ts, admin.ts What each surface may offer and what it writes, as pure functions: the ⊕ menu, the review form and its findings, space administration. Tested without rendering anything.
src/lib/docs.ts The tiered doc viewer: which tier a unit is drawn in, the second drift signal beside staleness(), the roadmap tier's derived sections, and the two commands editing a page writes. A tier is a REGISTRY NAME and this is the only module that knows the two — user-doc and roadmap-doc are artifact types, so the feature adds no record type and every other project-scoped type falls into the dev tier with no code change. Two rules live here rather than in a component: an edit request always names its own author as assignee (an unassigned one is claimable by any operator's daemon), and a new version re-pins its sources to their current heads, which is the only thing that clears source drift.
src/routes/p/[project]/docs/, src/lib/components/DocReader.svelte, DocEditor.svelte The surface: tier navigation, a page index — grouped by the kind of document, with disclosures where a tier holds more than one registry type, which is the dev tier and only it — and the reader — prose first, then the provenance that makes the page checkable, then the roadmap's road-ahead and road-travelled sections derived from live goal state. The editor is two ordinary runCli commands (request create, artifact post) through write.ts, so a private space gets doc editing through the substituted writer with no branch anywhere; a save that failed between them is offered as a resume rather than written twice. A unit holds at most one open request, so Edit and “Finish it” are the SAME action — Edit continues the open request rather than forking a second one that would leave the first open forever — and the way out is a request retract tombstone, which is also how a member changes sources recovery cannot. Where a tier's type is not registered the pane is a setup card, because materialize() drops a request no registry entry names — the entry is load-bearing, not decorative.
src/lib/labels.ts The reading side of goal labels: the argv a label editor writes (always --set, always the whole set — the record has no add or remove), the space's label vocabulary with counts (which IS the registry: there is none on-protocol), and a chip's hue as a pure function of its text. The normalization rule is @radial/core's, shared with the sidecar so a label typed here and one typed at a shell cannot differ.
src/lib/filters.ts, filters.svelte.ts Narrowing a goal list by label and state, and the URL round-trip that makes a narrowed list a link. Pure derivation — nothing here writes, and nothing reads prose: the predicate reads GoalView.labels and GoalView.ended and nothing else. Composed with quick find rather than replacing it, and the labels' own text joins the corpus matches() searches.
src/lib/grouping.ts Arranging that same list once the filter has decided what is in it: one section per label, in the vocabulary's own order, with the unlabelled goals last. A goal stands under every label it carries — a set has no primary member for this module to invent one from — so the sections can hold more rows than the list, and each is counted where it stands. Grouping is not narrowing: it stays per-tab and out of the URL, because a group parameter would be one more thing viewHref has to reproduce exactly for the rail's active-view highlight to keep matching.
src/lib/views.ts A saved view: that same filter under a name, kept as a personal on-protocol record. viewFilter() is the single bridge back to goalMatchesFilter, so a view and the filter bar can never disagree about what it holds; the rest is myViews, its count, and the argv that saves (or re-saves, which is the edit) and tombstones one.
src/routes/goals/ Every goal in the space in one list, narrowed by the filter bar — where a space-wide saved view lands, since a view cut by label alone spans projects and no existing list is "the goals matching this".
src/lib/guests.ts Comments from people who are not members: the Constellation backlink query, the re-validation that makes the index a hint rather than an authority, and the rows the Community section draws. The only module that reads a non-member's repo, and nothing it returns enters the fold.
src/lib/roomy.ts Resolves a Roomy appserver DID and defensively reads a bounded public thread in the browser. Live messages stay view-time state; only a member-confirmed snapshot enters agent context. Messages come back oldest-first whichever way the appserver paged them, because the panel below opens at the end of the list. A body is decoded by @radial/sidecar's roomyMessageText rather than here — a Roomy rich-text body is base64 on the wire, and the reader and thread import have to agree about what it says.
src/lib/components/RoomyThread.svelte That room as a framed window on the goal page (DESIGN.md, the foreign-thread window): a log that scrolls inside itself, opened at the newest message, holding the newest 40 with the rest a press away. Selecting is the mode — a hover-revealed checkbox per message, shift-press for a run, and one bar at the foot that writes a signed snapshot per selected message and reports how far it got if one fails. Below the window, what already crossed over, each with the Withdraw that tombstones it.
src/lib/userinput.ts The Userinput section: defensive parsers for a foreign feedback board, the trust fold over its grants and statuses, and the editable import draft. Authority is honoured only against the version a strongref pins; presentation resolves by URI, and latest-wins is decided by core's comparators rather than the reader's locale. discoverUserinput reads only the board — the space's own goals are joined onto the result by feedbackRows at the point of drawing, so the network fan-out is a function of the board's address and not of the sync tick. Browser-only, and a member pressing Create is the only way any of it reaches the fold.
src/routes/p/[project]/userinput/ The section itself. A piece of feedback is drawn with the unit row's own furniture — disc, tail, title, drawer — because it is answering the same question every other row answers; the body inside is the stranger's, so it is quoted in .brief, verbatim, through no markdown pass at all. The board's address is taken as either its page on userinput.app or the at:// URI (feedbackSourceUri in @radial/sidecar, one parser for the form and the CLI).
src/lib/issues.ts Issues on a project's tangled repository, offered as goals: the repository's own DID, the appview's issue list, the re-read from each filer's own repo that makes the index a hint, and which issues the space has already taken up. Every open issue a look can read comes back, imported or not — an imported one is a row like any other — and taken only orders how a bounded look spends its budget. issueRows joins a look to the fold at the point of drawing, exactly as feedbackRows does, so the network fan-out is a function of the remote and not of the sync tick. Another module reading outside the space's members, and nothing it returns enters the fold either.
src/routes/p/[project]/tangled/ The section itself, and the Userinput page's twin down to its markup: one unit row per open issue — disc, tail, the forge's own state as a flat chip, title, drawer — with the issue's body quoted verbatim in .brief and never through a markdown pass. Importing writes nothing; it opens the goal composer seeded with the issue's words (NewGoal.svelte), which is where a member reads them, edits them, and signs the goal.
src/lib/diagnostics.ts index.ignored and index.edits, grouped for display.
src/lib/build.ts Which copy of the app this tab is running — the constants scripts/build-stamp.mjs reads at build time and vite.config.ts injects, plus the origin the bundle was built for. Pure, and every input is an argument, so the one runtime value (where the tab is actually served from) is passed in. See Build info below.
src/lib/keys.ts, focus.ts The keyboard map, and focus restoration.
src/lib/directory.ts Membership → the row an actor gets: kind, initials, disc color. Color is derived from the DID, never authored. An agent's artifactTypes here is the union of its profiles' effective lists for the space the directory was built over (agentTypesFor, design §11), so every picker filtering on it is already space-scoped.
src/lib/prose.ts The comp's small ## + backtick markup grammar. Parses to a structure the component interpolates — no {@html} anywhere near an agent-authored body.
src/lib/editor.ts CodeMirror, assembled and loaded on demand. The only module that imports it, and only through import() — test/browser-bundle.test.mjs fails if a static one appears anywhere under src.
src/lib/insertion.ts Where an uploaded image goes: carrying an insertion point across what the reader typed while the blob was on the wire, and refusing the one point that would nest two images. Pure, and held without a DOM.
src/lib/images.ts, image-resolve.ts What a file has to be before it is uploaded (magic-number sniffing, the lexicon's allowlist and ceiling), and the per-space resolver that turns a radial-image: locator into a blob URL on its author's current PDS.
src/lib/jsdom-layout.ts The two layout methods jsdom lacks and CodeMirror calls. Loaded only as the component suite's vitest setup file.
src/lib/meta.ts What a URL says about itself: the tab title, and the og:* a link preview draws. Pure text, shared by the browser and the edge worker so the two cannot disagree.
src/edge/ The Cloudflare Pages worker that upgrades those tags per record for /g/* and /p/* — see Link previews below. Beside the browser path, never on it.
src/lib/components/ Rail, pane bar, space picker, state circle, pie, badges, discs, unit rows and drawers, compose cards, smart lists.
src/lib/components/FilterBar.svelte, GoalGroups.svelte, LabelEditor.svelte The label surfaces. The bar narrows on two lines — every control a reader operates on the first (which goals, how they are arranged, which labels), and everything that appears in response to them on a second whose height is reserved, so pressing a chip never moves the list a reader is looking at. GoalGroups draws what survived, flat or in sections headed by plain text; the pressable pills all live in the bar. LabelEditor stands on a goal's meta row, opens its field in a panel placed out of flow rather than in the row itself (for the same reason the bar's second line is reserved) — beside the chip that opens it where there is room, a bottom sheet clear of the toast's strip where there is not — and writes the whole label set on every gesture, because the record has no add and no remove.
src/lib/components/MarkdownEditor.svelte The one editor every markdown field is. Bindable string in, string out; degrades to a textarea; optionally offers image upload.
src/lib/components/PrivateDisclosure.svelte, JoinPrivate.svelte, PrivateDevices.svelte The private-mode surfaces: the disclosure, the picker's ticket card (whose Join button does nothing until the disclosure is acknowledged), and the space page's section — what this replica holds, your devices, and everybody else's as the directory has them. Tickets are not here: one is how a person gets in, so it is minted on that person's row in Members.
src/lib/components/TicketPanel.svelte The one surface that displays a ticket (ADR §23) — fingerprint beside it, copy button, and the line asking for the fingerprint to be read back. Used by the invite and by a member row's Ticket button.
src/lib/components/SignInField.svelte The identity field every sign-in surface draws: the standard name/autocomplete="username" hints, and the app's own combobox over handle-suggest.svelte.ts — remembered identities and the directory's handles in one listbox that paints when an answer arrives rather than at the next keystroke. Each row carries the face the app draws every identity with (Disc.svelte): the published picture when the directory named one or this browser had already resolved it, and the derived disc when nobody did. One component, so a login page cannot ship the field without the semantics, the suggestions, the faces or the sentence naming who sees what is typed.
src/lib/components/NewSpace.svelte The picker's create form, public and private in one vector: --private is the only difference, and the disclosure gates the button when it is set.
bin/serve.ts radial-ui: serves the built bundle on loopback, nothing else.

Terminology the copy sticks to: a review is both the act and the record, and that is the only word used for it. A verdict is the review record's verdict field — approve or request_changes — shown as the badges approved and changes.

Opening a space #

After signing in, the picker lists the spaces your account created or joined, read from one listRecords call over your own repo. A space somebody else added you to is a grant in their repo, so the first time you open it by URI the app writes your own join record, and it is listed from then on. Reading a space needs no account at all: the picker also accepts a bare space URI, and includes the fixture (a built-in demo space) — both work signed out.

Public links carry the space record's AT URI in a space query parameter, so a logged-out visitor can open the linked public space directly. Opening does not join the space: after sign-in, the existing join bookmark is written only for an active member. Goal links minted before this parameter remain recoverable because the public goal record names its space. Older project and space-wide list links are ambiguous and return to the picker instead of guessing from this browser's history.

Every read is com.atproto.repo.listRecords against a member's own PDS. Records are cached in IndexedDB, so a warm reload costs one getLatestCommit per member and no rescan. While the tab is visible it polls every ten seconds; hidden, it stops and catches up when it becomes visible again. A failed poll leaves the last good index on screen and turns the pane bar's freshness chip amber — the view goes stale, never wrong — with the failure itself readable in the diagnostics disclosure at the foot of the pane.

Writing #

Writes go to your own PDS under your own atproto OAuth session. The tab holds a session, never a password, and the DPoP key is a non-extractable CryptoKey. The app never writes a record the index would ignore: a button exists exactly where the governing rule says the record would count (admin.ts, requests.ts, verdicts.ts), and where an action is withheld the UI says which rule withheld it.

The OAuth scope is derived from the collections runCli() writes, so shipping a new one — the three guest-comment records, most recently — leaves an already issued token short of it. The PDS is right to refuse; write.ts recognises that refusal and says what fixes it (sign out, sign in again).

A deployed origin needs one extra document — an atproto client id is the URL its metadata is served at — so PUBLIC_RADIAL_ORIGIN=https://radial.example pnpm build emits client-metadata.json into the build. Without it the bundle is complete and still signs in on a loopback host, where the client id encodes the metadata instead of pointing at it.

The identity field on both sign-in surfaces is one component (SignInField.svelte) and it suggests two things. The first is the identities this browser has signed in as before, kept in localStorage beside the "Continue as" buttons — free, and empty on a fresh profile. The second is what a public directory names for the prefix being typed (handle-suggest.svelte.ts), which is what a person signing in for the first time actually needs. That ask leaves the tab before anything is submitted, so both surfaces say where the suggestions come from, a DID or a PDS address is never sent, and PUBLIC_RADIAL_HANDLE_TYPEAHEAD is the knob: an appview of your own, or off to leave the field with the remembered identities alone. It is a dropdown and nothing more — the handle a person settles on is resolved by the same code whether they picked it or typed it in full, and PUBLIC_RADIAL_HANDLE_DIRECTORY is what governs that.

The app draws that dropdown itself. It was a <datalist>, which is the browser's popup rather than the app's: it opens on a keystroke and offers whatever options existed at that moment, and a directory's answer arrives a debounce and a round trip later. With something remembered to show in the meantime that still looked like a typeahead — but the fully logged out screen is exactly the one with nothing remembered, so the only suggestions it can offer were always the late ones, and none of them was ever displayed. A combobox and a listbox of the app's own paint when the answer lands, match on text the app normalizes (a leading @ is dropped for the comparison exactly as it is for the ask, which a datalist could not do), and take a suggestion from the arrow keys or a click. autocomplete="username" stays on the input for a password manager that does hold something.

Drawing it also means the rows can carry a face, which is the point of a list of handles: they are domains, and near-identical ones at that, and a person recognises their own picture before they have read one. The directory answers with the picture in the same response the handles come in, so a suggestion costs no second request; a remembered identity takes the one this browser resolved while it was signed in (identity.ts's cache, read locally — a logged-out screen resolves nothing to decorate a dropdown), or the directory's when it turns up in the same list. Nobody's picture is required: a row with none is the derived disc every identity in the app falls back to. A picture is a request all the same, to a host that is not the appview, so the sentence under the field says so.

Comments from outside the space #

A goal page has a Community section when an admin has turned guest comments on (setGuestComments, folded onto index.guestCommentsEnabled) — or when the space has blessed a comment on that goal, whether or not the switch is still on. It is the one surface in this app whose contents did not come from the fold, and it is built so that stays obvious.

The switch governs solicitation, not revocation, and showsCommunity() is where the difference is spelt out. Turning it off stops the backlink query and takes away the guest composer; it does not touch a blessComment already in the fold, and that record is in the bundle of every turn on the goal until a retractBless withdraws it. So blessed rows outlive the switch, along with the button that withdraws them — hiding them would leave agents reading prose with nowhere on screen to see it or take it back.

The path is: ask Constellation which records point at the goal, drop every hit whose DID is an active member (their messages are already in the thread), fetch each remaining one from its own author's PDS, validate it against the message lexicon, and check the record itself names this goal. The index says where; the record says what, and when they disagree the index loses. Bodies render through prose.ts like every other body — tokenizer only, no {@html}, no markup ever produced from text somebody else wrote.

None of it enters RecordStore, materialize() or the digest. Two materializers with the same records agree whether or not Constellation answered, which is why discovery is view-time and not ingestion — and why the section still draws what the space has blessed when the index is unreachable: a blessing carries the member's own snapshot of the body.

Blessing is the only control here that changes what an agent will read, so it is a two-step with the text in front of the reader before the second press. It writes blessComment — the guest's record pinned by uri#cid plus a copy of exactly those bytes — and from then on every turn on the goal carries it under ## Community comments in bundle.md, labelled with both DIDs and never merged into the thread. Withdrawing writes retractBless, which the blessing's author or an active admin may do, and takes it back out of every bundle. A guest who edited their comment after it was blessed is badged, because the screen shows the live text while the bundle carries the snapshot.

A comment can accumulate several blessings — "Bless it again" on a withdrawn row writes a second one, and two members can bless the same words independently — so blessedRows() folds them to one row per comment and a live blessing always outranks a tombstone on a different one. The row reports what the bundle actually carries, and Withdraw blessing names the record that is in it.

Signed-in non-members get a composer of their own. It writes an ordinary message into their repo — the same record a member's message is, through the same runCli path — which was always possible; what the toggle decides is whether the app asks.

Issues on the forge, as candidate goals #

A project whose remote is on tangled gets a Tangled item in the rail, beside Userinput and under the same project: the repository's open issues, offered as goals somebody could write. It is the Userinput section's twin, and drawn as one — a page of unit rows, each with a drawer holding the issue's own words — because it is the same kind of surface answering the same question about a different stranger's service, and its module (issues.ts) follows the same three rules guests.ts does. A candidate that somebody has already taken up keeps its row and says so, exactly as a piece of feedback does — it is read from its filer's repo like any other, because the fold holds an imported issue's URI and nothing else, so a look that skipped it could draw that row only in the tab that did the importing and only for as long as it stayed open. What taken decides is the ORDER a bounded look spends its budget in: the issues nobody has taken up first.

Nothing there is in the space, and no turn reads any of it. Ingestion polls member repos for com.disnetdev.radial.*, so an sh.tangled.repo.issue is in no RecordStore, no materialize() and no bundle — with no code at all. What crosses is a goal: importing does not write anything, it opens the ordinary goal composer with the issue's title and body in it, saying whose words they are, and the reader creates the goal. So what an agent reads is a record a member wrote, read first and edited if they wanted to. That is the whole prompt-injection answer, and it is why there is no one-press import: an issue tracker is open to anyone with an atproto account.

The goal carries source — {kind: 'tangled-issue', uri, cid}, the version that was read — and that is the only thing carried besides the text. It is what says an issue has been taken up for everybody in the space rather than just for whoever imported it: every row is read from its filer's own repo, and one this space has taken up says who took it up and links the goals it became. It is deliberately not in any turn bundle (core/test/bundle.test.mjs asserts that): an agent handed the at-uri could fetch the live issue and read comments and edits nobody reviewed.

The section does not look until the page is open, and opening it is the ask — the bargain the Userinput page already makes with somebody else's feedback board. Tangled's appview (api.tangled.org, the same Bobbin the daemon reads pull state from) has no SLA and is somebody else's service, and a project page that queried it on sight would also be telling tangled which repositories this browser reads; a rail item costs it nothing, and the page behind it is the first thing that asks it anything. Once open it looks once per remote, not once per sync tick: the space is republished every ten seconds whether or not a record moved, so everything the discovery effect reads is either a string that only changes when the answer would or is read under untrack — including this space's own goals, which make a look cheaper and must never make it happen. Refresh is the manual path. When it does look: the repository's own DID is resolved the way the daemon's adapter resolves it — the owner's handle to a DID, then the sh.tangled.repo record in the owner's repo, whose repoDid is a different DID from the owner's — the appview lists the issues, and every candidate is then re-read from the repo of whoever filed it and must still say it belongs to this repository. The appview says where; the record says what, and when they disagree the appview loses. An issue that cannot be re-read is counted and not shown.

Writing markdown, and pictures in it #

Every field that holds markdown is MarkdownEditor.svelte: goal bodies, thread messages and replies, request briefs, an artifact type's template, a review finding. Titles, paths, line numbers and one-line descriptions stay native controls — they are not markdown, and highlighting them would claim they are. The bound value is a plain string throughout, so what is written to a record is byte-identical to what the textareas wrote.

CodeMirror is loaded on demand (editor.ts), which is worth about 200 kB to a reader who never opens an editor; until it lands, and if it never does, the field is a textarea bound to the same string, and the swap carries the text, the caret and the focus. Escape and ⌘↵ are explicit keymaps, because a contenteditable inherits neither from the browser.

The browser's writing aids are the same fact and are handled the same way. A textarea spellchecks, autocorrects, capitalizes sentences and offers writing suggestions by default; CodeMirror turns all four off on its contenteditable. So the set is named once — WRITING_AIDS in editor.ts — and both halves carry it: the fallback as attributes, the editor through EditorView.contentAttributes. The facet rather than a setAttribute on contentDOM, because CodeMirror recomputes the whole content attribute set on every view update against its own cached values, and a hand-written attribute lives only until something makes that comparison unequal. The pair matters as a pair on a touch keyboard, where capitalization is the visible half of what a person calls autocorrect — enable one and not the other and a draft changes behaviour mid-sentence, the moment the chunk lands.

Prose in single-line fields is opted in one at a time, since Firefox's layout.spellcheckDefault checks multi-line fields only: a goal title, an imported goal's title, a space name, a private space's public label, an artifact type's one-line description. Identifiers stay off and say so where they are — a handle, a project name (it is also the URL), a git URL, a path, a check command, a ticket. Labels and saved-view names are left alone too: they are tags people re-use, not sentences, and squiggling a project's own vocabulary back at them teaches nothing.

Image upload is on for goal bodies, messages and replies, request briefs, and review findings — the surfaces whose records people read as prose. A finding is nested inside a review record rather than being a record of its own, and it needs no attachment field for that: the body owns the relationship, because the locator it contains names the image record directly, exactly as a goal body's does. So the review lexicon is unchanged, an old finding and a new one are the same shape, and the daemon's bundle writer — which already scans finding bodies — advertises the picture to the next turn for free.

It is off for an artifact type's template: a template is copied into every turn of that type, so what a picture in it means downstream is a separate question that has not been answered, and offering the button before it is would claim support that does not exist. It is off for an agent turn too, and structurally rather than by choice — a turn container holds no protocol credential and its socket takes JSON findings, not file bytes. Highlighting is on everywhere.

The findings editor is several markdown fields at once, so the hold that every other composer puts on one Post button is spread across all of them: while any finding is uploading, both verdict buttons say "Adding image…" and neither writes, and the uploading row itself cannot be removed — destroying the editor an upload measured its anchor against would leave a public, permanent image record with nothing referring to it. Every other row stays editable throughout. Rows are keyed by their own identity rather than by position, so removing one above an editor does not hand its undo history, caret and in-flight anchor to a different finding.

Adding a picture is two writes in a fixed order — the blob, then a com.disnetdev.radial.image record that anchors it — and only then is ![alt](radial-image:<record uri>) inserted, with the alt text selected.

Where it is inserted is decided when the reader drops, pastes or picks the file, not when the network answers: a pointer drop goes where the drop cursor said, and everything typed while the upload runs moves that point along with it rather than moving the picture. Several files at once go in one after another, each after the last rather than into the alt the last one left selected or edited. A selected range is replaced only by the first image that consumes it; any edit under a still-uploading range — including a racing drop — collapses it to a point, so the later image cannot erase what just landed. A caret parked inside an existing image is pushed past it. The alt is left selected only if the reader has not gone somewhere else meanwhile — an offer to correct a filename that arrives mid-sentence, or in another field, would eat the next few keystrokes. Both halves of the editor follow the same rules; the textarea recovers what changed by comparing strings, since it reports no change set.

Submit is held while a file is in flight, so a body can never name a record that has not landed. Files are checked locally first: the container's own magic number, not the browser's filename-derived MIME type, against the lexicon's four raster formats and its 10 MB ceiling. A URL dropped from another tab stays text. See design §4 for why the body names the record rather than a URL, and what a reader is allowed to fetch as a result.

A crawler does not run the app. Paste a Radial URL into Slack, Discord or a social feed and the unfurler fetches the page, reads <meta property="og:*"> out of the HTML, and shows what it finds — so a pure SPA, which answers every URL with one shell, used to unfurl as nothing at all.

Two layers, and the first is the one that matters:

  1. The shell's defaults, in src/app.html: a title, a description, and a 1200×630 og:image. True of the product and true of every route, so any host serving this bundle — Pages, the radial-ui loopback server, a directory on a laptop — unfurls every URL with the branded card. og:image has to be absolute or it is ignored, so it is built from PUBLIC_RADIAL_ORIGIN, the same knob client-metadata.json uses; unset, it degrades to a relative URL no crawler reaches anyway. The card itself is static/og.png, drawn from the mark's own geometry by pnpm --filter @radial/ui og and checked in.
  2. Per-record tags at the edge, for the two path families whose URLs name a record. _worker.js (bundled from src/edge/worker.ts) fetches the same static shell Pages would have served, reads the public record the URL already carries — /g/<did>/<rkey> is a goal's AT URI, and ?unit= carries the unit's — and replaces <title>, og:title, og:description, og:type and og:url before handing the document back. A project route carries only a name, so that is all it claims.

This is not a view server coming back (docs/phase6-ui-plan.md §1). The worker never materializes, never reads an index, never writes, and holds no credential of any kind: it decorates a head with what one public record says about itself, over the same unauthenticated read anyone can make. The app in the browser still resolves everything for itself, and src/lib/meta.ts — the pure text rules both use — is what keeps the tab title and the shared card saying the same thing about the same URL. Crawlers and people are served identical bytes; there is no user-agent branch, because cloaking is how link previews get distrusted.

Every failure is the same failure: the shell, with its defaults. A DID that will not resolve, a PDS that is down, a record of the wrong shape, a malformed link, a deadline missed (1.5s) — all of them end in a generic preview and a working app, never an error page, because real people follow these links too. Answers are cached for five minutes on the canonical URL, so a burst of unfurls costs one round trip to a member's PDS rather than one each.

static/_routes.json confines the worker to /g/* and /p/*. Everything else — every asset, /client-metadata.json, and the per-viewer views, whose previews are the shell's defaults by nature — keeps the byte-for-byte static path it had before the worker existed. Inside those two families the worker always answers with the shell it fetched by name, rather than relying on _redirects applying behind a function, so the deep links it owns do not depend on that question having a particular answer.

Two things worth knowing. A ?unit= link is keyed by the root artifact's URI, so its description comes from the first version that landed rather than from the tip — the title and type are right, a much-revised body may read as its own v1. And some unfurlers drop query strings entirely, which makes a ?unit= link preview as its goal; that is a graceful floor rather than a failure.

Deploying #

.tangled/workflows/deploy.yml publishes build/ to Cloudflare Pages on every push to main, after the test suite passes. It is the only automated deploy; GitHub CI runs the same checks and ships nothing.

Two things the bundle needs from a static host, both already in the repo. PUBLIC_RADIAL_ORIGIN is set in the deploy workflow's environment — it is public, and the built client-metadata.json is only valid for the origin it names, so it moves the same day a custom domain does. static/_redirects answers every unmatched path with the shell, which is what makes /g/<did>/<rkey> resolve on a host that has never heard of a goal; a real file still wins over the rule. Alongside them the build emits _worker.js and copies _routes.json into build/, which is all Pages needs to run the link preview worker — the wrangler invocation is unchanged, and the Build step gates on all three files being there.

The credentials are spindle repo secrets, never the workflow: CLOUDFLARE_API_TOKEN (a token with Cloudflare Pages: Edit, and nothing else) and CLOUDFLARE_ACCOUNT_ID. Neither reaches the app — they authorize an upload of files that are public the moment they land.

Build info #

Every screen carries a build stamp at the foot of the pane — a closed disclosure beside the diagnostics one, with the same chrome and one deliberate difference: it is always there. The diagnostics are absent when the fold has nothing to report, which is right for a report about the space; the moment somebody most needs to know which build they are looking at is the moment the app appears to be fine. It is drawn in the picker shell too, since a reader who cannot get past the front door is the one most likely to be asked.

A deployed SPA is otherwise anonymous: a set of hashed files with nothing in them that says where they came from, so a tab three deploys behind and a tab with a real bug look identical. So scripts/build-stamp.mjs reads the commit, whether that checkout was clean, the moment, and whether the browser transport binding was copied in; vite.config.ts injects the lot as __RADIAL_BUILD__ and src/lib/build.ts is what the panel reads. Two of those facts change what the bundle can do rather than merely identifying it:

  • The origin it was built for, from the same PUBLIC_RADIAL_ORIGIN that client-metadata.json is written from, shown beside the origin it is actually being served from. A deployed tab's client id is <this origin>/client-metadata.json, so when the two differ the document an authorization server fetches names somewhere else and the client is refused — silently, as far as this tab is concerned. The panel says so in a sentence, and says the same about a bundle built with no origin and served from a public one, which is that failure a step earlier. It says nothing on a loopback host, where sign-in goes through the loopback client id and never reads the document (isLoopbackHost is shared with auth.svelte.ts so the two cannot disagree about which client this is).
  • Whether the browser transport binding is in it. copy-wasm.mjs is best-effort by design, and a bundle built without it reports having no endpoint in every private space — indistinguishable from a peer that never answered until something says the reason is the build.

The Node half never throws and never fails a build. A checkout with no .git, a tarball, or a CI clone git will not read still builds; it takes the sha from TANGLED_SHA/GITHUB_SHA if one is set — without claiming to know whether the tree was clean, which only git can see — and otherwise says on the build log and on the panel that the commit was not recorded. It lives in scripts/ rather than in vite.config.ts because that config is typechecked against the browser's tsconfig, which has no @types/node; scripts/build-stamp.d.mts is the declaration it reads instead, and it imports the shape from src/lib/build.ts so the injected object and the app cannot disagree.

Keyboard #

One map, in src/lib/keys.ts; components register what they can do rather than listening for keys themselves.

/ quick find
⌘N / ^N the ⊕ of the current view — a goal on a project's list, a request everywhere else
Esc leave the field you are in; outside a field, close the topmost open layer

Escape unwinds one layer per press, topmost first — the shortcuts map, the space picker, the account popover, the rail's narrow-viewport drawer, the ⊕ menu, a compose card, quick find. The drawer sits between the popover and the menu because that is how those three are drawn: the popover is z-index: 30 and later in the document than the drawer's own 30, and the menu is 21, genuinely under the drawer's scrim. Inside a text field it blurs instead of closing, so a stray press never discards a half-written brief. Closing any layer returns focus to the control that opened it — for the drawer, to the hamburger, even when that button never held focus in the first place, which is what a finger on Safari leaves behind.

Quick find #

/ puts the caret in the pane bar's box, and what is typed there narrows the view you are looking at — the rows on a list, the units and the thread on a goal, the members, types and projects on the Space page. It changes no URL, writes no record and touches no index: it is a way of reading one screen.

A row is searched by what it shows — its type, the current version's title, where it hangs off, the brief of a request still open on it, and whoever it is with — plus the artifact's full body, which is how anything written before title existed is still findable. The projections live beside the row models in units.ts and replies.ts so a string a row draws and a string quick find reads cannot drift apart.

While it is narrowing, counts and empty copy describe what is on screen: a section heading counts the rows under it, and a list that a query emptied says so rather than claiming the desk is clear. Goal-wide facts do not move — the pie, "3 of 5 reviewed" and the ending badge are about the goal, not about the search — and neither do the request bar, the composer or the goal's own actions, because a filter must not take away the way out of itself.

It is per-tab and per-view: nothing persists it, it is never a search parameter, and the layout clears it on the way to another route or another space. Escape clears it too.

Fixture mode #

fixtureSpace() from @radial/core/fixture — the comp's dataset as real com.disnetdev.radial.* records — is one click away in the picker and stays in the build as the demo and screenshot path. It touches neither the network nor IndexedDB, and the pane bar labels it (fixture · synthetic records) where a live space shows freshness instead.

Human handles are the one thing fixture mode supplies that the protocol does not: a handle is a resolution, not a record (see identity.ts), and there is no network here to resolve against. That map lives in fixture.ts next to the fixture wiring and nowhere else.

?frame — the comp's window framing, for screenshots #

The app normally fills the tab. The comp drew it as a window on a desk, which is right for product screenshots and wrong for daily use, so the framing is opt-in: append ?frame to any URL and the desk, the 1220×860 window, and its shadow come back for that page load. It is set on the document before first paint, so it survives client-side navigation and screenshots never catch the app mid-change. Below 860px it is disabled.

radial-ui #

pnpm --filter @radial/ui start builds and serves build/ on 127.0.0.1:4321; RADIAL_UI_PORT and RADIAL_UI_HOST override. It serves files and does nothing else — no session, no CSRF surface, no origin allowlist, because everything happens in the tab. Any static host serves this bundle just as well; this binary exists so an operator running radiald has one command instead of a deployment step.

127.0.0.1, not localhost. atproto's loopback redirect URI must be an IP, so an OAuth session always lands on 127.0.0.1. Served under the other name, the tab would sign in and come back to an origin whose IndexedDB holds nothing. dev, preview, and radial-ui all bind the same way.