Add registry kind indexes and entity pages.

CONTENT-WP-0002-T02 reads kinds from each snapshot and serves both example dataspaces without oncology imports.

Assistant: grok
Assistant-Session: 01a1346c-fdff-7741-b1af-b7283661554b
This commit is contained in:
tegwick 2026-10-13 22:56:52 +02:00
parent a6e360aaa5
commit e16aa03393
21 changed files with 608 additions and 59 deletions

View file

@ -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<Metadata> {
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 <EntityDetail entity={view} />;
}

View file

@ -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<Metadata> {
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 <KindIndex kind={view} />;
}

View file

@ -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<Metadata> {
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 <DataspaceHome id={dataspace} />;
}

View file

@ -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 }) {
<html lang="en">
<body>
<SkipLink />
<SiteHeader />
<SiteHeader dataspaces={listNavigation()} />
<main id="main">{children}</main>
<SiteFooter />
</body>

View file

@ -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 (
<>
<Hero dataspace={fixture.title} description={fixture.description} />
<RecordBrowser rows={fixture.rows} />
</>
);
return <DataspaceHome id="systems-thinking" />;
}

View file

@ -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 (
<>
<Hero
dataspace={dataspace.title}
description={dataspace.description}
kinds={dataspace.kinds.map((kind) => ({ href: kind.href, label: kind.plural }))}
/>
{others.length > 0 ? (
<p className="mx-auto max-w-7xl px-4 pt-8 text-sm text-muted sm:px-6">
Other fields:{" "}
{others.map((item, index) => (
<span key={item.id}>
{index > 0 ? ", " : null}
<a className="underline" href={`/${item.id}/`}>{item.title}</a>
</span>
))}
</p>
) : null}
<RecordBrowser rows={dataspace.rows} />
</>
);
}

View file

@ -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(<KindIndex kind={kind!} />);
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(<EntityDetail entity={entity!} />);
expect(html).toContain(">System boundary<");
expect(html).toContain("formal-measure");
expect(html).toContain("unknown");
expect(html).toContain("Stock and flow model");
});
});

View file

@ -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 (
<>
<PageHeader kicker={entity.kindLabel} title={entity.title} lede={entity.tldr} />
<div className="mx-auto max-w-7xl space-y-10 px-4 py-10 sm:px-6">
<p className="max-w-3xl text-body leading-relaxed">{entity.summary}</p>
{entity.tags.length > 0 ? (
<ul className="flex flex-wrap gap-2">
{entity.tags.map((tag) => (
<li key={tag} className="chip">{tag}</li>
))}
</ul>
) : null}
<section aria-labelledby="sources-heading">
<h2 id="sources-heading" className="mb-3 text-lg">Sources</h2>
{entity.sources.length === 0 ? (
<p className="text-sm text-muted">No sources.</p>
) : (
<ul className="space-y-2 text-sm">
{entity.sources.map((source) => (
<li key={source.title}>
{source.title}
{source.license ? <span className="text-muted"> · {source.license}</span> : null}
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="claims-heading">
<h2 id="claims-heading" className="mb-3 text-lg">Claims</h2>
{entity.claims.length === 0 ? (
<p className="text-sm text-muted">No claims.</p>
) : (
<ul className="space-y-2 text-sm">
{entity.claims.map((claim) => (
<li key={`${claim.key}:${claim.status}:${claim.value ?? ""}`}>
<span className="font-medium">{claim.key}</span>
<span className="text-muted"> · {claim.status}</span>
{claim.value ? <span> · {claim.value}</span> : null}
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="relations-heading">
<h2 id="relations-heading" className="mb-3 text-lg">Relations</h2>
{entity.relations.length === 0 ? (
<p className="text-sm text-muted">No relations.</p>
) : (
<ul className="space-y-2 text-sm">
{entity.relations.map((relation) => (
<li key={`${relation.direction}:${relation.wording}:${relation.otherTitle}`}>
<span className="text-muted">{relation.wording}</span>
{" · "}
{relation.otherHref ? (
<a className="underline" href={relation.otherHref}>{relation.otherTitle}</a>
) : (
relation.otherTitle
)}
</li>
))}
</ul>
)}
</section>
<p className="text-sm">
<a className="underline" href={entity.kindHref}>Back to {entity.kindLabel}</a>
</p>
</div>
</>
);
}

View file

@ -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 (
<section className="hero border-b border-border">
<div className="mx-auto max-w-7xl px-4 pt-14 pb-10 sm:px-6 sm:pt-20 sm:pb-12">
@ -11,10 +19,15 @@ export function Hero({ dataspace, description }: { dataspace: string; descriptio
<p className="kicker">{dataspace}</p>
<h1 className="display mt-4">Explore a field of knowledge.</h1>
<p className="mt-6 max-w-2xl text-[17px] leading-relaxed text-foreground/85 sm:text-xl">{description}</p>
<div className="mt-8">
<div className="mt-8 flex flex-wrap gap-2">
<a href="#records" className="btn btn-primary">
Browse records
</a>
{kinds.map((kind) => (
<a key={kind.href} href={kind.href} className="btn">
{kind.label}
</a>
))}
</div>
</div>
</div>

View file

@ -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 (
<>
<PageHeader kicker={kind.dataspaceTitle} title={kind.plural} lede={lede} />
<RecordBrowser rows={kind.rows} />
</>
);
}

View file

@ -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 (
<header className="border-b border-border">
<div className="mx-auto max-w-7xl px-4 pt-8 pb-6 sm:px-6 sm:pt-10">
{kicker ? <p className="kicker mb-2">{kicker}</p> : null}
<h1 className="text-3xl font-semibold tracking-tight first-letter:uppercase sm:text-4xl">{title}</h1>
{lede ? <p className="mt-3 max-w-3xl text-[17px] leading-relaxed text-foreground/85 sm:text-lg">{lede}</p> : null}
</div>
</header>
);
}

View file

@ -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) => <span className="chip">{row.kindLabel}</span> },
{
key: "title",
label: "Title",
sortable: true,
render: (row: RecordRow) => row.href ? <a className="underline" href={row.href}>{row.title}</a> : row.title,
},
{
key: "kindLabel",
label: "Kind",
sortable: true,
render: (row: RecordRow) => row.kindHref
? <a className="chip" href={row.kindHref}>{row.kindLabel}</a>
: <span className="chip">{row.kindLabel}</span>,
},
{ 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<SortState | undefined>({ key: "title", dir: 1 });
const ordered = sortRows(rows, sort, (row, key) => String(row[key as keyof RecordRow] ?? ""));
return (
<section id="records" aria-labelledby="records-heading" className="mx-auto max-w-7xl px-4 py-14 sm:px-6">
<h2 id="records-heading" className="mb-6 text-2xl">Records</h2>
<h2 id="records-heading" className="mb-6 text-2xl">{heading}</h2>
<ResultsTable
columns={columns}
rows={ordered}

View file

@ -5,7 +5,7 @@ export function SiteFooter() {
<p className="max-w-2xl text-sm leading-relaxed text-muted">
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.
</p>
</div>
</footer>

View file

@ -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 (
<header className="sticky top-0 z-40 h-14 border-b border-border bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/75">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-6 px-4 sm:px-6">
<a href="/" className="inline-flex h-10 items-center rounded-lg font-semibold tracking-tight" aria-label="Content Explorer home">
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/75">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-4 sm:gap-6 sm:px-6">
<a href="/" className="inline-flex h-10 shrink-0 items-center rounded-lg font-semibold tracking-tight" aria-label="Content Explorer home">
<span className="text-[15px]">Content Explorer</span>
</a>
<nav aria-label="Primary" className="flex items-center gap-4 text-sm">
<a href="#records" className="text-muted hover:text-foreground">
Records
</a>
</nav>
{active ? (
<nav aria-label="Primary" className="flex min-w-0 items-center gap-4 overflow-x-auto text-sm">
<a href={`/${active.id}/`} className="shrink-0 text-foreground">{active.title}</a>
{active.kinds.map((kind) => (
<a key={kind.id} href={kind.href} className="shrink-0 text-muted hover:text-foreground">
{kind.plural}
</a>
))}
</nav>
) : null}
</div>
</header>
);

34
ui/lib/catalog.test.ts Normal file
View file

@ -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");
});
});

263
ui/lib/catalog.ts Normal file
View file

@ -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<string, { forward?: string; inverse?: string }>;
};
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<string, { forward: string; inverse: string }>;
byId: Map<string, EntityRecord>;
};
const ROOT = join(process.cwd(), "../examples/dataspaces");
let cache: Map<string, Loaded> | 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<string, Loaded> {
if (cache) return cache;
const loaded = new Map<string, Loaded>();
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<string, { forward: string; inverse: string }>();
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,
};
}

View file

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

View file

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

View file

@ -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",

View file

@ -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"
```

View file

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