Xudanu's typed, bidirectional, unbreakable connections between passages — the defining feature of Nelson-style hypertext. How to create them, render them, and navigate them consistently.
crdt_sync.ts)
5. The useTransclusion Hook
6. Example A: Create a Typed Comment Link
7. Example B: Transclusion (Same Content, Two Places)
8. Example C: Backlinks — Who Links Into You
9. Example D: Disagreement Links & Structured Dissent
10. Example E: Profuse Overlapping Links + Density
11. Rendering: How Markers Paint on the Canvas
12. Span Migration: Links Survive Edits
13. Consistency Guidelines & Gotchas
14. Source Code Map
Xudanu implements Ted Nelson's fundamental distinction between two kinds of connections. They share the same HyperLink data model but serve opposite purposes. Getting this distinction straight is the single most important thing for new developers.
| Transclusion | Content Link | |
|---|---|---|
| Connects | The same content in multiple places | Different content with a typed relationship |
| Example | A quotation appears in 3 documents | A comment on a passage; a cross-reference |
| Data signal | Matching content fingerprint | link_types: Vec<u64> (typed) |
| UI today | Margin bars + inline resolved spans | Coloured typed markers (this guide) |
| Survives edits | Yes (span migration) | Yes (span migration) |
Figure 1: A transclusion (green) copies identical content between works. A content link (blue/red) connects different content with a typed relationship. A single document can have both.
Every connection — transclusion or typed link — is a HyperLink. A HyperLink has two (or more) named ends, conventionally "LeftEnd" (origin) and "RightEnd" (destination). Each end is a HyperRef that points at a specific span inside a specific work.
pub struct HyperLink { ends: HashMap<String, HyperRef>, // "LeftEnd" → origin, "RightEnd" → destination link_types: Vec<u64>, // empty = pure transclusion; non-empty = typed link } pub struct HyperRef { kind: HyperRefKind, // "single" = a span inside one work work_context: Option<BeId>, // which work this end points into excerpt: Option<Edition>, // a copy of the span text (fallback for search) start_position: Option<i64>, // canonical char offset (preferred) end_position: Option<i64>, // canonical char offset (preferred) provenance_chain: Vec<ProvenanceHop>, }
start_position / end_position char offsets, and (b) an excerpt of the text. The frontend prefers the stored span; if absent it falls back to find_excerpt_positions (a text search). Always pass start/end when creating a link — the excerpt search is ambiguous when the same phrase appears twice.
The client sends these op strings over the WebSocket. The opcodes live in protocol.rs:
| Client op | Opcode | Purpose |
|---|---|---|
link_create | 0x0701 | Create a HyperLink with origin/destination refs |
link_get | 0x0702 | Fetch one link with both ends + titles |
link_list_for_work | 0x0705 | All links touching a work (both directions) |
link_delete | 0x0704 | Remove a link |
link_set_types | 0x0709 | Assign typed link_types to a link |
link_type_list | 0x070B | List registered type ids + names |
work_backlinks | 0x0343 | Incoming + outgoing links for a work |
find_excerpt_positions | 0x0706 | Recover span offsets from excerpt text (fallback) |
LinkCreate = 0x0706. That was wrong — 0x0706 is FindExcerptPositions. LinkCreate is 0x0701. Custom type registration (LinkTypeRegister, 0x070A) exists on the server via register_link_type(type_id, name) but is not yet exposed as a client method — the frontend ships with five built-in types.
Five default types are pre-registered (see DEFAULT_LINK_TYPES in useTransclusion.ts and LINK_TYPE_STYLES in CollaborativeEditor.tsx). The colour and dash pattern are the contract — use them everywhere consistently.
| ID | Name | Colour | Underline style | Typical use |
|---|---|---|---|---|
1 | Comment | #58a6ff | dashed [4,3] | Annotate a passage; scholarly note |
2 | Reference | #3fb950 | solid [] | Cross-reference, citation, "see this" |
3 | Disagreement | #f85149 | long dash [8,3] | Mark a contested claim; counter-argument |
4 | Quotation | #a371f7 | dotted [1,3] | This passage quotes another |
5 | See Also | #d29922 | dash-dot [6,2,1,2] | Reading path; related work |
Figure 2: Each type has a distinct dash pattern so overlapping links remain readable even without colour.
crdt_sync.ts)These are the methods on CrdtSyncClient you will use. All are async and go over the WebSocket.
// Create a link. ALWAYS pass { start, end } in the refs — they become // start_position/end_position on the wire and are the canonical span. const linkId = await client.linkCreate( originWorkId, destinationWorkId, { excerpt: sourceText, start: srcStart, end: srcEnd }, // origin_ref { excerpt: targetText, start: tgtStart, end: tgtEnd }, // destination_ref ); // Assign a type (Comment=1, Reference=2, ...). Call right after linkCreate. await client.linkSetTypes(linkId, [1]); // now it's a Comment link // Read const links = await client.linkListForWork(workId); // LinkEntry[] const one = await client.linkGet(linkId); // full payload w/ both refs const types = await client.linkTypeList(); // {type_id, name}[] // Backlinks: who links INTO this work (incoming) and what it links to (outgoing) const backlinks = await client.findBacklinks(workId); // BacklinkEntry[] // Fallback position search (used internally when a ref lacks stored coords) const hits = await client.findExcerptPositions(workId, excerpt); // Delete await client.linkDelete(linkId);
interface LinkEntry { link_id: number; origin: number; destination: number; origin_ref: HyperRefPayload | null; destination_ref: HyperRefPayload | null; link_types?: number[]; origin_title?: string; destination_title?: string; origin_archived?: boolean; destination_archived?: boolean; } interface HyperRefPayload { kind: string; work_context: number | null; excerpt: string | null; start_position?: number | null; // ← read this to place the marker end_position?: number | null; provenance_chain?: ProvenanceHop[]; } interface BacklinkEntry { source_work_id: number; link_id: number; link_type: string; // "hyperlink_incoming" | "hyperlink_outgoing" excerpt?: string; title?: string; }
useTransclusion HookThe hook manages both transclusion and typed-link state for the active work. Despite the name, it is the single home for all link UI flows. (A separate useContentLinks hook was proposed in the FR-4 spec; the functionality lives here instead.)
const t = useTransclusion(); // t.links — LinkEntry[] for the current work // t.markers — TransclusionMarker[] ready to hand to <CollaborativeEditor> // t.backlinks — BacklinkEntry[] (populated by loadBacklinks) // t.linkTypes — {type_id, name}[] // t.pendingLink — held source selection while the user picks a target // 1. User selects text in the source document: t.holdLinkSelection(workId, workTitle, selStart, selEnd, selText); // 2. ...user navigates to the target document and selects target text... // 3. Create the typed link: const id = await t.createContentLink( client, targetWorkId, targetStart, targetEnd, targetText, typeId ); // createContentLink calls linkCreate (with start/end) then linkSetTypes([typeId]) // Refresh data on work switch: t.loadLinks(client, workId, works); // builds t.markers (prefers stored spans) t.loadBacklinks(client, workId); t.loadLinkTypes(client);
loadLinks builds the markers for you. It reads each link's local HyperRef end, prefers the stored start_position/end_position, and only falls back to findExcerptPositions when those are absent. The resulting TransclusionMarker[] is passed straight to <CollaborativeEditor transclusionMarkers={...} />.
Use case: A reviewer selects a passage in an essay and attaches a comment that lives in a separate notes document. This is the canonical two-part selection flow.
// Source work: "Essay" (0x10). Target work: "Reviewer notes" (0x20). // The user selected chars 142..168 in the essay ("privacy is a human right") // and chars 0..34 in the notes ("this claim is contested in case law"). t.holdLinkSelection(0x10, "Essay", 142, 168, "privacy is a human right"); // ...navigate to the notes document, select the target span... await t.createContentLink(client, 0x20, 0, 34, "this claim is contested in case law", 1); // typeId 1 = Comment (blue, dashed)
Figure 3: A Comment link in the origin document. The left margin shows a blue "C" bar; the passage has a blue dashed underline. Hovering reveals the target work, type, excerpt, a "Go to" button, and a red "Delete link" button.
onNavigateToWork (jumps to the linked work). Double-click calls onShowBacklinks (jumps there and surfaces its connections). Both are wired in AppShell.tsx.
Use case: A quotation appears in a source canon work and is re-used verbatim in a newsletter. Transclusion keeps the two in sync and records provenance.
// Hold the source selection in the canon work (0x30) t.holdSelection(0x30, "Canon", 200, 226, "privacy is a human right"); // ...navigate to the newsletter (0x40), click at the insertion point... await t.placeTransclusion(client, 0x40, insertionPosition); // No linkSetTypes call → link_types stays empty → rendered as a transclusion // (hatched margin bar, inline resolved span, provenance chain).
Figure 4: A transclusion (left) shows the resolved text inline with a hatched margin bar. A typed link (right) underlines the existing text without copying it.
Use case: You open your essay and want to see everyone who has commented on or referenced it. Backlinks are the incoming half of the bidirectional graph.
// On work switch, AppShell already does this for you via the hook: await t.loadBacklinks(client, workId); // → t.backlinks is now BacklinkEntry[] with source_work_id, title, excerpt, link_type // ...or call the client directly: const incoming = (await client.findBacklinks(workId)) .filter(b => b.link_type === "hyperlink_incoming"); incoming.forEach(b => console.log(`${b.title}: ${b.excerpt}`));
Figure 5: Outgoing links paint on the left margin; incoming backlinks paint on the right. The Connections panel lists both with arrows (← incoming, → outgoing).
find_backlinks returns at most one entry per linking work even if ten links come from it. Keep this in mind when counting — the count is "how many works reference me", not "how many links".
Use case: Multiple authors contest a passage. Each attaches a Disagreement link to their counter-argument. Readers see all sides at the contested text.
// Reviewer 1 holds the contested passage and links their rebuttal t.holdLinkSelection(essayId, "Essay", 142, 168, "privacy is a human right"); await t.createContentLink(client, rebuttalAId, s, e, "Doe 2021 disproves this", 3); // Reviewer 2 links a different rebuttal to the SAME passage t.holdLinkSelection(essayId, "Essay", 142, 168, "privacy is a human right"); await t.createContentLink(client, rebuttalBId, s, e, "the right is not absolute", 3); // Both render as red underlines; FR-4.5 layers them so both stay visible.
Figure 6: Two Disagreement links on the same passage. They stack on the left margin and layer their underlines vertically so neither hides the other.
Use case: A heavily-annotated legal clause accumulates many overlapping links. Rather than paint a mess, the canvas collapses 5+ overlapping links into a single density pill; clicking expands the cluster.
// 6 overlapping Comment links // on the same sentence → one pill: // // ┌──┐ privacy is a human right // │6 │ ████████████████████ // └──┘ // click → expands to individual // lane-stacked markers
// Top-right control toggles types. // null filter = show all; // a Set = only those types // (transclusions always show). filterMarkersByType(markers, new Set([3])); // → only Disagreement links
Figure 7: Above the density threshold (5), overlapping links collapse to a single amber pill showing the count. The pure helpers assignLinkLanes, clusterOverlappingMarkers, and filterMarkersByType drive this (see link-markers.ts).
CollaborativeEditor.drawOverlay() paints onto a canvas layered above the text. The link pass runs after attribution and transclusion passes:
LINK_TYPE_STYLES[linkTypeId]. Overlapping links offset by lane * 2px (FR-4.5).lane * 4px. Provenance chains stack further right.rect.width - 3 - lane*4.MarkerHitZone used by mouse-move (tooltip) and click (navigate / expand).Figure 8: A marker is three things at once: a margin bar, an underline, and an invisible hit zone. All driven by the same TransclusionMarker record.
A link's stored start_position/end_position are not fixed byte offsets — they are migrated whenever the text changes. When a user edits a work, the server walks every link touching that work and remaps each end's span through the edit delta. This is why stored coordinates beat excerpt re-search: they stay correct after edits, while a fresh excerpt search might land on a new copy of the same phrase.
Figure 9: migrate_link_spans_for_delta() in server.rs remaps link endpoints on every edit. Thirteen dedicated tests cover insert/delete/replace/inside-span cases.
start/end when creating a link. The client now transmits them as start_position/end_position. Omitting them forces the brittle excerpt-search fallback (ambiguous on duplicate phrases).linkSetTypes right after linkCreate for a content link. A link with empty link_types is a transclusion and renders with hatch bars, not typed underlines.register_link_type first.loadLinks, not hand-built. Let the hook resolve positions (stored span first, excerpt fallback second). Building TransclusionMarker objects yourself duplicates that logic.find_backlinks dedupes per source work. Ten links from one reviewer show as one backlink entry.drawOverlay recomputes markers, lanes, and clusters each redraw — don't cache marker DOM. This keeps live multi-user edits correct.kind. The client hardcodes kind: "single" for both refs, which is correct for spans inside a single work. Other kinds (federated cross-peer refs) exist in the data model but have no selection UX yet — leave them to future work.
| File | What It Implements |
|---|---|
src/edition/links.rs | HyperLink, HyperRef, spans, provenance hops, the multi-ended end map |
src/server/server.rs | create_link, find_backlinks, migrate_link_spans_for_delta, link persistence round-trip |
src/server/transport/dispatch.rs | Wire handlers for link_create, link_set_types, work_backlinks, etc. |
src/server/transport/protocol.rs | Opcodes, HyperRefPayload, BacklinkEntryPayload, LinkPayload |
src/persist/manifest.rs | LinkEntry persistence shape, links_hash chunk, dual-slot round-trip |
web/app/src/api/crdt_sync.ts | linkCreate (with span coords), linkSetTypes, findBacklinks, linkListForWork |
web/app/src/link-markers.ts | Pure helpers: resolveMarkerPositions, assignLinkLanes, clusterOverlappingMarkers, filterMarkersByType |
web/app/src/hooks/useTransclusion.ts | Link + transclusion state, createContentLink, holdLinkSelection, loadLinks, loadBacklinks |
web/app/src/components/CollaborativeEditor.tsx | drawOverlay marker pass, LINK_TYPE_STYLES, density pills, filter control, hit zones + tooltip |
web/app/src/components/shell/AppShell.tsx | Wires markers + onShowBacklinks into the editor; backlinks into the Connections panel |
web/app/src/components/panels/ConnectionsSection.tsx | Unified list of transclusions, typed links, and backlinks with filter dropdown |
link-markers.test.ts (lane/density/filter/position math) and link-create.test.ts (span-coordinate payload). Run cargo test --features server --lib link and npm test.