diff --git a/packages/web/relay.config.json b/packages/web/relay.config.json
index 89f9e7e..1f531fa 100644
--- a/packages/web/relay.config.json
+++ b/packages/web/relay.config.json
@@ -2,5 +2,10 @@
"src": "./src",
"schema": "./schema.graphql",
"language": "typescript",
+ "customScalarTypes": {
+ "DateTime": "string",
+ "URL": "string",
+ "UUID": "string"
+ },
"eagerEsModules": true
}
diff --git a/packages/web/src/components/ActorCard.tsx b/packages/web/src/components/ActorCard.tsx
new file mode 100644
index 0000000..c7913a0
--- /dev/null
+++ b/packages/web/src/components/ActorCard.tsx
@@ -0,0 +1,49 @@
+// 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 { A } from "@solidjs/router";
+import { graphql } from "relay-runtime";
+import { Show } from "solid-js";
+import { createFragment } from "solid-relay";
+
+import type { ActorCard_actor$key } from "./__generated__/ActorCard_actor.graphql.ts";
+
+import styles from "~/styles/instance.module.css";
+
+export const ActorCard = (props: { $actor: ActorCard_actor$key }) => {
+ const actorData = createFragment(
+ graphql`
+ fragment ActorCard_actor on Actor {
+ handle
+ id
+ }
+ `,
+ () => props.$actor,
+ );
+
+ return (
+
+ {(actor) => (
+
+
+
+ {actor().handle}
+
+
+ )}
+
+ );
+};
diff --git a/packages/web/src/components/ActorDetail.tsx b/packages/web/src/components/ActorDetail.tsx
index af8be37..52a5ee1 100644
--- a/packages/web/src/components/ActorDetail.tsx
+++ b/packages/web/src/components/ActorDetail.tsx
@@ -14,32 +14,144 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+import { Title } from "@solidjs/meta";
import { graphql } from "relay-runtime";
-import { Show } from "solid-js";
+import { For, Show } from "solid-js";
import { createFragment } from "solid-relay";
import type { ActorDetail_actor$key } from "./__generated__/ActorDetail_actor.graphql.ts";
+import { CopyButton } from "./CopyButton.tsx";
-import styles from "~/styles/instance.module.css";
+import styles from "~/styles/actor.module.css";
-export const ActorDetail = (props: { $actor: ActorDetail_actor$key }) => {
- const actorData = createFragment(
+/**
+ * Read-only identity and federation information for an actor.
+ * @returns The actor identity and endpoint sections.
+ */
+export function ActorDetail(props: { $actor: ActorDetail_actor$key }) {
+ const data = createFragment(
graphql`
- fragment ActorDetail_actor on Actor {
+ fragment ActorDetail_actor on Actor @throwOnFieldError {
handle
+ username
+ uuid
+ type
+ created
+ iri
+ avatarUrl
+ inboxUrl
+ outbox {
+ iri
+ }
+ followers {
+ iri
+ }
+ following {
+ iri
+ }
+ featured {
+ iri
+ }
+ profileUrl
+ instance {
+ host
+ }
}
`,
() => props.$actor,
);
-
return (
-
- {(actor) => (
-
-
-
+
+ >
+ );
+ }}
);
-};
+}
+
+// Stored remote URLs are data, not trusted navigation targets.
+function httpUrl(value: string): string | undefined {
+ try {
+ const url = new URL(value);
+ return url.protocol === "https:" || url.protocol === "http:"
+ ? url.href
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/packages/web/src/components/CopyButton.tsx b/packages/web/src/components/CopyButton.tsx
new file mode 100644
index 0000000..19210a2
--- /dev/null
+++ b/packages/web/src/components/CopyButton.tsx
@@ -0,0 +1,49 @@
+// 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 { createSignal } from "solid-js";
+
+import styles from "~/styles/buttons.module.css";
+
+/**
+ * Copy a value to the clipboard and announce the result.
+ * @returns A copy button with accessible feedback.
+ */
+export function CopyButton(props: { value: string; label: string }) {
+ const [message, setMessage] = createSignal("");
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(props.value);
+ setMessage("Copied.");
+ } catch {
+ setMessage("Could not copy. Select and copy the text manually.");
+ }
+ }
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/web/src/routes/actor/[id].tsx b/packages/web/src/routes/actor/[id].tsx
new file mode 100644
index 0000000..3c1949a
--- /dev/null
+++ b/packages/web/src/routes/actor/[id].tsx
@@ -0,0 +1,105 @@
+// 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 { Title } from "@solidjs/meta";
+import {
+ A,
+ type RouteDefinition,
+ type RouteSectionProps,
+ query,
+} from "@solidjs/router";
+import { graphql } from "relay-runtime";
+import { ErrorBoundary, Show, Suspense } from "solid-js";
+import {
+ createPreloadedQuery,
+ loadQuery,
+ useRelayEnvironment,
+} from "solid-relay";
+
+import { ActorDetail } from "~/components/ActorDetail.tsx";
+
+import type { ActorDetailQuery } from "./__generated__/ActorDetailQuery.graphql.ts";
+
+import styles from "~/styles/actor.module.css";
+
+const actorDetailQuery = graphql`
+ query ActorDetailQuery($id: ID!) @throwOnFieldError {
+ node(id: $id) {
+ ... on Actor @alias(as: "actor") {
+ ...ActorDetail_actor
+ }
+ }
+ }
+`;
+
+const loadActorDetailQuery = query(
+ (id: string) =>
+ loadQuery(useRelayEnvironment()(), actorDetailQuery, {
+ id,
+ }),
+ "ActorDetailQuery",
+);
+
+export const route = {
+ preload({ params }) {
+ if (params.id === undefined) {
+ throw new Error("Missing actor ID route parameter.");
+ }
+ return loadActorDetailQuery(params.id);
+ },
+} satisfies RouteDefinition;
+
+type RouteData = ReturnType;
+
+export default function ActorDetailPage(props: RouteSectionProps) {
+ return (
+
+ Actor — DrFed
+
+ ← All instances
+
+
+ Unable to load this actor. Please reload the page to try again.
+
diff --git a/packages/web/src/styles/actor.module.css b/packages/web/src/styles/actor.module.css
new file mode 100644
index 0000000..77a87e5
--- /dev/null
+++ b/packages/web/src/styles/actor.module.css
@@ -0,0 +1,117 @@
+/*
+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 .
+*/
+
+.page {
+ display: grid;
+ gap: 1.5rem;
+ padding-bottom: 3rem;
+}
+.backLink {
+ color: var(--ink-soft);
+ justify-self: start;
+ font-weight: 700;
+ text-decoration: none;
+}
+.header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ border-bottom: 1px solid var(--line);
+ padding-bottom: 1.25rem;
+}
+.header > div {
+ min-width: 0;
+}
+.header h1 {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: clamp(1.25rem, 4vw, 2rem);
+ overflow-wrap: anywhere;
+}
+.avatar {
+ border-radius: var(--radius-sm);
+ object-fit: cover;
+ flex-shrink: 0;
+}
+.label {
+ color: var(--accent-strong);
+ font-weight: 700;
+ margin: 0 0 0.5rem;
+}
+.panel {
+ background: var(--card);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: clamp(1rem, 3vw, 1.75rem);
+}
+.panel h2 {
+ margin: 0;
+ font-size: 1.3rem;
+}
+.fields {
+ margin: 1.25rem 0 0;
+}
+.fields > div {
+ display: grid;
+ grid-template-columns: 8rem minmax(0, 1fr);
+ gap: 1rem;
+ padding: 1rem 0;
+ border-top: 1px solid var(--line);
+}
+.fields dt {
+ color: var(--ink-soft);
+ font-weight: 700;
+}
+.fields dd {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ overflow-wrap: anywhere;
+}
+.fields a {
+ color: var(--ink);
+ text-underline-offset: 0.2rem;
+}
+.endpoint {
+ display: flex;
+ align-items: baseline;
+ gap: 0.75rem;
+}
+.endpoint > a {
+ flex: 1;
+ min-width: 0;
+}
+.endpoint > :last-child {
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ max-width: 40%;
+ margin-top: 0;
+}
+.page a:focus-visible {
+ outline: 3px solid var(--accent-soft);
+ outline-offset: 0.2rem;
+}
+@media (max-width: 520px) {
+ .fields > div {
+ grid-template-columns: 1fr;
+ gap: 0.5rem;
+ }
+ .header {
+ align-items: start;
+ }
+}
diff --git a/packages/web/src/styles/buttons.module.css b/packages/web/src/styles/buttons.module.css
new file mode 100644
index 0000000..d82ea84
--- /dev/null
+++ b/packages/web/src/styles/buttons.module.css
@@ -0,0 +1,37 @@
+/*
+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 .
+*/
+
+.copyControl {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+ font-size: 0.8rem;
+}
+.copyControl button {
+ background: var(--card);
+ color: var(--ink);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 0.35rem 0.65rem;
+ cursor: pointer;
+}
+.copyControl button:focus-visible {
+ outline: 3px solid var(--accent-soft);
+ outline-offset: 0.2rem;
+}
diff --git a/packages/web/src/styles/instance.module.css b/packages/web/src/styles/instance.module.css
index ba80e9a..3372baf 100644
--- a/packages/web/src/styles/instance.module.css
+++ b/packages/web/src/styles/instance.module.css
@@ -171,7 +171,7 @@ along with this program. If not, see .
width: 0.85rem;
}
-.actorCard p {
+.actorCard a {
font-family: var(--font-mono);
font-size: 0.9rem;
margin: 0;
@@ -180,7 +180,8 @@ along with this program. If not, see .
.backLink:focus-visible,
.createButton:focus-visible,
-.endpointList a:focus-visible {
+.endpointList a:focus-visible,
+.actorCard a:focus-visible {
outline: 3px solid var(--accent-soft);
outline-offset: 0.2rem;
}