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,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>
);