+ );
+};
diff --git a/apps/web/app/components/variants.tsx b/apps/web/app/components/variants.tsx
new file mode 100644
index 0000000..f571c8c
--- /dev/null
+++ b/apps/web/app/components/variants.tsx
@@ -0,0 +1,114 @@
+import { useEffect, useSyncExternalStore } from "react";
+import { Link } from "react-router";
+import { keyTextColor } from "~/lib/color";
+import { diffPath } from "~/lib/paths";
+import { authorName } from "~/lib/profile";
+import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles";
+import type { Standing } from "~/lib/spots";
+import type { Variant } from "~/lib/variants";
+import { AuthorAvatar } from "./author-avatar";
+
+export const VARIANTS_ID = "under-this-name";
+
+const asDate = (seconds: number): string => new Date(seconds * 1000).toISOString().slice(0, 10);
+
+/**
+ * What their copy is, against the one on this page, in the fewest words that
+ * stay honest: how many passages it changes, that it reads the same, or that
+ * it only shares the name.
+ */
+const said = (standing: Standing): string => {
+ if (standing.kind === "same") return "reads the same";
+ if (standing.kind === "independent") return "its own writing";
+ return `changes ${standing.places} ${standing.places === 1 ? "passage" : "passages"}`;
+};
+
+/**
+ * The other documents published under this page's identifier. Drawn dashed like
+ * everything on this site that is available rather than settled: nobody vouches
+ * for these, they exist, and existing is the whole point.
+ */
+export const Variants = ({
+ variants,
+ standings,
+ from,
+}: {
+ variants: Variant[];
+ standings: Record;
+ /** The document whose page this section sits on, and a comparison's base side. */
+ from: { npub: string; identifier: string };
+}) => {
+ const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState);
+
+ useEffect(() => {
+ if (variants.length > 0) wantAuthors(variants.map((variant) => variant.pubkey));
+ }, [variants]);
+
+ if (variants.length === 0) return null;
+
+ return (
+
+
+ Under this name
+
+
+ Anyone may publish a document under this name. These are the other keys that have.
+
+
+ );
+};
diff --git a/apps/web/app/lib/diff.server.ts b/apps/web/app/lib/diff.server.ts
new file mode 100644
index 0000000..8e21f2c
--- /dev/null
+++ b/apps/web/app/lib/diff.server.ts
@@ -0,0 +1,17 @@
+import { diffMarkdown, KINSHIP_FLOOR, type MarkdownDiff } from "@openspecs/markdown";
+import { createLoadCache } from "./cache.server";
+import { mentionResolver } from "./mention";
+import type { CachedSpec } from "./specs.server";
+
+export { KINSHIP_FLOOR };
+
+/**
+ * Keyed on the two revisions, so a diff is computed once per pair however many
+ * readers open it, and a republished document makes a new key by itself.
+ */
+const diffs = createLoadCache({ max: 100, ttlMs: 60 * 60 * 1000 });
+
+export const loadDiff = (base: CachedSpec, other: CachedSpec): Promise =>
+ diffs.get(`${base.page.eventId}:${other.page.eventId}`, async () =>
+ diffMarkdown(base.event.content, other.event.content, { mention: mentionResolver({}) }),
+ );
diff --git a/apps/web/app/lib/paths.ts b/apps/web/app/lib/paths.ts
index f719618..18f3b0d 100644
--- a/apps/web/app/lib/paths.ts
+++ b/apps/web/app/lib/paths.ts
@@ -73,6 +73,10 @@ export const oembedPath = (canonical: string): string =>
export const eventPath = (npub: string, identifier: string): string =>
`/spec/${npub}/${encodeURIComponent(identifier)}/event.json`;
+/** What another key's document under the same name changes against this one. */
+export const diffPath = (npub: string, identifier: string, otherNpub: string): string =>
+ `/spec/${npub}/${encodeURIComponent(identifier)}/diff/${otherNpub}`;
+
/** A document nobody has written yet, which belongs to whichever key is connected. */
export const newSpecPath = (): string => "/new";
diff --git a/apps/web/app/lib/spots.test.ts b/apps/web/app/lib/spots.test.ts
new file mode 100644
index 0000000..2936dd4
--- /dev/null
+++ b/apps/web/app/lib/spots.test.ts
@@ -0,0 +1,102 @@
+import { parseSpec, SPEC_KIND, toNpub } from "@openspecs/nostr";
+import { describe, expect, it } from "vitest";
+import { compareCopies, type SpotBase } from "./spots";
+
+const BASE_KEY = "1336a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e";
+const OTHER = "2446a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e";
+const THIRD = "3556a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e";
+
+const CONTENT = "# NIP-00\n\nOne says a full sentence.\n\nTwo says a full sentence.";
+
+const base: SpotBase = {
+ content: CONTENT,
+ title: "NIP-00",
+ npub: toNpub(BASE_KEY),
+ identifier: "nip-00",
+};
+
+const specOf = (pubkey: string, content: string) => {
+ const spec = parseSpec({
+ id: "a".repeat(64),
+ pubkey,
+ created_at: 1,
+ kind: SPEC_KIND,
+ tags: [["d", "nip-00"]],
+ content,
+ sig: "b".repeat(128),
+ });
+ if (spec === null) throw new Error("the fixture does not parse as a document");
+ return spec;
+};
+
+describe("compareCopies", () => {
+ it("marks a changed paragraph at its rendered element, past the dropped title", () => {
+ // The title heading is block 0 but the page drops it, so the second
+ // paragraph is the page's element 1.
+ const other = specOf(OTHER, CONTENT.replace("Two says", "Two now says"));
+ const { spots } = compareCopies(base, [other]);
+ expect(spots).toHaveLength(1);
+ expect(spots[0]?.element).toBe(1);
+ expect(spots[0]?.entries[0]).toMatchObject({
+ pubkey: OTHER,
+ diffHref: `/spec/${base.npub}/nip-00/diff/${toNpub(OTHER)}`,
+ });
+ expect(spots[0]?.entries[0]?.html).toContain("");
+ });
+
+ it("groups two keys touching the same passage into one spot", () => {
+ const { spots } = compareCopies(base, [
+ specOf(OTHER, CONTENT.replace("Two says", "Two now says")),
+ specOf(THIRD, CONTENT.replace("Two says", "Two also says")),
+ ]);
+ expect(spots).toHaveLength(1);
+ expect(spots[0]?.entries.map((entry) => entry.pubkey)).toEqual([OTHER, THIRD]);
+ });
+
+ it("marks nothing for a copy below the kinship floor", () => {
+ const other = specOf(OTHER, "Entirely different words about an unrelated idea altogether.");
+ const { spots, standings } = compareCopies(base, [other]);
+ expect(spots).toEqual([]);
+ expect(standings[OTHER]).toEqual({ kind: "independent" });
+ });
+
+ it("marks blocks added before everything at element minus one", () => {
+ const other = specOf(OTHER, `A fresh opening block.\n\n${CONTENT}`);
+ const { spots } = compareCopies(base, [other]);
+ expect(spots).toHaveLength(1);
+ expect(spots[0]?.element).toBe(-1);
+ });
+
+ it("keeps two separated passages as two spots, in reading order", () => {
+ const between = `${CONTENT}\n\nThree stays put in both copies.\n\nFour says a full sentence.`;
+ const other = specOf(
+ OTHER,
+ between.replace("One says", "One now says").replace("Four says", "Four now says"),
+ );
+ const { spots } = compareCopies({ ...base, content: between }, [other]);
+ expect(spots.map((spot) => spot.element)).toEqual([0, 3]);
+ });
+
+ it("covers a contiguous run of changed passages with one spot", () => {
+ const other = specOf(
+ OTHER,
+ CONTENT.replace("One says", "One now says").replace("Two says", "Two now says"),
+ );
+ const { spots } = compareCopies(base, [other]);
+ expect(spots.map((spot) => spot.element)).toEqual([0]);
+ });
+
+ it("tells a copy's standing: how many passages it changes", () => {
+ const other = specOf(OTHER, CONTENT.replace("Two says", "Two now says"));
+ const { standings } = compareCopies(base, [other]);
+ expect(standings[OTHER]).toEqual({ kind: "kin", places: 1 });
+ });
+
+ it("tells a copy reading the same, even when only a link target differs", () => {
+ const linked = `${CONTENT.replace("sentence.", "sentence with [a link](./a.md).")}`;
+ const other = specOf(OTHER, linked.replace("./a.md", "./b.md"));
+ const { spots, standings } = compareCopies({ ...base, content: linked }, [other]);
+ expect(spots).toEqual([]);
+ expect(standings[OTHER]).toEqual({ kind: "same" });
+ });
+});
diff --git a/apps/web/app/lib/spots.ts b/apps/web/app/lib/spots.ts
new file mode 100644
index 0000000..6c0cf77
--- /dev/null
+++ b/apps/web/app/lib/spots.ts
@@ -0,0 +1,107 @@
+import {
+ type BlockAnchor,
+ blockAnchors,
+ compareMarkdown,
+ KINSHIP_FLOOR,
+ type MarkdownChange,
+} from "@openspecs/markdown";
+import { type Spec, toNpub } from "@openspecs/nostr";
+import { mentionResolver } from "./mention";
+import { diffPath } from "./paths";
+
+export type SpotEntry = {
+ pubkey: string;
+ npub: string;
+ /** The change as the comparison renders it, sanitized by the same pipeline as the page. */
+ html: string;
+ /** The whole comparison, read from this document's side. */
+ diffHref: string;
+};
+
+/** One place in the document where other keys' copies differ, however many do. */
+export type Spot = {
+ /**
+ * Which of the page's rendered top level elements the marker sits at, `-1`
+ * for changes before everything. The panel always unfolds below the element:
+ * whether the passage reads differently there or new blocks follow it, below
+ * is where the reader's eye goes next.
+ */
+ element: number;
+ entries: SpotEntry[];
+};
+
+/**
+ * A change anchored on source blocks, placed among the page's elements. The
+ * page renders fewer elements than the source has blocks, so the position is
+ * the count of rendered blocks before the anchor. A change to a block the page
+ * draws nothing for marks nothing: there is no place to put it.
+ */
+const placeOf = (anchors: BlockAnchor[], change: MarkdownChange): number | null => {
+ const renderedBefore = (index: number) =>
+ anchors.slice(0, index).filter((anchor) => anchor.rendered).length;
+
+ if (change.placement === "after") {
+ return change.anchor < 0 ? -1 : renderedBefore(change.anchor + 1) - 1;
+ }
+ const anchor = anchors[change.anchor];
+ if (anchor === undefined || !anchor.rendered) return null;
+ return renderedBefore(change.anchor);
+};
+
+export type SpotBase = {
+ content: string;
+ title: string;
+ npub: string;
+ identifier: string;
+};
+
+/** Where a copy stands against this document, said in one short phrase on its card. */
+export type Standing = { kind: "kin"; places: number } | { kind: "same" } | { kind: "independent" };
+
+export type CopiesComparison = {
+ spots: Spot[];
+ /** By the copy's pubkey. A copy whose comparison never ran has no standing. */
+ standings: Record;
+};
+
+/**
+ * Every place where another key's copy departs from this document, grouped by
+ * the element it concerns so five copies touching one paragraph make one mark,
+ * and each copy's standing alongside. A copy below the kinship floor annotates
+ * nothing: it would mark every paragraph, and its card says it is its own
+ * writing instead.
+ */
+export const compareCopies = (base: SpotBase, others: Spec[]): CopiesComparison => {
+ const anchors = blockAnchors(base.content, base.title);
+ const spots = new Map();
+ const standings: Record = {};
+
+ for (const other of others) {
+ const comparison = compareMarkdown(base.content, other.content, {
+ mention: mentionResolver({}),
+ });
+ if (comparison.similarity < KINSHIP_FLOOR) {
+ standings[other.pubkey] = { kind: "independent" };
+ continue;
+ }
+ standings[other.pubkey] =
+ comparison.changes.length === 0
+ ? { kind: "same" }
+ : { kind: "kin", places: comparison.changes.length };
+
+ const npub = toNpub(other.pubkey);
+ for (const change of comparison.changes) {
+ const element = placeOf(anchors, change);
+ if (element === null) continue;
+ const spot = spots.get(element) ?? { element, entries: [] };
+ spot.entries.push({
+ pubkey: other.pubkey,
+ npub,
+ html: change.html,
+ diffHref: diffPath(base.npub, base.identifier, npub),
+ });
+ spots.set(element, spot);
+ }
+ }
+ return { spots: [...spots.values()].sort((a, b) => a.element - b.element), standings };
+};
diff --git a/apps/web/app/lib/variants.test.ts b/apps/web/app/lib/variants.test.ts
new file mode 100644
index 0000000..83541f5
--- /dev/null
+++ b/apps/web/app/lib/variants.test.ts
@@ -0,0 +1,51 @@
+import { parseSpec, SPEC_KIND, toNpub } from "@openspecs/nostr";
+import { describe, expect, it } from "vitest";
+import { selectVariants } from "./variants";
+
+const SHOWN = "1336a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e";
+const OTHER = "2446a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e";
+const THIRD = "3556a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e";
+
+const specOf = (pubkey: string, content = "text", createdAt = 1) => {
+ const spec = parseSpec({
+ id: "a".repeat(64),
+ pubkey,
+ created_at: createdAt,
+ kind: SPEC_KIND,
+ tags: [
+ ["d", "nip-07"],
+ ["title", "NIP-07"],
+ ],
+ content,
+ sig: "b".repeat(128),
+ });
+ if (spec === null) throw new Error("the fixture does not parse as a document");
+ return spec;
+};
+
+describe("selectVariants", () => {
+ it("drops the key whose document the page already is", () => {
+ const variants = selectVariants([specOf(SHOWN), specOf(OTHER)], SHOWN);
+ expect(variants.map((variant) => variant.pubkey)).toEqual([OTHER]);
+ });
+
+ it("drops blank records, the way listings do", () => {
+ expect(selectVariants([specOf(OTHER, " ")], SHOWN)).toEqual([]);
+ });
+
+ it("lists the newest revision first", () => {
+ const variants = selectVariants([specOf(OTHER, "text", 1), specOf(THIRD, "text", 2)], SHOWN);
+ expect(variants.map((variant) => variant.pubkey)).toEqual([THIRD, OTHER]);
+ });
+
+ it("carries what a row draws", () => {
+ const [variant] = selectVariants([specOf(OTHER, "text", 7)], SHOWN);
+ expect(variant).toMatchObject({
+ pubkey: OTHER,
+ npub: toNpub(OTHER),
+ title: "NIP-07",
+ revisedAt: 7,
+ path: `/spec/${toNpub(OTHER)}/nip-07`,
+ });
+ });
+});
diff --git a/apps/web/app/lib/variants.ts b/apps/web/app/lib/variants.ts
new file mode 100644
index 0000000..baa4e79
--- /dev/null
+++ b/apps/web/app/lib/variants.ts
@@ -0,0 +1,116 @@
+import { fetchSpecs, type NostrEvent, type Spec, specPath, toNpub } from "@openspecs/nostr";
+import { useEffect, useState } from "react";
+import { eventPath } from "./paths";
+import { compareCopies, type Spot, type Standing } from "./spots";
+
+/** Another key's document under the same identifier, reduced to what its row draws. */
+export type Variant = {
+ pubkey: string;
+ npub: string;
+ title: string;
+ summary: string;
+ revisedAt: number;
+ path: string;
+};
+
+/**
+ * The other keys publishing under this identifier. The one on the page is
+ * dropped because its document is the page, and blank records are dropped the
+ * way listings drop them. Newest revision first, which is the order everything
+ * else on this site lists in: any other order would be a ranking, and ranking
+ * the versions of a name is the one call this site refuses to make.
+ */
+export const selectVariants = (specs: Spec[], shownPubkey: string): Variant[] =>
+ specs
+ .filter((spec) => spec.pubkey !== shownPubkey && !spec.isEmpty)
+ .sort((a, b) => b.createdAt - a.createdAt)
+ .map((spec) => ({
+ pubkey: spec.pubkey,
+ npub: toNpub(spec.pubkey),
+ title: spec.title,
+ summary: spec.summary,
+ revisedAt: spec.createdAt,
+ path: specPath(spec),
+ }));
+
+export type UnderThisName = {
+ variants: Variant[];
+ /** The places in the shown revision where those copies differ, for the margin. */
+ spots: Spot[];
+ /** Each copy's standing against the shown revision, for its card. */
+ standings: Record;
+};
+
+const NOTHING: UnderThisName = { variants: [], spots: [], standings: {} };
+
+/**
+ * The marks must sit on the text the reader is looking at, so the base source
+ * has to be the shown revision exactly. The relays usually hand it back in the
+ * same query that found the variants; when they hold another revision, the
+ * server's own copy of the event is the one the page was rendered from.
+ */
+const baseContentOf = async (
+ specs: Spec[],
+ shown: { pubkey: string; npub: string; identifier: string; eventId: string },
+): Promise => {
+ const own = specs.find((spec) => spec.pubkey === shown.pubkey);
+ if (own !== undefined && own.event.id === shown.eventId) return own.content;
+ try {
+ const response = await fetch(eventPath(shown.npub, shown.identifier));
+ if (!response.ok) return null;
+ const event = (await response.json()) as NostrEvent;
+ return event.id === shown.eventId ? event.content : null;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Asked by the browser after the page is on screen, like the discussion and for
+ * the same reason: the server renders the document for crawlers and cachers, and
+ * who else signs its name is not part of the document. When the indexer backend
+ * exists, this one query is what it replaces.
+ *
+ * The variants are published first and the margin marks after: the section is a
+ * list to draw, the marks cost a comparison per copy, and neither should wait
+ * on the other.
+ */
+export const useVariants = (shown: {
+ pubkey: string;
+ npub: string;
+ identifier: string;
+ eventId: string;
+ title: string;
+}): UnderThisName => {
+ const [found, setFound] = useState(NOTHING);
+ const { pubkey, npub, identifier, eventId, title } = shown;
+
+ useEffect(() => {
+ let live = true;
+ setFound(NOTHING);
+
+ (async () => {
+ try {
+ const specs = await fetchSpecs({ identifiers: [identifier] });
+ if (!live) return;
+ const variants = selectVariants(specs, pubkey);
+ setFound({ variants, spots: [], standings: {} });
+ if (variants.length === 0) return;
+
+ const content = await baseContentOf(specs, { pubkey, npub, identifier, eventId });
+ if (!live || content === null) return;
+ const others = specs.filter((spec) => spec.pubkey !== pubkey && !spec.isEmpty);
+ const { spots, standings } = compareCopies({ content, title, npub, identifier }, others);
+ if (live) setFound({ variants, spots, standings });
+ } catch {
+ // A page that could not learn who else signs this name still has its document.
+ }
+ })();
+
+ return () => {
+ live = false;
+ };
+ }, [pubkey, npub, identifier, eventId, title]);
+
+ return found;
+};
diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts
index 4138f8c..68905c9 100644
--- a/apps/web/app/routes.ts
+++ b/apps/web/app/routes.ts
@@ -11,6 +11,7 @@ export default [
route("atom.xml", "routes/atom.ts"),
route("spec/:author/:identifier", "routes/spec.tsx"),
route("spec/:author/:identifier/edit", "routes/spec-edit.tsx"),
+ route("spec/:author/:identifier/diff/:other", "routes/spec-diff.tsx"),
route("spec/:author/:identifier/event.json", "routes/event.ts"),
route("og/:author/:identifier", "routes/og.ts"),
route("og/:author", "routes/og-author.ts"),
diff --git a/apps/web/app/routes/spec-diff.tsx b/apps/web/app/routes/spec-diff.tsx
new file mode 100644
index 0000000..9774aad
--- /dev/null
+++ b/apps/web/app/routes/spec-diff.tsx
@@ -0,0 +1,169 @@
+import { parsePubkey, resolveNip05, specPath, toNpub } from "@openspecs/nostr";
+import { data, Link, redirect } from "react-router";
+import { ErrorPage } from "~/components/error-page";
+import { Shell } from "~/components/shell";
+import { keyTextColor } from "~/lib/color";
+import { KINSHIP_FLOOR, loadDiff } from "~/lib/diff.server";
+import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http";
+import { diffPath } from "~/lib/paths";
+import { authorName } from "~/lib/profile";
+import { loadAuthor } from "~/lib/profile.server";
+import { loadSpec } from "~/lib/specs.server";
+import type { Route } from "./+types/spec-diff";
+
+const resolve = async (author: string): Promise =>
+ parsePubkey(author) ?? (await resolveNip05(author))?.pubkey ?? null;
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const [pubkey, otherPubkey] = await Promise.all([resolve(params.author), resolve(params.other)]);
+ if (pubkey === null || otherPubkey === null) {
+ throw data({ missing: "address" }, { status: 404, headers: NOT_FOUND_HEADERS });
+ }
+
+ // A document diffed against itself is the document, which has its own page.
+ if (pubkey === otherPubkey) throw redirect(specPath({ pubkey, identifier: params.identifier }));
+
+ const path = diffPath(toNpub(pubkey), params.identifier, toNpub(otherPubkey));
+ if (params.author !== toNpub(pubkey) || params.other !== toNpub(otherPubkey)) {
+ throw redirect(path, 301);
+ }
+
+ const [base, other] = await Promise.all([
+ loadSpec(pubkey, params.identifier),
+ loadSpec(otherPubkey, params.identifier),
+ ]);
+ if (base === null || other === null) {
+ throw data({ missing: "document" }, { status: 404, headers: NOT_FOUND_HEADERS });
+ }
+
+ const [diff, baseAuthor, otherAuthor] = await Promise.all([
+ loadDiff(base, other),
+ loadAuthor(pubkey),
+ loadAuthor(otherPubkey),
+ ]);
+
+ return {
+ base: base.page,
+ other: other.page,
+ diff,
+ // Decided here rather than shipped as a threshold: the browser never
+ // imports the server module this constant lives in.
+ kin: diff.similarity >= KINSHIP_FLOOR,
+ baseName: authorName(baseAuthor, base.page.npub),
+ otherName: authorName(otherAuthor, other.page.npub),
+ };
+}
+
+export function headers({ errorHeaders }: Route.HeadersArgs) {
+ return errorHeaders ?? PAGE_HEADERS;
+}
+
+/**
+ * Never indexed: the two documents are the record, and this page is a reading
+ * of them that exists for whoever asked for it.
+ */
+export function meta({ loaderData }: Route.MetaArgs) {
+ if (!loaderData)
+ return [{ title: "Not found | Open Specs" }, { name: "robots", content: "noindex" }];
+ const { base, otherName } = loaderData;
+ return [
+ { title: `${base.identifier}: what ${otherName}'s copy changes | Open Specs` },
+ { name: "robots", content: "noindex" },
+ ];
+}
+
+const KeyName = ({
+ pubkey,
+ npub,
+ identifier,
+ name,
+}: {
+ pubkey: string;
+ npub: string;
+ identifier: string;
+ name: string;
+}) => (
+
+ {name}
+
+);
+
+export default function SpecDiff({ loaderData }: Route.ComponentProps) {
+ const { base, other, diff, kin, baseName, otherName } = loaderData;
+ const shared = Math.round(diff.similarity * 100);
+
+ const baseLink = (
+
+ );
+ const otherLink = (
+
+ );
+
+ return (
+
+
+
+
+ {base.kind}:{base.identifier}
+
+
+ What {otherName}'s copy changes
+
+
+ The document signed by {baseLink}, read whole, with what the copy signed by {otherLink}{" "}
+ changes marked in place.
+
+
+
+ {diff.changed && kin && (
+ <>
+ only in {otherName}'s
+ only in {baseName}'s
+ >
+ )}
+ sharing {shared}% of their words
+
+ Swap sides
+
+
+
+
+
+ {!diff.changed ? (
+
+ The two documents read the same. The signatures and the dates differ, and nothing in
+ the text does.
+
+ ) : kin ? (
+ // Sanitized by the same pipeline as every document page, block by block.
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: rendered Markdown
+
+ ) : (
+
+ These two share a name, not a text: they hold {shared}% of their words in common, and
+ marking the differences would mark nearly everything. Read them side by side instead:
+ the copy signed by {baseLink}, and the one signed by {otherLink}.
+
+ )}
+
+
+
+ );
+}
+
+export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+ return ;
+}
diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx
index 9f44f31..c2a0d78 100644
--- a/apps/web/app/routes/spec.tsx
+++ b/apps/web/app/routes/spec.tsx
@@ -8,6 +8,7 @@ import {
toNpub,
} from "@openspecs/nostr";
import { data, Link, redirect } from "react-router";
+import { AnnotatedDoc } from "~/components/annotated-doc";
import { AuthorAvatar } from "~/components/author-avatar";
import { CopyButton } from "~/components/copy-button";
import { DISCUSSION_ID, Discussion } from "~/components/discussion/discussion";
@@ -17,6 +18,7 @@ import { ErrorPage } from "~/components/error-page";
import { Rebroadcast } from "~/components/rebroadcast";
import { Shell } from "~/components/shell";
import { SpecTags } from "~/components/spec-tags";
+import { VARIANTS_ID, Variants } from "~/components/variants";
import { keyTextColor } from "~/lib/color";
import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http";
import { useLiveRevision } from "~/lib/live-revision";
@@ -29,6 +31,7 @@ import { loadAuthor } from "~/lib/profile.server";
import { discussionRelays, rebroadcastRelays } from "~/lib/relays.server";
import type { SpecPage } from "~/lib/spec-page";
import { loadSpec } from "~/lib/specs.server";
+import { useVariants } from "~/lib/variants";
import type { Route } from "./+types/spec";
export async function loader({ params, request }: Route.LoaderArgs) {
@@ -159,7 +162,13 @@ const Field = ({ label, children }: { label: string; children: React.ReactNode }
);
-const Contents = ({ headings }: { headings: MarkdownHeading[] }) => (
+const Contents = ({
+ headings,
+ variantCount,
+}: {
+ headings: MarkdownHeading[];
+ variantCount: number;
+}) => (