diff --git a/ui/app/[dataspace]/[kind]/[slug]/page.tsx b/ui/app/[dataspace]/[kind]/[slug]/page.tsx
new file mode 100644
index 0000000..3ce0bbb
--- /dev/null
+++ b/ui/app/[dataspace]/[kind]/[slug]/page.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { EntityDetail } from "@/components/EntityDetail";
+import { findEntity } from "@/lib/catalog";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ dataspace: string; kind: string; slug: string }>;
+}): Promise {
+ const { dataspace, kind, slug } = await params;
+ const view = findEntity(dataspace, kind, slug);
+ if (!view) return { title: "Content Explorer" };
+ return { title: `${view.title} · ${view.kindLabel} · Content Explorer`, description: view.tldr };
+}
+
+export default async function EntityPage({
+ params,
+}: {
+ params: Promise<{ dataspace: string; kind: string; slug: string }>;
+}) {
+ const { dataspace, kind, slug } = await params;
+ const view = findEntity(dataspace, kind, slug);
+ if (!view) notFound();
+ return ;
+}
diff --git a/ui/app/[dataspace]/[kind]/page.tsx b/ui/app/[dataspace]/[kind]/page.tsx
new file mode 100644
index 0000000..2f96302
--- /dev/null
+++ b/ui/app/[dataspace]/[kind]/page.tsx
@@ -0,0 +1,18 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { KindIndex } from "@/components/KindIndex";
+import { findKind } from "@/lib/catalog";
+
+export async function generateMetadata({ params }: { params: Promise<{ dataspace: string; kind: string }> }): Promise {
+ const { dataspace, kind } = await params;
+ const view = findKind(dataspace, kind);
+ if (!view) return { title: "Content Explorer" };
+ return { title: `${view.plural} · ${view.dataspaceTitle} · Content Explorer` };
+}
+
+export default async function KindPage({ params }: { params: Promise<{ dataspace: string; kind: string }> }) {
+ const { dataspace, kind } = await params;
+ const view = findKind(dataspace, kind);
+ if (!view) notFound();
+ return ;
+}
diff --git a/ui/app/[dataspace]/page.tsx b/ui/app/[dataspace]/page.tsx
new file mode 100644
index 0000000..b54edaf
--- /dev/null
+++ b/ui/app/[dataspace]/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+import { DataspaceHome } from "@/components/DataspaceHome";
+import { findDataspace } from "@/lib/catalog";
+
+export async function generateMetadata({ params }: { params: Promise<{ dataspace: string }> }): Promise {
+ const { dataspace } = await params;
+ const view = findDataspace(dataspace);
+ if (!view) return { title: "Content Explorer" };
+ return { title: `${view.title} · Content Explorer`, description: view.description };
+}
+
+export default async function DataspacePage({ params }: { params: Promise<{ dataspace: string }> }) {
+ const { dataspace } = await params;
+ return ;
+}
diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx
index 295a34b..bf4dac9 100644
--- a/ui/app/layout.tsx
+++ b/ui/app/layout.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { SkipLink } from "@/components/SkipLink";
+import { listNavigation } from "@/lib/catalog";
import "./globals.css";
export const metadata: Metadata = {
@@ -15,7 +16,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
-
+
{children}
diff --git a/ui/app/page.tsx b/ui/app/page.tsx
index 7920714..4dc24f3 100644
--- a/ui/app/page.tsx
+++ b/ui/app/page.tsx
@@ -1,13 +1,5 @@
-import { Hero } from "@/components/Hero";
-import { RecordBrowser } from "@/components/RecordBrowser";
-import { loadSystemsThinking } from "@/lib/fixture";
+import { DataspaceHome } from "@/components/DataspaceHome";
export default function HomePage() {
- const fixture = loadSystemsThinking();
- return (
- <>
-
-
- >
- );
+ return ;
}
diff --git a/ui/components/DataspaceHome.tsx b/ui/components/DataspaceHome.tsx
new file mode 100644
index 0000000..98a0d80
--- /dev/null
+++ b/ui/components/DataspaceHome.tsx
@@ -0,0 +1,31 @@
+import { Hero } from "@/components/Hero";
+import { RecordBrowser } from "@/components/RecordBrowser";
+import { findDataspace, listNavigation } from "@/lib/catalog";
+import { notFound } from "next/navigation";
+
+export function DataspaceHome({ id }: { id: string }) {
+ const dataspace = findDataspace(id);
+ if (!dataspace) notFound();
+ const others = listNavigation().filter((item) => item.id !== dataspace.id);
+ return (
+ <>
+ ({ href: kind.href, label: kind.plural }))}
+ />
+ {others.length > 0 ? (
+
+ Other fields:{" "}
+ {others.map((item, index) => (
+
+ {index > 0 ? ", " : null}
+ {item.title}
+
+ ))}
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/ui/components/EntityDetail.test.tsx b/ui/components/EntityDetail.test.tsx
new file mode 100644
index 0000000..7f69efb
--- /dev/null
+++ b/ui/components/EntityDetail.test.tsx
@@ -0,0 +1,26 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { findEntity, findKind } from "@/lib/catalog";
+import { EntityDetail } from "./EntityDetail";
+import { KindIndex } from "./KindIndex";
+
+describe("kind and detail views", () => {
+ it("renders the registry plural and a linked record", () => {
+ const kind = findKind("software-architecture", "concept");
+ expect(kind).not.toBeNull();
+ const html = renderToStaticMarkup();
+ expect(html).toContain(">Concepts<");
+ expect(html).toContain("Coupling");
+ expect(html).toContain('href="/software-architecture/concept/coupling/"');
+ });
+
+ it("renders the entity title, an unknown claim, and a neighbour", () => {
+ const entity = findEntity("systems-thinking", "concept", "system-boundary");
+ expect(entity).not.toBeNull();
+ const html = renderToStaticMarkup();
+ expect(html).toContain(">System boundary<");
+ expect(html).toContain("formal-measure");
+ expect(html).toContain("unknown");
+ expect(html).toContain("Stock and flow model");
+ });
+});
diff --git a/ui/components/EntityDetail.tsx b/ui/components/EntityDetail.tsx
new file mode 100644
index 0000000..3d9b4f9
--- /dev/null
+++ b/ui/components/EntityDetail.tsx
@@ -0,0 +1,78 @@
+import { PageHeader } from "@/components/PageHeader";
+import type { EntityView } from "@/lib/catalog";
+
+/**
+ * Generic record page: title, summary, sources, claims, and neighbouring relations.
+ * OnCo EntityDetail was not copied. That file special-cases domain panels.
+ */
+export function EntityDetail({ entity }: { entity: EntityView }) {
+ return (
+ <>
+
+
+
{entity.summary}
+ {entity.tags.length > 0 ? (
+
+ {entity.tags.map((tag) => (
+ - {tag}
+ ))}
+
+ ) : null}
+
+ Sources
+ {entity.sources.length === 0 ? (
+ No sources.
+ ) : (
+
+ {entity.sources.map((source) => (
+ -
+ {source.title}
+ {source.license ? · {source.license} : null}
+
+ ))}
+
+ )}
+
+
+ Claims
+ {entity.claims.length === 0 ? (
+ No claims.
+ ) : (
+
+ {entity.claims.map((claim) => (
+ -
+ {claim.key}
+ · {claim.status}
+ {claim.value ? · {claim.value} : null}
+
+ ))}
+
+ )}
+
+
+ Relations
+ {entity.relations.length === 0 ? (
+ No relations.
+ ) : (
+
+ {entity.relations.map((relation) => (
+ -
+ {relation.wording}
+ {" · "}
+ {relation.otherHref ? (
+ {relation.otherTitle}
+ ) : (
+ relation.otherTitle
+ )}
+
+ ))}
+
+ )}
+
+
+ Back to {entity.kindLabel}
+
+
+ >
+ );
+}
diff --git a/ui/components/Hero.tsx b/ui/components/Hero.tsx
index 6a111b9..2862a24 100644
--- a/ui/components/Hero.tsx
+++ b/ui/components/Hero.tsx
@@ -3,7 +3,15 @@
* Copyright (c) 2026 OnCo for the adapted pattern. MIT License. See ui/NOTICE.
* The heading and lede are Content Explorer copy.
*/
-export function Hero({ dataspace, description }: { dataspace: string; description: string }) {
+export function Hero({
+ dataspace,
+ description,
+ kinds = [],
+}: {
+ dataspace: string;
+ description: string;
+ kinds?: { href: string; label: string }[];
+}) {
return (
@@ -11,10 +19,15 @@ export function Hero({ dataspace, description }: { dataspace: string; descriptio
{dataspace}
Explore a field of knowledge.
{description}
-
diff --git a/ui/components/KindIndex.tsx b/ui/components/KindIndex.tsx
new file mode 100644
index 0000000..5211988
--- /dev/null
+++ b/ui/components/KindIndex.tsx
@@ -0,0 +1,15 @@
+import { RecordBrowser } from "@/components/RecordBrowser";
+import { PageHeader } from "@/components/PageHeader";
+import type { KindView } from "@/lib/catalog";
+
+/** Kind directory. The heading and rows come from the snapshot kind registry. */
+export function KindIndex({ kind }: { kind: KindView }) {
+ const count = kind.rows.length;
+ const lede = count === 1 ? `1 record in ${kind.dataspaceTitle}.` : `${count} records in ${kind.dataspaceTitle}.`;
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/ui/components/PageHeader.tsx b/ui/components/PageHeader.tsx
new file mode 100644
index 0000000..1abd540
--- /dev/null
+++ b/ui/components/PageHeader.tsx
@@ -0,0 +1,16 @@
+/**
+ * Page heading adapted from OnCo PageHeader: kicker, title, lede.
+ * Copyright (c) 2026 OnCo for the adapted pattern. MIT License. See ui/NOTICE.
+ * Garden art and domain logos are not included.
+ */
+export function PageHeader({ kicker, title, lede }: { kicker?: string; title: string; lede?: string }) {
+ return (
+
+
+ {kicker ?
{kicker}
: null}
+
{title}
+ {lede ?
{lede}
: null}
+
+
+ );
+}
diff --git a/ui/components/RecordBrowser.tsx b/ui/components/RecordBrowser.tsx
index fa0b419..323acfc 100644
--- a/ui/components/RecordBrowser.tsx
+++ b/ui/components/RecordBrowser.tsx
@@ -6,17 +6,29 @@ import { nextSort, sortRows } from "@/lib/sort-rows";
import type { RecordRow, SortState } from "@/lib/types";
const columns = [
- { key: "title", label: "Title", sortable: true, render: (row: RecordRow) => row.title },
- { key: "kindLabel", label: "Kind", sortable: true, render: (row: RecordRow) => {row.kindLabel} },
+ {
+ key: "title",
+ label: "Title",
+ sortable: true,
+ render: (row: RecordRow) => row.href ? {row.title} : row.title,
+ },
+ {
+ key: "kindLabel",
+ label: "Kind",
+ sortable: true,
+ render: (row: RecordRow) => row.kindHref
+ ? {row.kindLabel}
+ : {row.kindLabel},
+ },
{ key: "tldr", label: "Summary", sortable: true, render: (row: RecordRow) => row.tldr },
];
-export function RecordBrowser({ rows }: { rows: RecordRow[] }) {
+export function RecordBrowser({ rows, heading = "Records" }: { rows: RecordRow[]; heading?: string }) {
const [sort, setSort] = useState({ key: "title", dir: 1 });
const ordered = sortRows(rows, sort, (row, key) => String(row[key as keyof RecordRow] ?? ""));
return (
- Records
+ {heading}
Content Explorer browses an interconnected field of knowledge. Interface patterns on this page are adapted
from OnCo under the MIT License, commit b594d7433bf4e408b44215e25e5304696d6f840e. The OnCo corpus is not
- included. The sample records are the CC0 systems-thinking fixture.
+ included. The sample records are the CC0 systems-thinking and software-architecture fixtures.
diff --git a/ui/components/SiteHeader.tsx b/ui/components/SiteHeader.tsx
index 57bb412..8222338 100644
--- a/ui/components/SiteHeader.tsx
+++ b/ui/components/SiteHeader.tsx
@@ -1,21 +1,33 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import type { DataspaceNav } from "@/lib/catalog";
+
/**
* Sticky header shell adapted from OnCo SiteHeader.
* Copyright (c) 2026 OnCo. MIT License. See ui/NOTICE.
- * The OnCo mark, command palette, and kind routes are not included.
- * Kind routes arrive in CONTENT-WP-0002-T02; this header only links to the home page.
+ * The OnCo mark and command palette are not included.
+ * Kind links come from the snapshot registry passed in by the layout.
*/
-export function SiteHeader() {
+export function SiteHeader({ dataspaces }: { dataspaces: DataspaceNav[] }) {
+ const path = usePathname() ?? "/";
+ const active = dataspaces.find((item) => path === `/${item.id}` || path.startsWith(`/${item.id}/`)) ?? dataspaces.find((item) => item.id === "systems-thinking") ?? dataspaces[0];
return (
-
-
-
+
);
diff --git a/ui/lib/catalog.test.ts b/ui/lib/catalog.test.ts
new file mode 100644
index 0000000..f561bf9
--- /dev/null
+++ b/ui/lib/catalog.test.ts
@@ -0,0 +1,34 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { entitySlug, findDataspace, findEntity, findKind } from "./catalog";
+
+const root = join(process.cwd(), "../examples/dataspaces");
+
+function titles(dataspaceId: string, kindId: string): string[] {
+ const snapshot = JSON.parse(readFileSync(join(root, dataspaceId, "snapshot.json"), "utf8")) as {
+ entities: { kindId: string; title: string }[];
+ };
+ return snapshot.entities.filter((entity) => entity.kindId === kindId).map((entity) => entity.title).sort();
+}
+
+describe("catalog", () => {
+ it("takes the kind index from the snapshot registry", () => {
+ const concept = findKind("systems-thinking", "concept");
+ const pattern = findKind("software-architecture", "pattern");
+ expect(concept?.plural).toBe("Concepts");
+ expect(concept?.rows.map((row) => row.title).sort()).toEqual(titles("systems-thinking", "concept"));
+ expect(pattern?.rows.map((row) => row.title).sort()).toEqual(titles("software-architecture", "pattern"));
+ expect(concept?.rows.every((row) => row.href?.startsWith("/systems-thinking/concept/"))).toBe(true);
+ });
+
+ it("resolves one entity and hides unknown routes", () => {
+ expect(entitySlug("urn:ce:systems-thinking:concept:system-boundary", "concept")).toBe("system-boundary");
+ const feedback = findEntity("systems-thinking", "concept", "feedback");
+ expect(feedback?.title).toBe("Feedback");
+ expect(feedback?.relations.some((relation) => relation.otherTitle === "Causal loop diagram")).toBe(true);
+ expect(findEntity("systems-thinking", "concept", "missing")).toBeNull();
+ expect(findKind("systems-thinking", "no-such-kind")).toBeNull();
+ expect(findDataspace("software-architecture")?.title).toBe("Software Architecture");
+ });
+});
diff --git a/ui/lib/catalog.ts b/ui/lib/catalog.ts
new file mode 100644
index 0000000..2026bdd
--- /dev/null
+++ b/ui/lib/catalog.ts
@@ -0,0 +1,263 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import type { RecordRow } from "./types";
+
+type KindRecord = { id: string; label: string; plural: string };
+
+type EntityRecord = {
+ id: string;
+ kindId: string;
+ title: string;
+ tldr: string;
+ summary: string;
+ tags?: string[];
+ sourceRefs?: string[];
+ claimRefs?: string[];
+};
+
+type SourceRecord = { id: string; title: string; license: string };
+
+type ClaimRecord = { id: string; subjectId: string; key: string; value?: string; status: string };
+
+type RelationRecord = { id: string; predicate: string; from: string; to: string };
+
+type SnapshotFile = {
+ dataspace: { id: string; title: string; description: string };
+ kinds: KindRecord[];
+ sources?: SourceRecord[];
+ entities: EntityRecord[];
+ relations?: RelationRecord[];
+ claims?: ClaimRecord[];
+};
+
+type ManifestFile = {
+ relationLabels?: Record;
+};
+
+export type DataspaceNav = {
+ id: string;
+ title: string;
+ kinds: { id: string; plural: string; href: string }[];
+};
+
+export type DataspaceView = {
+ id: string;
+ title: string;
+ description: string;
+ kinds: { id: string; label: string; plural: string; href: string }[];
+ rows: RecordRow[];
+};
+
+export type KindView = {
+ dataspaceId: string;
+ dataspaceTitle: string;
+ kindId: string;
+ label: string;
+ plural: string;
+ rows: RecordRow[];
+};
+
+export type RelationView = {
+ wording: string;
+ direction: "out" | "in";
+ otherTitle: string;
+ otherHref: string | null;
+};
+
+export type EntityView = {
+ id: string;
+ title: string;
+ tldr: string;
+ summary: string;
+ tags: string[];
+ dataspaceId: string;
+ dataspaceTitle: string;
+ kindId: string;
+ kindLabel: string;
+ kindHref: string;
+ sources: { title: string; license: string }[];
+ claims: { key: string; status: string; value: string | null }[];
+ relations: RelationView[];
+};
+
+type Loaded = {
+ snapshot: SnapshotFile;
+ labels: Map;
+ byId: Map;
+};
+
+const ROOT = join(process.cwd(), "../examples/dataspaces");
+
+let cache: Map | null = null;
+
+export function entitySlug(id: string, kindId: string): string {
+ const marker = `:${kindId}:`;
+ const at = id.lastIndexOf(marker);
+ if (at >= 0) return id.slice(at + marker.length);
+ const tail = id.split(":").pop();
+ return tail && tail.length > 0 ? tail : id;
+}
+
+export function kindHref(dataspaceId: string, kindId: string): string {
+ return `/${dataspaceId}/${kindId}/`;
+}
+
+export function entityHref(dataspaceId: string, kindId: string, id: string): string {
+ return `/${dataspaceId}/${kindId}/${encodeURIComponent(entitySlug(id, kindId))}/`;
+}
+
+function loadAll(): Map {
+ if (cache) return cache;
+ const loaded = new Map();
+ for (const name of readdirSync(ROOT).sort()) {
+ const snapshotPath = join(ROOT, name, "snapshot.json");
+ let snapshot: SnapshotFile;
+ try {
+ snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")) as SnapshotFile;
+ } catch {
+ continue;
+ }
+ if (!snapshot.dataspace?.id || !Array.isArray(snapshot.kinds) || !Array.isArray(snapshot.entities)) continue;
+ let manifest: ManifestFile = {};
+ try {
+ manifest = JSON.parse(readFileSync(join(ROOT, name, "manifest.json"), "utf8")) as ManifestFile;
+ } catch {
+ manifest = {};
+ }
+ const labels = new Map();
+ for (const [predicate, wording] of Object.entries(manifest.relationLabels ?? {})) {
+ labels.set(predicate, {
+ forward: wording.forward ?? predicate,
+ inverse: wording.inverse ?? predicate,
+ });
+ }
+ loaded.set(snapshot.dataspace.id, {
+ snapshot,
+ labels,
+ byId: new Map(snapshot.entities.map((entity) => [entity.id, entity])),
+ });
+ }
+ cache = loaded;
+ return loaded;
+}
+
+function kindOf(loaded: Loaded, kindId: string): KindRecord | null {
+ return loaded.snapshot.kinds.find((kind) => kind.id === kindId) ?? null;
+}
+
+function rowFor(loaded: Loaded, entity: EntityRecord): RecordRow {
+ const kind = kindOf(loaded, entity.kindId);
+ return {
+ id: entity.id,
+ kindId: entity.kindId,
+ kindLabel: kind?.label ?? entity.kindId,
+ title: entity.title,
+ tldr: entity.tldr,
+ href: entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id),
+ kindHref: kindHref(loaded.snapshot.dataspace.id, entity.kindId),
+ };
+}
+
+export function listNavigation(): DataspaceNav[] {
+ return [...loadAll().values()].map((loaded) => ({
+ id: loaded.snapshot.dataspace.id,
+ title: loaded.snapshot.dataspace.title,
+ kinds: loaded.snapshot.kinds.map((kind) => ({
+ id: kind.id,
+ plural: kind.plural,
+ href: kindHref(loaded.snapshot.dataspace.id, kind.id),
+ })),
+ }));
+}
+
+export function findDataspace(id: string): DataspaceView | null {
+ const loaded = loadAll().get(id);
+ if (!loaded) return null;
+ return {
+ id: loaded.snapshot.dataspace.id,
+ title: loaded.snapshot.dataspace.title,
+ description: loaded.snapshot.dataspace.description,
+ kinds: loaded.snapshot.kinds.map((kind) => ({
+ id: kind.id,
+ label: kind.label,
+ plural: kind.plural,
+ href: kindHref(loaded.snapshot.dataspace.id, kind.id),
+ })),
+ rows: loaded.snapshot.entities.map((entity) => rowFor(loaded, entity)),
+ };
+}
+
+export function findKind(dataspaceId: string, kindId: string): KindView | null {
+ const loaded = loadAll().get(dataspaceId);
+ if (!loaded) return null;
+ const kind = kindOf(loaded, kindId);
+ if (!kind) return null;
+ return {
+ dataspaceId,
+ dataspaceTitle: loaded.snapshot.dataspace.title,
+ kindId: kind.id,
+ label: kind.label,
+ plural: kind.plural,
+ rows: loaded.snapshot.entities.filter((entity) => entity.kindId === kind.id).map((entity) => rowFor(loaded, entity)),
+ };
+}
+
+function hrefForEntity(loaded: Loaded, id: string): string | null {
+ const entity = loaded.byId.get(id);
+ if (!entity) return null;
+ return entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id);
+}
+
+export function findEntity(dataspaceId: string, kindId: string, slug: string): EntityView | null {
+ const loaded = loadAll().get(dataspaceId);
+ if (!loaded) return null;
+ const kind = kindOf(loaded, kindId);
+ if (!kind) return null;
+ const matches = loaded.snapshot.entities.filter(
+ (entity) => entity.kindId === kind.id && entitySlug(entity.id, kind.id) === slug,
+ );
+ if (matches.length !== 1) return null;
+ const entity = matches[0];
+ const sources = new Map((loaded.snapshot.sources ?? []).map((source) => [source.id, source]));
+ const relations: RelationView[] = [];
+ for (const relation of loaded.snapshot.relations ?? []) {
+ const wording = loaded.labels.get(relation.predicate);
+ if (relation.from === entity.id) {
+ const other = loaded.byId.get(relation.to);
+ relations.push({
+ wording: wording?.forward ?? relation.predicate,
+ direction: "out",
+ otherTitle: other?.title ?? relation.to,
+ otherHref: hrefForEntity(loaded, relation.to),
+ });
+ } else if (relation.to === entity.id) {
+ const other = loaded.byId.get(relation.from);
+ relations.push({
+ wording: wording?.inverse ?? relation.predicate,
+ direction: "in",
+ otherTitle: other?.title ?? relation.from,
+ otherHref: hrefForEntity(loaded, relation.from),
+ });
+ }
+ }
+ return {
+ id: entity.id,
+ title: entity.title,
+ tldr: entity.tldr,
+ summary: entity.summary,
+ tags: entity.tags ?? [],
+ dataspaceId,
+ dataspaceTitle: loaded.snapshot.dataspace.title,
+ kindId: kind.id,
+ kindLabel: kind.label,
+ kindHref: kindHref(dataspaceId, kind.id),
+ sources: (entity.sourceRefs ?? []).map((id) => {
+ const source = sources.get(id);
+ return { title: source?.title ?? id, license: source?.license ?? "" };
+ }),
+ claims: (loaded.snapshot.claims ?? [])
+ .filter((claim) => claim.subjectId === entity.id)
+ .map((claim) => ({ key: claim.key, status: claim.status, value: claim.value ?? null })),
+ relations,
+ };
+}
diff --git a/ui/lib/fixture.ts b/ui/lib/fixture.ts
deleted file mode 100644
index 01b1c90..0000000
--- a/ui/lib/fixture.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { readFileSync } from "node:fs";
-import { join } from "node:path";
-import type { RecordRow } from "./types";
-
-type Snapshot = {
- dataspace: { id: string; title: string; description: string };
- kinds: { id: string; label: string; plural: string }[];
- entities: { id: string; kindId: string; title: string; tldr: string }[];
-};
-
-export function loadSystemsThinking(): { title: string; description: string; rows: RecordRow[] } {
- const path = join(process.cwd(), "../examples/dataspaces/systems-thinking/snapshot.json");
- const snapshot = JSON.parse(readFileSync(path, "utf8")) as Snapshot;
- const labels = new Map(snapshot.kinds.map((kind) => [kind.id, kind.label]));
- const rows = snapshot.entities.map((entity) => ({
- id: entity.id,
- kindId: entity.kindId,
- kindLabel: labels.get(entity.kindId) ?? entity.kindId,
- title: entity.title,
- tldr: entity.tldr,
- }));
- return {
- title: snapshot.dataspace.title,
- description: snapshot.dataspace.description,
- rows,
- };
-}
diff --git a/ui/lib/types.ts b/ui/lib/types.ts
index 3c69af8..14aa7c0 100644
--- a/ui/lib/types.ts
+++ b/ui/lib/types.ts
@@ -4,6 +4,8 @@ export type RecordRow = {
kindLabel: string;
title: string;
tldr: string;
+ href?: string;
+ kindHref?: string;
};
export type SortState = { key: string; dir: 1 | -1 };
diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts
index 54739ea..766bc81 100644
--- a/ui/vitest.config.ts
+++ b/ui/vitest.config.ts
@@ -1,6 +1,11 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
+const root = path.dirname(fileURLToPath(import.meta.url));
+
export default defineConfig({
+ resolve: { alias: { "@": root } },
esbuild: { jsx: "automatic" },
test: {
environment: "node",
diff --git a/workplans/CONTENT-WP-0002-ui-extraction.md b/workplans/CONTENT-WP-0002-ui-extraction.md
index fb9a7ba..b834d97 100644
--- a/workplans/CONTENT-WP-0002-ui-extraction.md
+++ b/workplans/CONTENT-WP-0002-ui-extraction.md
@@ -16,7 +16,7 @@ state_hub_workstream_id: "55c1d92f-fe1a-552a-998d-2d6905fb6b99"
# UI extraction and experience skeleton
-Deliver the strongest OnCo-style interaction patterns on generic data. CONTENT-WP-0000 and CONTENT-WP-0001 are finished, so this plan is eligible. T01 has landed as a reduced shell under `ui/`: tokens, layout, header, hero, and a generic sortable table. The sample rows are the systems-thinking fixture. T02 is next. See `workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md`.
+Deliver the strongest OnCo-style interaction patterns on generic data. CONTENT-WP-0000 and CONTENT-WP-0001 are finished, so this plan is eligible. T01 and T02 have landed under `ui/`. T02 adds a kind index and an entity page driven by each snapshot's kind registry, for both example dataspaces. T03 is next. See `workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md`.
## Work packages
@@ -33,7 +33,7 @@ state_hub_task_id: "12db582c-24d1-5a93-a70b-e944c3e88535"
```task
id: CONTENT-WP-0002-T02
-status: todo
+status: done
priority: high
state_hub_task_id: "3a8dcdb5-f4b0-5a5d-99e6-7fd62ea7d002"
```
diff --git a/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md b/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md
index 53f1acc..c07b212 100644
--- a/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md
+++ b/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md
@@ -32,4 +32,21 @@ Checks on 2026-10-13:
- `python3 scripts/validate_fixtures.py` and `python3 -m unittest tests.test_contract tests.test_ingest` passed.
- `next dev` on `127.0.0.1:3400` returned the home page (HTTP 200, Next.js 16.3.4). The HTML contained the Content Explorer heading, the skip link, `#main`, and the fixture titles Feedback, Requisite variety, and Stock and flow model. The rendered page did not contain the words oncology or cancer. The dev server was stopped after that check. No browser pane was available, so this was an HTTP render check, not a keyboard or screenshot pass. Those remain T09.
-Not done: kind index, detail, BrowserRow, URL facets, keyboard states, both dataspaces, the formal import gate, or keyboard and small-screen smoke.
+Not done after T01: kind index, detail, BrowserRow, URL facets, keyboard states, both dataspaces, the formal import gate, or keyboard and small-screen smoke.
+
+## T02 — registry kind index and entity detail
+
+The kind list is the `kinds` array on each snapshot. Routes are `/{dataspace}/{kind}/` and `/{dataspace}/{kind}/{slug}/`. The slug is the part of the entity id after the kind. Both example dataspaces are loaded from `examples/dataspaces/`. An unknown kind or slug returns 404.
+
+`EntityDetail.tsx` from the pin was not copied. The detail page is a greenfield view: title, TL;DR, summary, tags, sources, claims, and neighbouring relations with the manifest's forward and inverse labels. Search, facets, and URL sort state stay with later tasks. The neighbour list is not the CONTENT-WP-0004 graph journey.
+
+The header kind links are the active dataspace's registry. On `/` that dataspace is systems-thinking.
+
+Checks on 2026-10-13, after T02:
+
+- `npm test` in `ui/`: 8 tests passed, including registry membership for both dataspaces, the Feedback neighbour, and static markup for the kind heading and the system-boundary claim.
+- `npm run typecheck` in `ui/` passed.
+- `python3 scripts/check_ui_boundary.py`, fixture validation, and `tests.test_contract` / `tests.test_ingest` passed.
+- `next dev` on `127.0.0.1:3400` returned HTTP 200 for `/`, `/systems-thinking/concept/`, `/systems-thinking/concept/feedback/`, `/systems-thinking/concept/system-boundary/`, `/software-architecture/pattern/`, and `/software-architecture/concept/coupling/`. Unknown kind and slug returned 404. The rendered pages did not contain the words oncology or cancer. The server was stopped. No browser pane was available, so this was an HTTP render check.
+
+Not done: BrowserRow mapper, URL facets and sort, keyboard search and empty/error states, the T06 both-dataspace gate for search and filter, the formal import gate, provenance gate, or keyboard and small-screen smoke.