Diogramos Android client — design doc
Status: draft, pre-implementation. Owner: gmp@h4xmb.org. Goal: native Android client for viewing and lightly editing canvases, sharing the existing Phoenix backend.
This doc is the contract for the work. Implementation lands in phases (see Phase plan); each phase has its own commit(s) and updates this doc with what shipped.
Sister app to ../waxx/android and ../camerite/android — same Compose /
OKLCh palette / B612-mono shell, so they read as one product family. Where
waxx already solved a problem (bearer tokens, QR pairing, F-Droid release),
this doc points at that solution rather than inventing a second one.
1. Goals & non-goals
Goals
- Native Kotlin/Compose Android app for the case the web UI serves worst: looking at a diagram on a phone, and making the small fix you notice while you’re there.
- Correct rendering. A canvas must look like the same diagram it is on the web — same shapes, same theme colors, same connector routing.
- Real-time. An edit on web appears on the phone in the same second it appears in another web tab, and vice versa.
- Two auth paths: scan-a-QR from a logged-in web session, or magic-link by email. Either alone is sufficient; QR is the primary path for self-hosted installs with no SMTP.
- Server stays authoritative.
Diagrams.Documentvalidation,Diagrams.Permissionsauthorization, and theDiagrams.Authorityop pipeline are not reimplemented on the client.
Non-goals (v1)
- Connector authoring. Connectors are rendered but not created or re-anchored on the phone. Picking an anchor on a 6” screen is its own design problem and it is not the thing mobile users are blocked on.
- Folder management, permission/share-link administration, embed-token settings, canvas import. All remain web-only; the app links out to the web UI for them.
- iOS. The architecture should not preclude it; no iOS work in v1.
- Offline-first writes. Read caching is fine; mutations require a live connection.
- Push notifications.
- A public/third-party API.
/api/v1exists for the first-party Android client; stability guarantees are weak until v2.
v1 editing surface, precisely
The app emits exactly these ops:
| Gesture | Op |
|---|---|
| Drag a shape | update_element (geometry) |
| Drag a resize handle | update_element (geometry) |
| Edit label / text body | update_element (label, style) |
| Fill / stroke / label color pick | update_element (style) |
| Add shape from FAB | insert_element |
| Delete from the selection sheet | delete_element |
| Bring to front / send to back | set_z_order |
insert_connector, update_connector, and delete_connector are not sent
in v1 — but they arrive over the channel from web peers and must render.
Note that delete_element on the server also drops every connector
referencing that element (Document.apply_op/2), so the client must accept
a document where connectors vanished without a matching connector op.
2. Architecture overview
+-------------------+ HTTPS/JSON +------------------------------+
| Android client | <---------------> | DiogramosWeb.Api.* (Phase 0)|
| (Kotlin/Compose) | | - Plug pipeline :api |
| | WSS/Channels | - bearer-token auth |
| | <---------------> | DiogramosWeb.CanvasChannel |
+-------------------+ | (Phase 0) |
+---------------+--------------+
|
v
+------------------------------+
| Diogramos.{Accounts,Diagrams}|
| Authority / Document / |
| Permissions / Canvases |
| (UNCHANGED business logic) |
+------------------------------+
The server gains two surfaces — an /api/v1 HTTP scope and a /socket
user socket with a CanvasChannel. Both call into the existing contexts.
No business logic moves; only thin transport layers are added.
One pre-existing gap was closed alongside them, because the phone makes it
much more visible (more ops per edit, more reconnects): Diagrams.CanvasOp
described itself as an append-only WAL for crash recovery, but nothing
wrote to it, so an authority crash lost every op since the last snapshot —
up to 25. Authority now appends a row per accepted op, replays rows newer
than the snapshot on load, and prunes them when a snapshot is written.
The Android app is a single Gradle module under /android, matching
../waxx/android (single-activity, no DI framework, dependencies
constructed inline).
3. HTTP API design
Conventions
- Scope:
/api/v1/.... Major version bumps for breaking changes; additive fields are not breaking. - Auth:
Authorization: Bearer <token>on every request except session redemption. Tokens live inusers_tokenswith a new context"api". - Content type:
application/json, snake_case fields (matches the schemas). - Timestamps: ISO-8601 UTC strings.
- Canvases are addressed by slug in URLs (
Canvases.get_by_slug/2), matching the web routes. The numericidis returned in payloads because the channel topic is keyed by it. - Errors:
{"error": {"code": "...", "message": "..."}}.unauthenticated(401),forbidden(403),not_found(404),unprocessable(422, withdetailsfor changeset errors).
Endpoints (v1 surface)
Unauthenticated (pipe_through :api):
| Method | Path | Notes |
|---|---|---|
| POST | /sessions/request_magic_link |
{email} → 204 always (no account enumeration) |
| POST | /sessions/redeem |
{token} → {token, user}; mints an "api" token from a magic-link token |
Authenticated (pipe_through :api_authenticated):
| Method | Path | Notes |
|---|---|---|
| DELETE | /sessions/current |
revokes the calling token |
| GET | /users/me |
{id, email, confirmed_at}; also the QR-pair probe |
| GET | /folders |
flat list; used to group the canvas list |
| GET | /canvases |
Canvases.list/1 — id, slug, title, theme, folder_id, version, updated_at, role. No document. |
| POST | /canvases |
{slug, title, theme, folder_id} → canvas |
| GET | /canvases/:slug |
full canvas including document and version |
| PATCH | /canvases/:slug |
metadata only (title, theme, folder_id) via Canvas.metadata_changeset/2 |
| DELETE | /canvases/:slug |
owner/admin only |
| GET | /canvases/:slug/export.json |
same payload as Canvases.export/2 |
| GET | /canvases/:slug/export.mermaid |
MermaidExport.to_mermaid/1, text/plain |
| GET | /themes |
{themes: [...], color_tokens: [...]} — lets the client validate a color pick without hardcoding Themes.color_tokens/0 |
Ops do not have an HTTP endpoint. Every mutation to document goes
through the channel, because Authority is the only thing allowed to
sequence ops and bump version. An HTTP op endpoint would be a second
write path into the same GenServer with no ordering story.
JSON shape
document is passed through verbatim as the jsonb map the server
stores — %{"elements" => %{id => el}, "order" => [id], "connectors" => %{id => c}}.
It is not remapped into a client-flavored schema. The client’s Kotlin
models mirror Document’s validation rules 1:1:
- element types:
rect,rounded,circle,text - geometry:
x/y/w/hfor rect/rounded/text,cx/cy/rfor circle label,style(fill,stroke,shadow,dash,stroke_width,font_family,font_size,font_bold,font_italic,label_position,label_size,label_color,label_font_family,label_bold,label_italic),links[]- connectors:
from/to{element, anchor, x?, y?}with anchorsauto|n|e|s|w|point;routingstraight|orthogonal|curve;dash,marker_start,marker_end,start_gap,end_gap,stroke_width,color,label
Unknown keys are preserved on round-trip: an update_element patch is a
partial map (the server deep-merges), so the client never has to send
back fields it didn’t understand.
View modules
DiogramosWeb.Api.V1.CanvasJSON etc., alongside a shared
DiogramosWeb.Api.FallbackController and DiogramosWeb.Api.ErrorJSON —
lifted from ../waxx/lib/waxx_web/api/.
4. Auth flows
Both paths end with the app holding a raw bearer token. The backend treats them identically after that.
Token lifecycle
"api"tokens: 90-day inactivity expiry,authenticated_atrefreshed at most once per day on a successful authenticated request.- Revocation: delete the row from
users_tokens; in-flight requests get401 unauthenticatedand the socket reconnect fails. - Stored on-device in
EncryptedSharedPreferences(AES via the Keystore).
New Accounts functions, mirroring waxx: create_api_token/2,
fetch_user_by_api_token/1, list_api_tokens/1, delete_api_token/2;
new UserToken functions build_api_token/2, verify_api_token_query/1,
api_token_needs_refresh?/1.
4a. Pair via QR (primary)
The web user, already logged in, generates a token from a “Connected
devices” panel added to UserLive.Settings. The panel renders a QR
encoding server URL + token; the phone scans it and is paired. No email,
no SMTP, no App Links.
QR payload:
diogramos://pair?base=<url-encoded origin>&token=<url-encoded token>
The raw token is displayed exactly once, at generation time. The panel
otherwise lists sent_to, authenticated_at, inserted_at, and a Revoke
action per token.
Base URL derivation, in priority order — so a self-hosted install behind a reverse proxy doesn’t bake a bind address into the QR:
DIOGRAMOS_PUBLIC_URLenv (explicit override)X-Forwarded-Proto+X-Forwarded-Host- the request’s
Hostheader - endpoint config
Server dep: {:eqrcode, "~> 0.2.1"} (pure Elixir, SVG output).
Client dep: journeyapps:zxing-android-embedded via ScanContract.
The diogramos://pair intent filter also makes the URI tappable from a
note or chat, deep-linking into the Pair screen with both fields filled.
4b. Magic link
For “I’m only on my phone and have never logged in on a desktop.”
POST /sessions/request_magic_link reuses
Accounts.deliver_login_instructions/2; the email link carries a
/m/:token route that content-negotiates — native clients redeem it via
POST /sessions/redeem, browsers keep falling through to the existing
/users/log-in/:token LiveView.
App Links (autoVerify + /.well-known/assetlinks.json with both debug
and release SHA-256 fingerprints) make the tap-through seamless; without
them the user pastes the link into the app, which still works.
Registration
Diogramos registration is invite-gated (Accounts.Invite,
UserLive.Invites). The app does not implement registration — an
unregistered user is told to open the invite link in a browser. Revisit if
that proves annoying.
5. Real-time channel
Server
DiogramosWeb.UserSocket
/socket connect: ?token=<api_token> → Accounts.fetch_user_by_api_token/1
channel "canvas:*", DiogramosWeb.CanvasChannel
DiogramosWeb.CanvasChannel
join("canvas:<id>"):
- load canvas, Permissions.authorize(scope, :read, canvas)
- PubSub.subscribe(Authority.topic(id))
- CanvasPresence.track(...)
- reply {:ok, %{canvas: meta, document: doc, version: v, role: role}}
from Authority.snapshot/1
handle_in("op", %{"op" => op, "actor_ref" => ref}):
- Authority.apply_op(id, scope, op, ref)
- reply {:ok, %{version: v}} | {:error, %{code: ...}}
handle_in("cursor", %{"x" => x, "y" => y}):
- CanvasPresence.update(...)
handle_info({:canvas_op, payload}):
- push("op", %{op:, version:, actor_ref:})
handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}):
- push through
Unlike waxx — where channel pushes are bare “refetch me” notifications — this channel pushes the ops themselves. The reasons are specific to this backend:
Authorityalready broadcasts{:canvas_op, %{op, version, actor_ref}}as a delta with a monotonic version. The contract exists; wrapping it is cheaper than inventing a notification.- The document is one blob. “Refetch the affected resource” means refetching the whole canvas on every keystroke-sized op.
actor_refis already there for echo suppression, which is exactly what an optimistic client needs.
Client reconciliation
Authority.apply_op/4 does not take a client version — it applies ops
in arrival order and increments version itself. There is no OT and no
CRDT; concurrent edits to the same field are last-writer-wins, same as the
web editor today. So the client rule is simple:
- Apply the op locally (optimistically) and send it with a fresh
actor_ref. - On an incoming
oppush whoseactor_refis ours, drop it — already applied — but adopt itsversion. - On any other incoming op: if
version == local_version + 1, apply and advance. Otherwise the client has drifted — re-GET /canvases/:slugfor a fresh document + version and resync. Drift is expected after a reconnect and should be silent. - On an
{:error, ...}reply to our own op, roll back the optimistic change and resync.
The client must not assume its op landed until the reply arrives; a
:forbidden reply is normal for a viewer-role user whose role changed
mid-session.
Presence
CanvasPresence already carries :name, :color, :cursor, :lock per
peer, with a color palette and generated two-word names. The app renders
ghost cursors and ghosted elements the same way the web editor does, and
publishes its own cursor on drag (throttled to ~20 Hz, and only while a
drag is in flight — a phone has no hover).
Channel auth
UserSocket.connect/3 verifies the "api" token and assigns user_id.
CanvasChannel.join/3 re-checks Permissions.authorize/3 on every join;
write authorization is re-checked per op inside Authority.do_apply_op/4,
so a role change takes effect without a rejoin.
6. Android app architecture
Stack
Identical to waxx, deliberately — one toolchain across the family.
| Layer | Choice |
|---|---|
| Language / UI | Kotlin 2.0 · Jetpack Compose · Material 3 |
| HTTP | Retrofit · OkHttp · kotlinx.serialization |
| Realtime | com.github.dsrees:JavaPhoenixClient (0.3.4, jitpack) |
| Storage | EncryptedSharedPreferences |
| Scanner | com.journeyapps:zxing-android-embedded |
| Build | Gradle 9.0 · AGP 8.5.2 · JDK 17 |
| minSdk | 26 · targetSdk 34 |
Module layout
android/app/src/main/java/org/neiam/diogramos/app/
MainActivity.kt # nav host, deep-link dispatch, theme provider
data/
TokenStore.kt # bearer + base URL (https-coerced)
ThemeStore.kt # selected UI theme key
DiogramosClient.kt # Retrofit interfaces + factories
CanvasSocket.kt # channel wrapper → Flow<CanvasEvent>
PairPayload.kt # diogramos://pair parser
Models.kt # Document / Element / Connector DTOs
DocumentStore.kt # in-memory doc + version + op application
auth/
LoginScreen.kt · PairScreen.kt · MagicLinkScreen.kt
canvas/
CanvasListScreen.kt # canvases grouped by folder
CanvasScreen.kt # the viewport
Viewport.kt # pan/zoom/fit transform state
Renderer.kt # Compose Canvas draw of elements + connectors
ConnectorGeometry.kt # port of the Elixir module
SelectionSheet.kt # bottom sheet: label, colors, z-order, delete
AddShapeSheet.kt
Peers.kt # ghost cursors from presence
ui/theme/
AppTheme.kt · Theme.kt · Typography.kt # lifted from waxx
CanvasTokens.kt # daisyUI token → Color, per canvas theme
Rendering
The renderer is the heart of the app and the part with no waxx precedent.
- Draw into a single Compose
Canvasunder a pan/zoom transform. Elements inorder(back-to-front), then connectors on top, then presence ghosts. ConnectorGeometry.ktis a direct port oflib/diogramos/diagrams/connector_geometry.ex— anchor resolution (auto/cardinal/point), gap shrinking, and the three routings. It is pure math over the document, so the port is mechanical.- Keeping it honest: a mix task dumps golden fixtures (document +
expected path
dstrings) from the Elixir tests intoandroid/app/src/test/resources/, and a Kotlin unit test asserts the port produces the same paths. Without this the two implementations drift silently and diagrams render subtly wrong.
- Keeping it honest: a mix task dumps golden fixtures (document +
expected path
style.fill/style.stroke/ connectorcolorare daisyUI tokens (base-100,primary,error, …), not hex.CanvasTokens.ktresolves them against the canvas’s theme (canvas.theme), which is independent of the app chrome theme the user picked. A canvas authored inforestrenders inforestcolors even if the app is inlight.- Fonts:
sans/serif/mono/b612/b612-mono. B612 ships in../waxx/android/app/src/main/res/font/already. - Hit testing walks
orderin reverse so the topmost element wins.
Interaction model
| Gesture | Effect |
|---|---|
| One-finger drag on empty canvas | pan |
| Pinch | zoom (clamped; double-tap = fit-to-content) |
| Tap an element | select (handles + bottom sheet) |
| Drag a selected element | move → update_element, one op on gesture end plus throttled intermediates |
| Drag a corner handle | resize → update_element |
| Long-press empty canvas | add-shape sheet at that point |
| Tap a link badge | open the link (external URL or another canvas) |
Intermediate drag ops are throttled to ~10/s. Each op is a
GenServer.call into the canvas’s Authority plus a PubSub broadcast to
every peer, and a snapshot write to Postgres every 25 ops, so a firehose of
sub-pixel updates is real server load — throttling is not just a network
nicety.
A viewer-role user gets no handles, no FAB, and a “read-only” chip.
Error handling
A top banner mirrors waxx’s: “Connecting…” / “Offline — changes won’t sync
until reconnect.” outside the joined state. Rejected ops roll back with a
snackbar naming the reason (forbidden, invalid_color, …, mapped from
Document’s error atoms).
7. Build & release
Straight copies of the waxx setup, which is already proven:
android/Dockerfilebuilds and signs the release APK in a container with the keystore as a BuildKit secret — it never reaches a Gradle script, cache, or image layer.- CI (Forgejo style)
builds on push to
masterand publishes to the F-Droid repo. versionCodefrom the CI run number,versionNamefrom the commit. Local builds stay atversionCode = 1/…+devand are never published.- Release keystore generated once, backed up to OpenBao before it exists anywhere else. Losing it means every install has to be uninstalled and redone by hand.
8. Phase plan
Phase 0 — Backend foundation (no Android code) ✅ shipped
"api"token context:UserToken.build_api_token/2,verify_api_token_query/1,api_token_needs_refresh?/1, plus alabelcolumn onusers_tokens;Accounts.create_api_token/2,fetch_user_by_api_token/1,list_api_tokens/1,delete_api_token/2.DiogramosWeb.Api.Authplug,:api_authenticatedpipeline,FallbackController,ErrorJSON./api/v1controllers: sessions, users, folders, canvases, themes.DiogramosWeb.UserSocket+CanvasChannel;socket "/socket"in the endpoint.canvas_opsWAL now actually written and replayed (see below).- 41 tests: token expiry/refresh/revocation, authz on every endpoint (viewer vs editor vs stranger), channel join/op/reject, echo suppression, presence, and crash recovery.
Shippable on its own: the API is useful to curl and to any script.
Two things worth knowing about what shipped:
- The channel’s topic is already the authority’s topic.
Authority.topic(id)is"canvas:<id>", which is also the channel topic, so joining subscribes us — an explicitPubSub.subscribethere would deliver every op twice. For the same reason presence diffs arrive as outgoing broadcasts, so the channel declaresintercept ["presence_diff"]and collapses the diff into the peer list inhandle_out/3. GET /canvases/:slugreads through the authority, not the row. A canvas being actively edited holds up to 24 unpersisted ops, so the DB copy can be far behind. The controller falls back to the row only when no authority can be started.
Phase 1a — Web “Connected devices” + QR ✅ shipped
eqrcodedep andDiogramosWeb.QR.svg/2.DiogramosWeb.PublicUrl.derive/1for both aPlug.Connand aLiveView.Socket; the LiveView socket now exposes:uriand:x_headersinconnect_info, which is what the socket flavour reads.- “Connected devices” panel in
UserLive.Settings: generate (with an optional label), one-time display of token + QR +diogramos://pairURI, active-token list, revoke. The whole page is already behind:require_sudo_mode, and the generate/revoke handlers re-assertAccounts.sudo_mode?/1. - 16 tests, including the forwarded-header matrix and a check that the token rendered on screen is the real bearer.
The base URL is derived in mount/3, because LiveView discards
connect_info afterwards. On the dead render that yields the endpoint URL;
the connected mount replaces it with the real origin, and a QR is only
ever generated after that.
Phase 1b — Android scaffold + both auth paths ✅ shipped
- Gradle project under
android/(Gradle 9.0, AGP 8.5.2, minSdk 26), single module, no DI framework. ui/theme/AppTheme.kt: all nine themes generated fromassets/css/app.css, token for token, keyed toThemes.all/0. Carries every entry inThemes.color_tokens/0— not just Material’s slots — because phase 2 resolves document colors against it. B612 typography.data/:TokenStore(EncryptedSharedPreferences, https coercion),ThemeStore,PairPayload,DiogramosClient(Retrofit + the error envelope),Models.auth/:LoginScreen,PairScreen(scan + paste, verified against/users/mebefore storing),MagicLinkScreen(request → paste → redeem).canvas/CanvasListScreen.kt: a placeholder list that proves the credentials work end to end. Phase 2 replaces it.- Server:
DiogramosWeb.MagicLinkControllerat/m/:token, andrequest_magic_linknow emails that URL. - 12 JVM unit tests (no emulator) plus 6 Elixir tests for the handoff.
PairPayload parses with java.net.URI rather than android.net.Uri
specifically so it is unit-testable on the JVM: it is the one place where
a parsing mistake silently stores the wrong server.
Phase 2 — Canvas list + read-only render ✅ shipped
canvas/CanvasDocument.kt— a read model over the document that keeps the raw JSON alongside it, so fields this client doesn’t know survive a round trip. Malformed shapes are skipped, never fatal.canvas/ConnectorGeometry.kt— the port, plusmix diogramos.android_fixturesgenerating 360 golden cases from the Elixir module and a Kotlin test asserting the SVG path strings match byte for byte.canvas/Viewport.kt— pan/zoom/fit, clamped, saveable across rotation.canvas/Renderer.kt— elements (all four types), labels in the nine positions, connectors with dashes and all five markers, colors resolved against the canvas’s theme.canvas/CanvasScreen.kt— pan, pinch, double-tap and a FAB to fit.canvas/CanvasListScreen.kt— grouped by folder, tapping opens.- 29 Android unit tests in total.
Verified: the geometry port against the generated fixtures, plus the document parser and viewport maths. Not verified: the drawing itself — no emulator or device was available, so nothing has visually confirmed a diagram on a screen yet. Worth doing before phase 4 puts edits behind it.
Number formatting was the subtle part of the port. Erlang’s
float_to_binary(n, [:compact, decimals: 2]) prints 1.999 as "2.0",
not "2", while a genuinely whole value prints as "30" — so the Kotlin
side needs both branches to agree, and the fixtures cover it.
Phase 3 — Realtime ✅ shipped
data/CanvasSocket.kt— one socket per subscription, join, typedFlow<CanvasEvent>(Connected / Disconnected / Joined / Op / Peers / Failed).canvas/DocumentOps.kt—Document.apply_op/2ported, rejections included, held to the server by 32 more golden fixtures frommix diogramos.android_fixtures.canvas/CanvasSession.kt— the reconciliation rules above, as a plain class with no Compose or Android types in it, with 12 tests.CanvasScreencollects the flow, resyncs on drift, draws ghost cursors, and shows the connection banner.- 44 Android unit tests in total.
The phone does not publish its own cursor yet: there is no hover on a touch screen, so a cursor only exists during a drag, and drags arrive in phase 4. The channel already accepts them.
Rejections are ported as carefully as the successes. An op the server would refuse must not appear to work on the phone — otherwise an optimistic edit sits on screen looking saved and silently never lands.
Phase 4 — Light editing ✅ shipped
- Tap to select, tap again for the sheet; drag to move, corner handles to resize; label/text, fill/stroke/label colour, bring to front, send to back, delete; a FAB to add each of the four shape types.
canvas/Ops.kt— the op builders, with coordinates rounded to two decimals before they go out.canvas/DragState.kt— move and resize maths, including the flip when a handle is dragged past the opposite edge.canvas/Ulid.kt— element ids in the server’s format.CanvasSender— ops pushed with theiractor_refand the reply acted on; cursor and lock published while a drag is in flight.- 64 Android unit tests in total.
A drag is not written into the document while the finger is down. Each op
is a GenServer.call, a broadcast to every peer and a WAL row, so the
in-flight geometry is a render-time override and ops go out on a ~10/s
throttle plus once at the end. Connectors re-route against the preview,
so they follow the shape instead of snapping when it lands.
Gesture detectors are deliberately not keyed on the document revision: a
drag sends ops as it goes, and restarting the pointerInput on the
resulting recomposition cancels the gesture that produced them.
A viewer gets no handles, no add button and no editing controls. An action that is always refused is worse than no button at all.
Phase 5 — Polish
- Export (JSON + mermaid) to the share sheet, canvas create/rename/theme,
app theme picker, launcher icon, App Links +
assetlinks.json.
Phase 6 — Release pipeline
android/Dockerfile, CI job, keystore, F-Droid publication.
9. Open questions
-
autoanchors always leave the east edge.ConnectorGeometrydocumentsautoas picking the cardinal edge whose direction best aligns with the line between the two bounding boxes, andbest_edge/2implements exactly that — but the"_other"key it reads is never written by anything inlib/. Soother_center/2returns nil,best_edgecompares an element’s centre against itself, and theabs(dx) >= abs(dy) and dx >= 0branch wins every time. Two shapes side by side get a connector out of both east edges, whatever their relative positions.The fix is small —
render/2has both endpoints, so it can pass each anchor the other endpoint’s element id — but it changes how existing diagrams look on the web, so it is a decision rather than a cleanup. The Kotlin port reproduces today’s behaviour deliberately, and the fixtures encode it; regenerate them after any fix and the port is held honest either way. -
Text rendering fidelity. The web renders text elements as SVG/HTML with daisyUI typography; Compose text metrics will not match exactly. Wrapping and vertical centering are the likely visible differences. Do we accept “close”, or does the server need to hand back measured line boxes?
-
Anchor validation is looser than it looks.
Document.valid_anchor?/1ends in a catch-all%{}clause, so any map-shaped anchor is accepted — an unrecognised name like"northwest", or"point"with no coordinates. That last one used to raiseArithmeticErrorinsideConnectorGeometry.render/2(nil coordinates reaching the gap arithmetic), which took down every render of the canvas holding it. Harmless while the editor’s own UI was the only thing writing ops; reachable by any client once/api/v1and the channel existed.render/2now skips such a connector, the way it already skips one whose endpoint is gone, and the Kotlin port matches the server’s leniency rather than being stricter than it — a client that refuses what the server accepts rejects edits that work on the web. Tighteningvalid_anchor?/1to reject these at the door is the other half, and would mean deciding what to do about any already stored. -
set_z_orderraces. The op replaces the entireorderlist, so two peers reordering at once means one silently wins with a stale list. Same bug exists on web today; worth noting, not worth fixing in this scope. -
Connector creation. Deferred out of v1, but the eventual gesture (drag from an edge handle to a target, anchor defaulting to
auto) is worth prototyping early enough that the renderer doesn’t foreclose it. -
Theme sync. The app theme is local-only, like waxx’s. A
PATCH /api/v1/users/me/preferenceswould let it follow the user across devices — Diogramos has nousers.preferencescolumn yet. -
Embed/share-link viewing.
/embed/:tokenand/s/:tokenare anonymous read paths. Should the app open a shared canvas without an account? Cheap to add (GET /api/v1/embed/:token), but it means an unauthenticated channel join.
10. Glossary
| Term | Meaning |
|---|---|
| Authority | Diagrams.Authority — the Horde-registered GenServer owning one canvas’s document and version. Every write goes through it. |
| op | One of the seven mutations in Document.apply_op/2. |
| version | Monotonic counter on canvases, incremented per accepted op. |
| actor_ref | Opaque binary a client attaches to its own ops to recognize the echo. |
| token (color) | A daisyUI color name from Themes.color_tokens/0, resolved per theme at render time — never a hex value in the document. |
| canvas theme | canvases.theme; how the document’s color tokens resolve. Distinct from the app’s chrome theme. |