Links & Backlinks

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.

1. Nelson's Two-Connection Model 2. The Data Model: HyperLink, HyperRef, Spans 3. Link Types & Their Visual Style 4. The Client API (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

1. Nelson's Two-Connection Model

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.

 TransclusionContent Link
ConnectsThe same content in multiple placesDifferent content with a typed relationship
ExampleA quotation appears in 3 documentsA comment on a passage; a cross-reference
Data signalMatching content fingerprintlink_types: Vec<u64> (typed)
UI todayMargin bars + inline resolved spansColoured typed markers (this guide)
Survives editsYes (span migration)Yes (span migration)
Transclusion vs Content Link Work A — "Essay on privacy" "privacy is a human right" the canonical source span Work B — "Newsletter" "privacy is a human right" SAME bytes — a copy transclusion (identical content) Work C — "Reviewer notes" "this claim is contested in case law" DIFFERENT text — linked by type: Comment Comment Disagreement

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.

Xanalogical principle. "A link can apply to any content, wherever the content may be re-used" and "thousands of overlapping links on the same body of content, created without coordination by many users." — Ted Nelson. Links are profuse, typed, and bidirectional by design.

2. The Data Model: HyperLink, HyperRef, Spans

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>,
}
The two ways a marker finds its position. A HyperRef carries (a) canonical 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.

Wire operations

The client sends these op strings over the WebSocket. The opcodes live in protocol.rs:

Client opOpcodePurpose
link_create0x0701Create a HyperLink with origin/destination refs
link_get0x0702Fetch one link with both ends + titles
link_list_for_work0x0705All links touching a work (both directions)
link_delete0x0704Remove a link
link_set_types0x0709Assign typed link_types to a link
link_type_list0x070BList registered type ids + names
work_backlinks0x0343Incoming + outgoing links for a work
find_excerpt_positions0x0706Recover span offsets from excerpt text (fallback)
Opcode correction. Earlier internal docs cited 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.

3. Link Types & Their Visual Style

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.

IDNameColourUnderline styleTypical use
1Comment#58a6ffdashed [4,3]Annotate a passage; scholarly note
2Reference#3fb950solid []Cross-reference, citation, "see this"
3Disagreement#f85149long dash [8,3]Mark a contested claim; counter-argument
4Quotation#a371f7dotted [1,3]This passage quotes another
5See Also#d29922dash-dot [6,2,1,2]Reading path; related work
The Five Underline Styles (as painted on the canvas) Comment Reference Disagreement Quotation See Also

Figure 2: Each type has a distinct dash pattern so overlapping links remain readable even without colour.

4. The Client API (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);

Key payload shapes

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;
}

5. The useTransclusion Hook

The 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={...} />.

6. Example A: Create a Typed Comment Link

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.

Code — the full 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)
What it looks like in Xudanu — Work "Essay" (origin side) C The modern discourse around digital autonomy often takes a forceful tone. Many argue that privacy is a human right and that any backdoor, however well-meaning, undermines the entire guarantee. Reviewer notes Comment — links to “this claim is contested in case law” Go to 0020 × Delete link All

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.

Single-click navigates; double-click shows backlinks. Clicking the marker calls onNavigateToWork (jumps to the linked work). Double-click calls onShowBacklinks (jumps there and surfaces its connections). Both are wired in AppShell.tsx.

7. Example B: Transclusion (Same Content, Two Places)

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.

Code — transclusion places the source span at a target position
// 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).
Transclusion marker (no type) vs typed link Transclusion weekly digest: as stated, "privacy is a human right" solid hatch bar · provenance chain to Canon Typed Comment link the essay claims that privacy is a human right dashed underline · left "C" bar

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.

8. Example C: Backlinks — Who Links Into You

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.

Code — query and render backlinks
// 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}`));
Incoming backlinks render on the RIGHT margin see Smith 2019 for background on this privacy is a human right ← left: outgoing (this work links out) · right: incoming (others link in) → Connections ← Reviewer notes · “contested in case law” ← Rebuttal · “see Doe 2021 §4” Backlinks (2)

Figure 5: Outgoing links paint on the left margin; incoming backlinks paint on the right. The Connections panel lists both with arrows (← incoming, → outgoing).

Backlinks are deduplicated per source work. 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".

9. Example D: Disagreement Links & Structured Dissent

Use case: Multiple authors contest a passage. Each attaches a Disagreement link to their counter-argument. Readers see all sides at the contested text.

Code — several reviewers each create a Disagreement link
// 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.
Layered disagreements on one passage (FR-4.5) privacy is a human right lane 0 · lane 1 — overlapping links get distinct lanes (1px+ apart)

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.

10. Example E: Profuse Overlapping Links + Density

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.

Collapsed (5+ links)
// 6 overlapping Comment links
// on the same sentence → one pill:
//
//  ┌──┐ privacy is a human right
//  │6 │ ████████████████████
//  └──┘
// click → expands to individual
// lane-stacked markers
Filter by type
// 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
Density pill collapses a crowded region 6 links privacy is a human right 6 links in this region Click the amber pill → cluster expands to individually lane-stacked, type-coloured markers.

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).

11. Rendering: How Markers Paint on the Canvas

CollaborativeEditor.drawOverlay() paints onto a canvas layered above the text. The link pass runs after attribution and transclusion passes:

  1. Underline — a dashed/solid line beneath the linked span, colour + dash from LINK_TYPE_STYLES[linkTypeId]. Overlapping links offset by lane * 2px (FR-4.5).
  2. Left margin bar — outgoing typed links, at x = lane * 4px. Provenance chains stack further right.
  3. Right margin bar — incoming typed backlinks, at rect.width - 3 - lane*4.
  4. Density pill — amber, count badge, when a cluster reaches 5+ links.
  5. Hit zones — every marker registers a MarkerHitZone used by mouse-move (tooltip) and click (navigate / expand).
  6. Description boxes — when a link has a description, a coloured box appears in the right margin showing the type label and description text.
  7. Connecting lines — dotted lines drawn from each description box to the source text. Lines anchor to the underline position (bottom of the text), not the centre of the span. Overlapping links use lane separation so each connecting line stays distinguishable.
Anatomy of a marker (outgoing Comment link) left margin (outgoing) privacy is a human right dashed underline (type style) hit zone (click → navigate · hover → tooltip)

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.

12. Span Migration: Links Survive Edits

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.

Span migration through an insertion Before: link spans [12..30] privacy is a human right insert "fundamentally, " at pos 0 After: same link, spans [30..48] — migrated by +18 fundamentally, privacy is a human right

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.

13. Consistency Guidelines & Gotchas

Don't mix 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.

14. Source Code Map

FileWhat It Implements
src/edition/links.rsHyperLink, HyperRef, spans, provenance hops, the multi-ended end map
src/server/server.rscreate_link, find_backlinks, migrate_link_spans_for_delta, link persistence round-trip
src/server/transport/dispatch.rsWire handlers for link_create, link_set_types, work_backlinks, etc.
src/server/transport/protocol.rsOpcodes, HyperRefPayload, BacklinkEntryPayload, LinkPayload
src/persist/manifest.rsLinkEntry persistence shape, links_hash chunk, dual-slot round-trip
web/app/src/api/crdt_sync.tslinkCreate (with span coords), linkSetTypes, findBacklinks, linkListForWork
web/app/src/link-markers.tsPure helpers: resolveMarkerPositions, assignLinkLanes, clusterOverlappingMarkers, filterMarkersByType
web/app/src/hooks/useTransclusion.tsLink + transclusion state, createContentLink, holdLinkSelection, loadLinks, loadBacklinks
web/app/src/components/CollaborativeEditor.tsxdrawOverlay marker pass, LINK_TYPE_STYLES, density pills, filter control, hit zones + tooltip
web/app/src/components/shell/AppShell.tsxWires markers + onShowBacklinks into the editor; backlinks into the Connections panel
web/app/src/components/panels/ConnectionsSection.tsxUnified list of transclusions, typed links, and backlinks with filter dropdown
Test coverage. Backend: ~90 link tests incl. 13 span-migration tests and 4 backlink tests. Frontend: 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.