diff --git a/packages/web/package.json b/packages/web/package.json
index dc88a13..d53024c 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -55,6 +55,7 @@
"nitro": "3.0.260610-beta",
"relay-runtime": "^21.0.1",
"seroval": "1.5.4",
+ "shiki": "4.4.1",
"solid-js": "^1.9.5",
"solid-relay": "1.0.0-beta.29",
"valibot": "1.4.2",
diff --git a/packages/web/src/components/ActivityLogBox.tsx b/packages/web/src/components/ActivityLogBox.tsx
new file mode 100644
index 0000000..2e36e96
--- /dev/null
+++ b/packages/web/src/components/ActivityLogBox.tsx
@@ -0,0 +1,141 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { graphql } from "relay-runtime";
+import { For, Show, createMemo, createSignal } from "solid-js";
+import { createFragment } from "solid-relay";
+
+import type { ActivityLogBox_log$key } from "./__generated__/ActivityLogBox_log.graphql.ts";
+import { ActivityLogSource } from "./ActivityLogSource.tsx";
+import { CopyButton } from "./CopyButton.tsx";
+
+import styles from "~/styles/activity-log.module.css";
+
+/**
+ * Displays delivery endpoints and expands to show the recorded JSON-LD.
+ * @returns A keyboard-accessible activity disclosure.
+ */
+export function ActivityLogBox(props: { $log: ActivityLogBox_log$key }) {
+ const data = createFragment(
+ graphql`
+ fragment ActivityLogBox_log on ActivityDelivery @throwOnFieldError {
+ direction
+ type
+ objectType
+ objectIri
+ actor {
+ iri
+ }
+ remoteActorIri
+ recipientIris
+ inboxUrl
+ status
+ created
+ payload
+ rawBody
+ }
+ `,
+ () => props.$log,
+ );
+ const [open, setOpen] = createSignal(false);
+ const source = createMemo(() => {
+ if (!open()) return "";
+ const log = data();
+ const payload: unknown = log?.payload;
+ return payload == undefined
+ ? (log?.rawBody ?? undefined)
+ : JSON.stringify(payload, undefined, 2);
+ });
+ const from = createMemo(() => {
+ const log = data();
+ if (log?.direction === "inbound") return log.remoteActorIri;
+ // Keep the sender visible even if its local actor has since been deleted.
+ const payload: unknown = log?.payload;
+ const actor: unknown =
+ payload != undefined && typeof payload === "object" && "actor" in payload
+ ? payload.actor
+ : undefined;
+ return log?.actor?.iri ?? (typeof actor === "string" ? actor : undefined);
+ });
+ const to = createMemo(() => {
+ const log = data();
+ if (!log) return [];
+ if (log.direction === "inbound") return [log.actor?.iri ?? log.inboxUrl];
+ return log.recipientIris.length > 0 ? log.recipientIris : [log.inboxUrl];
+ });
+
+ return (
+
+ {(log) => (
+ setOpen(event.currentTarget.open)}
+ class={styles.box}
+ >
+
+
+ {log().type ?? "Unknown activity"}
+ {log().objectType != undefined && log().objectType !== ""
+ ? `(${log().objectType})`
+ : ""}
+
+ · {log().status}
+
+ {(iri) => Object: {iri()}}
+
+
+ From: {from() ?? "Unknown sender"}
+
+
+ To:{" "}
+
+ {(iri, index) => (
+ <>
+ {index() > 0 ? ", " : ""}
+ {iri}
+ >
+ )}
+
+
+
+
+ {open() ? "Hide source" : "View source"}
+
+
+
+
}
+ >
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/web/src/components/ActivityLogSource.tsx b/packages/web/src/components/ActivityLogSource.tsx
new file mode 100644
index 0000000..007561e
--- /dev/null
+++ b/packages/web/src/components/ActivityLogSource.tsx
@@ -0,0 +1,51 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { createHighlighterCoreSync } from "shiki/core";
+import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
+import json from "shiki/langs/json.mjs";
+import githubDark from "shiki/themes/github-dark.mjs";
+import githubLight from "shiki/themes/github-light.mjs";
+import { createMemo } from "solid-js";
+
+import styles from "~/styles/activity-log.module.css";
+
+let highlighter: ReturnType | undefined;
+
+/**
+ * Render recorded source with JSON colours, or plain text for raw bodies.
+ * @returns Highlighted, escaped source markup.
+ */
+export function ActivityLogSource(props: { source: string; json: boolean }) {
+ // oxlint-disable-next-line node/no-sync
+ highlighter ??= createHighlighterCoreSync({
+ langs: [json],
+ themes: [githubLight, githubDark],
+ engine: createJavaScriptRegexEngine(),
+ });
+ const renderer = highlighter;
+ const html = createMemo(() =>
+ renderer.codeToHtml(props.source, {
+ lang: props.json ? "json" : "text",
+ themes: { light: "github-light", dark: "github-dark" },
+ defaultColor: false,
+ }),
+ );
+
+ // Shiki escapes source text before producing the highlighted markup.
+ // oxlint-disable-next-line solid/no-innerhtml
+ return ;
+}
diff --git a/packages/web/src/components/ActivityLogTimeline.tsx b/packages/web/src/components/ActivityLogTimeline.tsx
new file mode 100644
index 0000000..5aa890f
--- /dev/null
+++ b/packages/web/src/components/ActivityLogTimeline.tsx
@@ -0,0 +1,157 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { graphql } from "relay-runtime";
+import {
+ For,
+ Show,
+ createEffect,
+ createMemo,
+ createSignal,
+ onCleanup,
+} from "solid-js";
+import { createPaginationFragment } from "solid-relay";
+
+import type { ActivityLogTimeline_instance$key } from "./__generated__/ActivityLogTimeline_instance.graphql.ts";
+import { ActivityLogBox } from "./ActivityLogBox.tsx";
+
+import styles from "~/styles/activity-log.module.css";
+
+/**
+ * A chronological delivery list that loads older entries when scrolled upward.
+ * @returns The shared scrollable activity timeline.
+ */
+export function ActivityLogTimeline(props: {
+ $instance: ActivityLogTimeline_instance$key;
+}) {
+ const data = createPaginationFragment(
+ graphql`
+ fragment ActivityLogTimeline_instance on Instance
+ @throwOnFieldError
+ @argumentDefinitions(
+ count: { type: "Int", defaultValue: 20 }
+ cursor: { type: "String" }
+ )
+ @refetchable(queryName: "ActivityLogTimelinePaginationQuery") {
+ activityDeliveries(first: $count, after: $cursor)
+ @connection(key: "ActivityLogTimeline_activityDeliveries") {
+ edges {
+ node {
+ id
+ direction
+ ...ActivityLogBox_log
+ }
+ }
+ }
+ }
+ `,
+ () => props.$instance,
+ );
+ const entries = createMemo(() =>
+ (data()?.activityDeliveries.edges ?? []).toReversed(),
+ );
+ const [failed, setFailed] = createSignal(false);
+ let viewport: HTMLElement | undefined;
+ let initialized = false;
+ let anchor: { height: number } | undefined;
+ let frame: number | undefined;
+
+ // Older pages are prepended visually; compensate for their height so the
+ // current reading position stays in place, including scrolling during the request.
+ createEffect(() => {
+ entries();
+ if (frame !== undefined) cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => {
+ if (!viewport) return;
+ if (!initialized) {
+ viewport.scrollTop = viewport.scrollHeight;
+ initialized = true;
+ } else if (anchor) {
+ viewport.scrollTop += viewport.scrollHeight - anchor.height;
+ anchor = undefined;
+ }
+ });
+ });
+ onCleanup(() => {
+ if (frame !== undefined) cancelAnimationFrame(frame);
+ });
+
+ function loadOlder() {
+ if (!viewport || !data.hasNext || data.isLoadingNext) return;
+ anchor = { height: viewport.scrollHeight };
+ setFailed(false);
+ data.loadNext(20, {
+ onComplete(error) {
+ if (error) {
+ anchor = undefined;
+ setFailed(true);
+ }
+ },
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/InstanceActivityLog.tsx b/packages/web/src/components/InstanceActivityLog.tsx
new file mode 100644
index 0000000..e7759b0
--- /dev/null
+++ b/packages/web/src/components/InstanceActivityLog.tsx
@@ -0,0 +1,44 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { ErrorBoundary } from "solid-js";
+
+import type { ActivityLogTimeline_instance$key } from "./__generated__/ActivityLogTimeline_instance.graphql.ts";
+import { ActivityLogTimeline } from "./ActivityLogTimeline.tsx";
+
+/**
+ * Displays the instance's incoming and outgoing delivery logs.
+ * @returns The activity log section with a shared chronological timeline.
+ */
+export function InstanceActivityLog(props: {
+ $instance: ActivityLogTimeline_instance$key;
+}) {
+ return (
+
+
Activity log
+
+ Activity logs could not be loaded. Access requires instance
+ membership or administrator permissions.
+
+ }
+ >
+
+
+
+ );
+}
diff --git a/packages/web/src/routes/instance/[slug].tsx b/packages/web/src/routes/instance/[slug].tsx
index 9370cee..099cabe 100644
--- a/packages/web/src/routes/instance/[slug].tsx
+++ b/packages/web/src/routes/instance/[slug].tsx
@@ -31,6 +31,7 @@ import {
import { ActorCard } from "~/components/ActorCard.tsx";
import { CopyButton } from "~/components/CopyButton.tsx";
+import { InstanceActivityLog } from "~/components/InstanceActivityLog.tsx";
import type { InstanceDetailQuery } from "./__generated__/InstanceDetailQuery.graphql.ts";
@@ -41,6 +42,7 @@ const instanceDetailQuery = graphql`
query InstanceDetailQuery($slug: String!) {
localInstanceBySlug(slug: $slug) {
instance {
+ ...ActivityLogTimeline_instance
id
host
url
@@ -181,6 +183,7 @@ export default function InstanceDetailPage(
+
)}
diff --git a/packages/web/src/styles/activity-log.module.css b/packages/web/src/styles/activity-log.module.css
new file mode 100644
index 0000000..2bb5f35
--- /dev/null
+++ b/packages/web/src/styles/activity-log.module.css
@@ -0,0 +1,90 @@
+/*
+DrFed: A web-based platform for developing and debugging ActivityPub apps
+Copyright (C) 2026 DrFed team
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+.timeline {
+ position: relative;
+ height: 40rem;
+ overflow: auto;
+ overflow-anchor: none;
+ overflow-wrap: anywhere;
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: grid;
+ gap: 0.75rem;
+}
+
+.row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 1rem;
+}
+
+.entry {
+ min-width: 0;
+}
+
+.incoming {
+ grid-column: 1;
+}
+
+.outgoing {
+ grid-column: 2;
+}
+
+.box {
+ border: 1px solid var(--line);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+}
+
+.summary {
+ cursor: pointer;
+ overflow-wrap: anywhere;
+}
+
+.detailLine {
+ display: block;
+ margin-top: 0.5rem;
+}
+
+.recipientLine {
+ display: block;
+}
+
+.source pre {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.source pre,
+.source span {
+ color: var(--shiki-light);
+ background-color: var(--shiki-light-bg);
+}
+
+@media (prefers-color-scheme: dark) {
+ .source pre,
+ .source span {
+ color: var(--shiki-dark);
+ background-color: var(--shiki-dark-bg);
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f310962..17278d5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -311,6 +311,9 @@ importers:
seroval:
specifier: 1.5.4
version: 1.5.4
+ shiki:
+ specifier: 4.4.1
+ version: 4.4.1
solid-js:
specifier: ^1.9.5
version: 1.9.14