Skip to content

loom v2

Loom v2: Mobile Multiplayer Interface for LLMs

Section titled “Loom v2: Mobile Multiplayer Interface for LLMs”

Each rollout appends candidate futures along a path, and the user can splice branches into a shared continuation. On mobile, treat this as a DAG that is locally tree-like. The user sees one path as a strip, with cross-links revealed on demand through zoom or peeking. Moire — Language models are multiverse generators Moire — Block multiverse visualizations.

Use an infinite canvas with kinetic panning and pinch zoom. The “sheet” is a vertically oriented strip for the active lineage, while side branches live in gutters. Semantic zoom reveals topology as you zoom out, and reveals text and controls as you zoom in. Miro — Infinite canvas Bederson — Pad++ ZUI Microsoft — Semantic zoom.

Scroll down to the end of the current strip and pull past a threshold to trigger N candidates, like pull-to-refresh but grounded in generation. A small probability band displays a “block multiverse” miniature that previews near-future mass and lets you tap to lock a candidate before it appears as a full child. Moire — Block multiverse visualizations Moire — Loom: interface to the multiverse.

Swipe a paragraph card horizontally to “rip” it off the sheet. Provide a visible affordance and an alternate action button so the gesture is not hidden-only. Play a brief success haptic and show an accessible undo. On Android, map to swipe-to-dismiss patterns and predictive back where appropriate. Apple HIG — Gestures Apple HIG — Accessibility Apple HIG — Playing haptics Material 3 — Gestures Jetpack Compose — SwipeToDismissBox.

Two-finger press-and-hold at any paragraph opens a “branch rail” that lists sibling continuations. Drag a sibling chip into the strip to splice it, which adds an edge into the current path rather than cloning text. A dotted join marker communicates that the node has multiple parents. Moire — Loom: interface to the multiverse Moire — Language models are multiverse generators.

Use a peek panel for the block multiverse. Tapping the panel zooms to a token-level preview, then renormalizes and pins the chosen partial sequence. This keeps the probability sidecar usable on a small screen. Moire — Block multiverse visualizations.

The active strip always anchors center. Side branches compress into chips when zoomed in, and expand to node-link when zoomed out. Respect Fitts-like constraints by keeping touch targets at least platform minimums and bundling controls near thumbs. Miro — App wireframe on infinite canvas Apple HIG — Accessibility Material 3 — Gestures.

A blank sheet invites you to type a prompt. If a rip removes the last paragraph, show a ghost node and an undo CTA. Offer a history timeline that replays edges added or removed. This mirrors DAG provenance while keeping the phone interaction simple. Moire — Loom: interface to the multiverse.

  1. Type or paste context.
  2. Pull to generate.
  3. Swipe away weak continuations.
  4. Long-press to reveal sibling rail, drag to splice.
  5. Pin key nodes as bookmarks for Read mode.

This keeps human steering central, consistent with cyborgism. LessWrong — Cyborgism Moire — Loom: interface to the multiverse.

Store a DAG with nodes keyed by canonical state, and edges labeled by op and params. On mobile, cache only the active strip, sibling rail, and a small ring buffer for recent branches. Persist to a compact JSON with an edges array. Bederson — Pad++ ZUI Moire — Loom: interface to the multiverse.

// React Native style types for a DAG-first mobile Loom
type NodeID = string;
type EdgeID = string;
interface Node {
id: NodeID;
text: string; // current prefix chunk
meta?: {
modelId?: string;
logprobs?: number[];
bookmark?: boolean;
};
}
interface Edge {
id: EdgeID;
parent: NodeID;
child: NodeID;
op: "generate" | "splice" | "rip";
params?: Record<string, unknown>; // e.g., n, temperature, top_p
ts: number;
}
interface Doc {
nodes: Record<NodeID, Node>;
edges: Record<EdgeID, Edge>;
root: NodeID;
activePath: NodeID[]; // linearization for Read mode
}

Use a Skia or Canvas layer for the strip and chips to keep 60 fps with gesture compositing. Virtualize paragraphs and lazy-load side-branches. Clamp zoom levels and use semantic zoom thresholds to switch between text, chips, and node-link. Microsoft — Semantic zoom Bederson — Pad++ ZUI.

Apple HIG — Gestures Material 3 — Gestures.

Use light impact on generate, success impact on splice, and warning on rip. Provide visible buttons that mirror gestures, larger hit targets, and VoiceOver labels for edges and nodes. Respect user settings for reduced motion and haptics. Apple HIG — Playing haptics Apple HIG — Accessibility.

The infinite sheet preserves the narrative feel, while adaptive zoom and rails keep the DAG tractable on a phone. The user steers selection and merging rather than delegating to an autonomous agent, consistent with cyborgism. Moire — Language models are multiverse generators LessWrong — Cyborgism.

Make Mobile Loom multiplayer: real-time coauthoring on an infinite sheet where scroll spawns generations and swipe rips context, all while the underlying state is a DAG that converges under concurrency. Yjs docs Yjs Awareness Shapiro CRDTs.

  • Local-first CRDT for the shared state (text blocks, nodes, edges).
  • Awareness channel for presence, cursors, selections.
  • A coordination service that serializes expensive LLM calls and caches results.
  • Optional end-to-end encryption for content.

Pick Yjs for speed and mobile memory footprint or Automerge v2 for JSON-native ergonomics. Both converge without locks. Yjs docs Automerge v2 Preguiça CRDTs.

Represent the sheet as two CRDT collections: nodes (text blocks with metadata) and edges (parent→child links). Allow multiple incoming edges per node to permit DAG rejoin. Use a move-capable tree or graph strategy so reparent and splice converge even under conflict. Shapiro CRDTs Kleppmann tree-move.

// CRDT-friendly schema (Yjs or Automerge-style)
type NodeID = string;
type EdgeID = string;
interface Node {
id: NodeID
text: string // block text
attrs?: { modelId?: string; logprobs?: number[]; author?: string }
tombstone?: boolean // rip without physical delete
}
interface Edge {
id: EdgeID
parent: NodeID
child: NodeID
op: "generate" | "splice" | "rip"
params?: Record<string, any> // n, temperature, etc.
ts: number
}

Yjs docs Automerge sync.

  • generate(parent, k, params) - adds k edges from parent to new or de-duplicated children.
  • splice(child, newParent) - adds an additional incoming edge to child; never loses history.
  • rip(node or edge) - sets tombstone remove-wins on the element; readers hide it, but history persists.

Use remove-wins sets for rips and Kleppmann’s move for safe reparenting. Shapiro CRDTs Kleppmann tree-move.

Default to client-server with y-websocket for reliability, offline buffering, and easy horizontal scale. For small private rooms or local networks, you can add a WebRTC path, but full-mesh topologies do not scale with many peers. y-websocket y-webrtc README MDN DataChannel.

Use Yjs Awareness to broadcast lightweight presence: cursors, current focus node, and a “branch rail” selection. Keep awareness out of the document history to prevent log bloat. Yjs Awareness.

A single coordination actor per document prevents duplicate generations and races. Durable Objects work well: they are globally unique actors with built-in WebSockets. On generate requests, the DO takes a short lease on the parent node, fans out model calls, writes results as CRDT updates, and caches by (parent, params, model, seed). Cloudflare Durable Objects.

Cloudflare Durable Objects.

If you prefer a pull-based broker, back the coordinator with Redis Streams. Use consumer groups per document to single-flight jobs and fanout results to subscribers. Redis Streams.

  • Baseline: TLS to the provider and auth tokens per room.
  • E2EE option: encrypt node payloads client-side, leave edges in clear for topology. Matrix-style pattern: Olm for device-to-device key share, Megolm for group message ratchet. Presence can remain plaintext. Matrix E2EE guide Olm/Megolm tutorial.
  • Infinite sheet remains the primary view. Other users’ cursors appear as colored handles at paragraph edges. Tap a handle to peek that user’s lineage.
  • “Rip” becomes a shared soft-delete. Conflicts resolve remove-wins, with undo available locally until synced.
  • “Splice” offers a picker of suggested siblings from other users near your cursor.
  • A small presence rail shows avatars currently “holding” generation leases, so you do not collide. Yjs Awareness Yjs docs.

CRDTs allow edits offline. On reconnect, deltas merge and the coordinator revalidates leases. Awareness gracefully times out for absent peers without polluting history. Yjs Awareness Automerge sync.

If you must go serverless in a tiny room, use y-webrtc with RTCDataChannel for updates. Expect O(n²) peer links, so cap room size or add a super-peer relay. y-webrtc README MDN DataChannel.

Attach cost metadata and authorship to each edge: who requested, tokens used, model, params, and cache key. Your Read view can display per-branch cost totals. Store a signed hash of prompt+params to make results reproducible. Cloudflare Durable Objects Redis Streams.

  • Yjs + y-websocket for content and awareness.
  • Coordinator: Durable Object (or a small Node/Go service) hosting a doc room, exposing a WS for CRDT delta broadcast plus an HTTP endpoint for generation jobs.
  • Optional: Automerge Repo or Y-Sweet if you want a batteries-included CRDT backend. y-websocket Automerge repo Y-Sweet.
// Client: join room and request a generation lease
const ydoc = new Y.Doc()
const provider = new WebsocketProvider(URL, roomId, ydoc)
const awareness = provider.awareness
awareness.setLocalState({ user: me, cursor: { node: activeNode } })
// Ask the coordinator to single-flight a generate
await fetch(`/rooms/${roomId}/generate`, {
method: "POST",
body: JSON.stringify({ parent: activeNode, k: 4, model: "gpt-4.1", seed: 42 })
})

y-websocket Yjs docs.

For reproducible branches, include model version and a fixed seed. The coordinator dedupes by cache key and returns existing children when available. This keeps rooms fast and cheap under load. Cloudflare Durable Objects.

  • Concurrent rip vs splice on the same child.
  • Move storms: many users reparenting a subtree.
  • Lease loss during mobile suspend.
  • E2EE key share to a new device.
  • P2P fallback with 6+ peers to validate mesh caps. Kleppmann tree-move y-webrtc README.
  • y-websocket scales and is easier to run; y-webrtc saves server cost but caps room size.
  • Yjs is very fast; Automerge is JSON-native and simple to reason about.
  • DOs simplify single-flight and room fanout; Streams are great when you already run Redis. y-websocket Automerge v2 Cloudflare Durable Objects Redis Streams.

Let the user edit a single scrolling “page” with no visible graph. The DAG should update itself behind the scenes so branches, splices, and reuse remain correct and convergent. The trick is to separate the linear editing model from the structural model and bridge them deterministically. Yjs Y.Text Automerge Text Shapiro CRDT survey.

  1. Page layer: one collaborative text object holds the visible page.
  2. Projection layer: invisible anchors mark where DAG nodes begin and end on that text.
  3. Committer: a debounced worker translates recent page edits into minimal DAG changes.

Anchors use RelativePosition so they survive insertions and deletions without drifting, and the committer applies intent-preserving rules for edits that cross boundaries. Yjs RelativePosition Peritext.

Maintain a sparse index of node-boundary anchors: each anchor stores {nodeId, edgeId?, side} and is attached to the text via RelativePosition. When the user types or deletes, anchors stay attached to their logical characters, so the projector can always map page spans back to nodes without rescanning the whole document. Yjs RelativePosition.

Use a short debounce on idle or caret pause, plus explicit triggers on large structural edits like paste or block move. Conceptually this is like an interactive rebase of the active lineage: we rewrite the minimal suffix that keeps ancestry valid while preserving reachable historical nodes. Git Book — Rewriting History git-rebase docs.

  • Edit inside a single node span: create a new node with updated text, add edge from the original parent, and redirect the active path to the new node. Children of the old node are reattached if their prefixes still match, else they remain as alternate futures. CRDT survey.
  • Edit that spans multiple node spans: merge the touched spans into one new node and add edges from all original parents. This is a multi-parent splice; mark a provenance list on the new node. Peritext Move-op paper.
  • Cut or “rip”: do not hard-delete. Set a tombstone flag on edges or nodes with remove-wins semantics so concurrent undo stays well-defined. CRDT survey.
  • Reparent via drag or paste: use a true move operation on the structural CRDT rather than delete+insert to avoid dupes and cycles. Move-op paper.

Nodes remain content-addressed by a stable key such as hash(normalize(text) + modelId + memory). If the new text equals an existing node’s key, we reuse that node and only add an edge, which deduplicates repeated passages automatically. CAS evaluation.

  1. Read changed ranges from the text CRDT transaction.
  2. Expand to covering node-boundary anchors.
  3. Produce a minimal set of node creations, edge insertions, and tombstones that makes the active path match the new text.
  4. Apply a move operation if spans were reordered.

This pipeline is deterministic for a given text delta and anchor map, which keeps replicas convergent. Yjs Y.Text Peritext Move-op paper.

// Pseudocode for the committer
function commitFromTextDelta(tx: TextDelta, anchors: AnchorIndex, dag: Dag) {
const ranges = inflateToAnchors(tx.changedRanges, anchors)
for (const r of ranges) {
const oldNodes = anchors.nodesCovering(r.before)
const newText = pageText.slice(r.after.start, r.after.end)
const reused = dag.findByContentKey(newText)
const newNode = reused ?? dag.createNode({ text: newText })
dag.spliceMultiParent(newNode, parentsOf(oldNodes)) // move-op safe
dag.tombstoneEdges(edgesSupersededBy(oldNodes, newNode)) // remove-wins
anchors.rebindRangeToNode(r.after, newNode) // RelativePos
}
}

Yjs RelativePosition Yjs Y.Text Move-op paper.

The page is always a single linear CRDT string. Read mode just renders that string. The DAG is only consulted to show provenance, alternate branches on demand, or to reuse existing nodes when a user splices passages. This preserves seamless editing and mental simplicity. Automerge Text Yjs Y.Text.

Because all users type into one text CRDT, character-level merges are handled there. The committer then derives structure, using Peritext-style intent rules to avoid surprising block merges when edits cross boundaries, and the move-op to keep reparenting safe under races. Peritext CSCW paper Move-op paper.

  • No “graph flicker”: only the text updates immediately. DAG updates happen off the main thread, then provenance badges and branch chips fade in.
  • Undo and redo operate on the page first. Structural undo derives from the same history.
  • If a rewrite would orphan a large subtree, show a non-blocking toast with an option to keep that subtree reachable via a labeled side edge.

These keep the DAG invisible unless the user asks for it. CRDT survey.

  • Cross-boundary paste produces one merged node and multi-parent edges.
  • Concurrent rip vs splice resolves remove-wins but preserves an undo window.
  • Reorder paragraphs triggers move-op, not delete+insert.
  • Determinism: identical text states yield identical DAG states across replicas.

Use the tree move CRDT tests and text CRDT transaction logs to validate. Move-op paper Yjs Y.Text.

CRDT survey Yjs RelativePosition.

On mobile the user edits a simple scrolling page. Behind the scenes the app derives and updates a DAG without exposing graph mechanics. We achieve this with a page-first CRDT, stable anchors, and a deterministic committer that rewrites just the necessary graph edges. Yjs Docs.

Use a single Y.Text as the visible page bound to CodeMirror 6 in a SolidStart client-only route. Lazy import CM6 on mount to shrink JS. This keeps SSR isolated and prevents hydration issues. CodeMirror 6 Docs SolidStart clientOnly.

Mark logical node boundaries with Y.RelativePosition anchors stored in a Y.Map. Relative positions stick to content under concurrent inserts or deletes, so the projection always knows where nodes begin and end without rescanning the whole string. Yjs RelativePosition.

Represent each DAG node as either a named fragment inside the root doc or a Y.Doc subdocument when you want lazy load or very large nodes. Subdocs are first class in Yjs and can be fetched on demand by the server layer. Hocuspocus guides also describe multi document patterns if you defer subdocs. Yjs Subdocuments Hocuspocus multi docs guide.

Committer - How Text Turns into DAG Updates

Section titled “Committer - How Text Turns into DAG Updates”

Debounce on idle or paste. For each changed range, expand to covering anchors, then emit the minimal set of operations: create or reuse a node by content key, add edges from all parents that contributed text, tombstone superseded edges, and apply a move operation when paragraphs were reordered. Because the input is a Yjs transaction plus anchor map, replicas compute identical graph updates. Yjs Docs.

// Pseudocode - derive minimal DAG edits from a Yjs text transaction
function commitFromTx(tx, anchors, dag) {
const ranges = expandToAnchors(tx.changedRanges, anchors)
for (const r of ranges) {
const newText = page.slice(r.after.start, r.after.end)
const node = dag.findByKey(hash(newText)) ?? dag.createNode({ text: newText })
dag.spliceMultiParent(node, parentsCovering(r.before)) // add incoming edges
dag.tombstoneEdges(edgesSupersededBy(r.before, node)) // soft delete
anchors.rebind(r.after, node.id) // move anchors
}
}

Yjs Docs.

Keep the page collaborative and fast. Use Awareness for cursors and selection highlights, but do not store presence in the document history. Awareness is an ephemeral CRDT that broadcasts lightweight state. Awareness.

Prefer a server-authoritative WebSocket with Redis pub or sub for horizontal scale and late-join reliability. y-websocket is the reference provider, with y-redis as a scalable backend. Hocuspocus offers an opinionated Yjs server with auth hooks and persistence. y-websocket docs y-redis Hocuspocus.

Persist the root doc and subdocs periodically by encoding updates with Y.encodeStateAsUpdate, and keep point-in-time snapshots for recovery or migrations. This enables deterministic restore for the page and the derived graph. Yjs Document Updates.

Anchor the editor route client-only, lazy load bindings, keep the Awareness payload small, and cache subdocs or fragments for the active strip plus a small ring buffer of nearby branches. These patterns come straight from the Yjs documentation and provider guidance. SolidStart clientOnly y-websocket docs Awareness.

If you need a Rust path, the yrs-warp server implements the Yjs protocol on Warp Tokio, which pairs well with mobile workloads where you want low tail latency and tight memory control. yrs-warp.

They type and swipe as if no graph exists. The page string is the source of truth for viewing and editing. The DAG only appears when they open Visualize or provenance. All concurrency is resolved at the text layer, and the committer deterministically keeps the graph in sync. Yjs Docs.

Drop-in Steps for Your SolidStart + Yjs Setup

Section titled “Drop-in Steps for Your SolidStart + Yjs Setup”
  1. Make the route client-only and lazy load CM6 and y-bindings. 2. Add an anchor index using RelativePosition. 3. Implement the debounced committer shown above. 4. Store graph nodes as fragments now and migrate hot paths to subdocs when size dictates. 5. Switch your transport to a Redis-backed server with snapshots. SolidStart clientOnly Yjs RelativePosition y-websocket docs y-redis Yjs Document Updates.

One-Shot Build Brief for a GPT-5 Codex Agent: “Mobile PWA Multiplayer Loom”

Section titled “One-Shot Build Brief for a GPT-5 Codex Agent: “Mobile PWA Multiplayer Loom””
You are an expert full-stack engineer. Generate a complete, production-ready mobile web (PWA) app named **Loom Mobile** that implements a multiplayer, human-in-the-loop interface for LLMs: an infinite, scrollable “sheet” where each scroll action can generate new branches; a swipe gesture can “rip and tear” away unwanted context; editing is seamless as if no DAG existed, while the underlying multiverse DAG stays consistent. [MDN PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) [W3C Manifest](https://www.w3.org/TR/appmanifest/) [Moire — Loom](https://generative.ink/posts/loom-interface-to-the-multiverse/)
## Deliverables
- A SolidStart + TypeScript monorepo PWA with service worker, manifest, install prompt, offline support, and Lighthouse-ready performance. Include Workbox for routing/caching. [MDN Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) [Workbox](https://developer.chrome.com/docs/workbox/service-worker-overview/) [SolidStart routing](https://docs.solidjs.com/solid-start/building-your-application/routing)
- Real-time multiplayer via **Yjs** using **y-websocket** on the client and **Hocuspocus** (+ **Redis** fanout) on the server; **Awareness** for presence; **y-indexeddb** for offline replays; graceful reconnect and conflict-free merges. [Yjs](https://github.com/yjs/yjs) [y-websocket](https://docs.yjs.dev/ecosystem/connection-provider/y-websocket) [Hocuspocus](https://tiptap.dev/docs/hocuspocus/introduction) [Awareness](https://docs.yjs.dev/api/about-awareness) [y-indexeddb](https://github.com/yjs/y-indexeddb) [y-redis](https://github.com/yjs/y-redis)
- A touch-first UI using Pointer Events: pull to generate, swipe to rip, pinch to zoom the block-multiverse preview, long-press to fork at token, two-finger pan to scrub history. Respect iOS/Android system gestures. [MDN Pointer Events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) [Apple HIG Gestures](https://developer.apple.com/design/human-interface-guidelines/gestures) [Material Gestures](https://m3.material.io/foundations/interaction/gestures)
- Editor: CodeMirror 6 bound to a Y.Text; live cursors/selections; per-user color/labels; undo isolated to user intent. [CodeMirror 6](https://codemirror.net/docs/) [y-codemirror.next](https://github.com/yjs/y-codemirror.next) [Awareness](https://docs.yjs.dev/api/about-awareness)
- Block-multiverse panel that visualizes near-future token paths with probability mass; renormalize on zoom; tap to branch. [Moire — Block multiverse](https://generative.ink/meta/block-multiverse/) [Moire — Loom](https://generative.ink/posts/loom-interface-to-the-multiverse/)
- Optional: client-side E2EE of Y updates via Web Crypto AES-GCM; room key bootstrap assumed out-of-band. [MDN SubtleCrypto](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)
## Functional Spec
1. **Infinite Sheet**
- Vertical scroll paginates “pages.” Crossing a generation waypoint triggers “roll-out”: request N continuations and render immediately; the DAG stores branches even if the user sees only the active projection. [Moire — Loom](https://generative.ink/posts/loom-interface-to-the-multiverse/) [MDN SW offline](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers)
2. **Rip gesture**
- Horizontal swipe over a span creates a **Mask** that excludes selected nodes/edges from the projection; underlying DAG keeps history for undo/restore. Use physics-informed thresholds and elastic overscroll. Avoid edge-swipe conflicts per HIG. [Apple HIG](https://developer.apple.com/design/human-interface-guidelines/gestures) [Pointer Events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events)
3. **Seamless editing**
- User edits the visible page as plain text. A **Projection Lens** maps page edits back to the DAG: split, merge, and annotate affected nodes; anchors use Yjs **RelativePosition** so selections survive concurrent edits. [Yjs docs](https://docs.yjs.dev/)
4. **Multiplayer**
- Presence cursors with names/colors via Awareness; offline edits persist to IndexedDB and sync on reconnect; Hocuspocus with Redis ensures horizontal scale and late-join catch-up. [Awareness](https://docs.yjs.dev/api/about-awareness) [y-indexeddb](https://github.com/yjs/y-indexeddb) [Hocuspocus](https://tiptap.dev/docs/hocuspocus/introduction) [y-redis](https://github.com/yjs/y-redis)
5. **Block multiverse**
- Inline panel: show tree of token prefixes with cumulative probability bars; renormalize when zooming; tap path → create branch at that prefix. [Moire — Block multiverse](https://generative.ink/meta/block-multiverse/)
6. **PWA**
- Manifest, icons, shortcuts, offline shell; SW caches app shell + doc snapshots; background sync for queued generations; install banner UX. [W3C Manifest](https://www.w3.org/TR/appmanifest/) [MDN Manifest](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest) [Workbox](https://developer.chrome.com/docs/workbox/service-worker-overview/)
## Data Model
- **Doc**: Y.Map with `{ nodes: Y.Array<NodeId>, edges: Y.Map<EdgeId, Edge>, masks: Y.Array<Mask>, meta: Y.Map }`. Node text in per-node Y.Text. [Yjs](https://github.com/yjs/yjs)
- **Projection**: deterministic fold over DAG minus masks → linear page; stores mapping table from page ranges → {nodeId, nodeOffset..}. [Moire — Loom](https://generative.ink/posts/loom-interface-to-the-multiverse/)
```typescript
// types/shared.ts
export type NodeID = string;
export type EdgeID = string;
export interface LoomNodeMeta {
model: string;
prompt?: string;
logprobs?: number[]; // optional for block multiverse
author?: string; // user/device
createdAt: number;
}
export interface LoomNode {
id: NodeID;
textKey: string; // key to Y.Text in ydoc
meta: LoomNodeMeta;
}
export interface LoomEdge { from: NodeID; to: NodeID; weight?: number }
export interface Mask { range: [number, number]; reason: 'rip'|'hide'|'conflict'; createdAt: number }
export interface ProjectionSpan { pageFrom: number; pageTo: number; node: NodeID; nodeFrom: number; nodeTo: number }
export interface GenerationRequest { parent: NodeID; promptSuffix: string; n: number; maxTokens: number; temperature: number }

GitHub — yjs.

Projection Lens Algorithm (Edit-as-if-No-DAG)

Section titled “Projection Lens Algorithm (Edit-as-if-No-DAG)”
  • Build spans: ProjectionSpan[] for current page. On text insert/delete:
    1. Map page offsets → {nodeId, nodeOffset} via spans.
    2. If edit crosses node boundary, split nodes and insert a merge node capturing the edited text; retarget incoming/outgoing edges to preserve structure; add an implicit merge edge.
    3. Use Yjs RelativePosition for anchors to remain stable under concurrent edits. Yjs RelativePosition

Moire — Loom Yjs docs.

  • Pull to generate: vertical overscroll at waypoints issues GenerationRequest and paints ghost blocks while awaiting tokens. Pointer Events
  • Swipe to rip: horizontal swipe over selection adds Mask and animates a tear; safe zones avoid iOS system edges. Apple HIG
  • Pinch to zoom: toggles block-multiverse density and renormalizes probabilities. Moire — Block multiverse
  • Client: y-websocket provider with JWT param; y-indexeddb for offline; Awareness presence (name, color, selection). y-websocket y-indexeddb Awareness
  • Server: Hocuspocus with Redis extension; persist snapshots to object store; backpressure + rate limits. Hocuspocus y-redis
  • Optional backend alt: Cloudflare Durable Objects with WebSocket hibernation for room-affinity scaling. Cloudflare DO
server/hocuspocus.ts
import { Server } from '@hocuspocus/server';
import { Redis } from '@hocuspocus/extension-redis';
export const server = Server.configure({
port: Number(process.env.PORT || 1234),
extensions: [ new Redis({ host: process.env.REDIS_HOST || '127.0.0.1', port: 6379 }) ],
async onAuthenticate({ token, documentName }) {
// verify JWT; return user identity & room permissions
return { userId: token?.sub, room: documentName };
},
async onStoreDocument({ documentName, state }) {
// persist Uint8Array state to object store (e.g., S3/R2)
},
async onLoadDocument({ documentName }) {
// return latest persisted Uint8Array or undefined
return undefined;
},
});
server.listen();

Hocuspocus y-redis.

  • Manifest with display: standalone, icons, shortcuts, categories; route /new and last-room deep-link. W3C Manifest MDN Manifest
{
"name": "Loom Mobile",
"short_name": "Loom",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }],
"shortcuts": [
{ "name": "New Loom", "short_name": "New", "url": "/new" },
{ "name": "Last Room", "short_name": "Resume", "url": "/r/last" }
]
}

W3C Manifest.

  • Service worker with Workbox: cache app shell, fonts, editor wasm; background sync queue for generation calls; network-first for WebSocket, stale-while-revalidate for docs. Workbox MDN SW
// sw.js (Workbox)
import { clientsClaim } from 'workbox-core';
import { setCacheNameDetails } from 'workbox-core';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
self.skipWaiting(); clientsClaim();
setCacheNameDetails({ prefix: 'loom-mobile' });
registerRoute(({request}) => request.destination === 'document',
new StaleWhileRevalidate({ cacheName: 'app-shell' }));
registerRoute(({url}) => url.pathname.startsWith('/api/gen'),
new StaleWhileRevalidate({ cacheName: 'gen-api' }));
// ...add background sync, fonts, wasm caches...

Workbox.

src/routes/r/[room].tsx
import { clientOnly } from '@solidjs/start/client';
const LoomEditor = clientOnly(() => import('../../widgets/LoomEditor'));
export default function Room() { return <LoomEditor />; }

SolidStart clientOnly.

widgets/LoomEditor.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
import { yCollab } from 'y-codemirror.next';
import { Awareness } from 'y-protocols/awareness';
const ydoc = new Y.Doc();
new IndexeddbPersistence(`loom:${roomId}`, ydoc);
const provider = new WebsocketProvider(import.meta.env.VITE_YWS_URL, roomId, ydoc, {
params: { token: await getJWT() },
});
const awareness = provider.awareness;
// set name/color/selection on awareness.localState
// bind CodeMirror 6 to a Y.Text for the active node

y-websocket y-indexeddb y-codemirror.next.

  • Request top-k with logprobs from LLM; render prefix DAG as blocks with heights ∝ cumulative probability; pinch to change depth; tap to branch. Moire — Block multiverse

Moire — Block multiverse.

  • Wrap Y updates in AES-GCM before transport; rotate per-room keys; store ciphertext server-side. MDN Web Crypto
// e2ee.ts (concept)
export async function encryptUpdate(update: Uint8Array, key: CryptoKey) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, update);
return { iv, ct: new Uint8Array(ct) };
}

MDN SubtleCrypto.encrypt.

  • PWA: manifest present, SW registered, installable, offline shell works; ≥90 Lighthouse PWA. MDN PWA web.dev PWA checklist
  • Multiplayer: two simulated clients show presence, convergent edits, and deterministic projections after concurrent rips/edits. Yjs Awareness
  • Gestures: pull generates branches, swipe masks text, pinch zooms multiverse; avoids edge conflicts per HIG. Apple HIG
  • Resilience: offline edits persist; reconnect resyncs; server horizontal scale with Redis. y-indexeddb y-redis
  • Cloudflare Durable Objects room-affine transport. Cloudflare DO
  • Rich-text path with Peritext-inspired spans. Ink & Switch Peritext
  • Export/import: JSON tree + linearized page; replay masks. Yjs