29.6 KiB · text History e61db36

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.Document validation, Diagrams.Permissions authorization, and the Diagrams.Authority op 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/v1 exists 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 in users_tokens with 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 numeric id is returned in payloads because the channel topic is keyed by it.
  • Errors: {"error": {"code": "...", "message": "..."}}. unauthenticated (401), forbidden (403), not_found (404), unprocessable (422, with details for 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/h for rect/rounded/text, cx/cy/r for 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 anchors auto|n|e|s|w|point; routing straight|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_at refreshed at most once per day on a successful authenticated request.
  • Revocation: delete the row from users_tokens; in-flight requests get 401 unauthenticated and 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:

  1. DIOGRAMOS_PUBLIC_URL env (explicit override)
  2. X-Forwarded-Proto + X-Forwarded-Host
  3. the request’s Host header
  4. 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.

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:

  • Authority already 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_ref is 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:

  1. Apply the op locally (optimistically) and send it with a fresh actor_ref.
  2. On an incoming op push whose actor_ref is ours, drop it — already applied — but adopt its version.
  3. On any other incoming op: if version == local_version + 1, apply and advance. Otherwise the client has drifted — re-GET /canvases/:slug for a fresh document + version and resync. Drift is expected after a reconnect and should be silent.
  4. 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 Canvas under a pan/zoom transform. Elements in order (back-to-front), then connectors on top, then presence ghosts.
  • ConnectorGeometry.kt is a direct port of lib/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 d strings) from the Elixir tests into android/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.
  • style.fill / style.stroke / connector color are daisyUI tokens (base-100, primary, error, …), not hex. CanvasTokens.kt resolves them against the canvas’s theme (canvas.theme), which is independent of the app chrome theme the user picked. A canvas authored in forest renders in forest colors even if the app is in light.
  • Fonts: sans/serif/mono/b612/b612-mono. B612 ships in ../waxx/android/app/src/main/res/font/ already.
  • Hit testing walks order in 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/Dockerfile builds 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 master and publishes to the F-Droid repo.
  • versionCode from the CI run number, versionName from the commit. Local builds stay at versionCode = 1 / …+dev and 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 a label column on users_tokens; Accounts.create_api_token/2, fetch_user_by_api_token/1, list_api_tokens/1, delete_api_token/2.
  • DiogramosWeb.Api.Auth plug, :api_authenticated pipeline, FallbackController, ErrorJSON.
  • /api/v1 controllers: sessions, users, folders, canvases, themes.
  • DiogramosWeb.UserSocket + CanvasChannel; socket "/socket" in the endpoint.
  • canvas_ops WAL 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 explicit PubSub.subscribe there would deliver every op twice. For the same reason presence diffs arrive as outgoing broadcasts, so the channel declares intercept ["presence_diff"] and collapses the diff into the peer list in handle_out/3.
  • GET /canvases/:slug reads 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

  • eqrcode dep and DiogramosWeb.QR.svg/2.
  • DiogramosWeb.PublicUrl.derive/1 for both a Plug.Conn and a LiveView.Socket; the LiveView socket now exposes :uri and :x_headers in connect_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://pair URI, active-token list, revoke. The whole page is already behind :require_sudo_mode, and the generate/revoke handlers re-assert Accounts.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 from assets/css/app.css, token for token, keyed to Themes.all/0. Carries every entry in Themes.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/me before 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.MagicLinkController at /m/:token, and request_magic_link now 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, plus mix diogramos.android_fixtures generating 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, typed Flow<CanvasEvent> (Connected / Disconnected / Joined / Op / Peers / Failed).
  • canvas/DocumentOps.kt — Document.apply_op/2 ported, rejections included, held to the server by 32 more golden fixtures from mix 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.
  • CanvasScreen collects 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 their actor_ref and 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

  • auto anchors always leave the east edge. ConnectorGeometry documents auto as picking the cardinal edge whose direction best aligns with the line between the two bounding boxes, and best_edge/2 implements exactly that — but the "_other" key it reads is never written by anything in lib/. So other_center/2 returns nil, best_edge compares an element’s centre against itself, and the abs(dx) >= abs(dy) and dx >= 0 branch 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/2 has 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?

  • set_z_order races. The op replaces the entire order list, 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/preferences would let it follow the user across devices — Diogramos has no users.preferences column yet.

  • Embed/share-link viewing. /embed/:token and /s/:token are 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.