Port the explorer shell: tokens, header, hero, and a generic table.

CONTENT-WP-0002-T01 adapts the pinned OnCo visual system under ui/ without the corpus, mark, or kind routes.

Assistant: grok
Assistant-Session: 01a1346c-fdff-7741-b1af-b7283661554b
This commit is contained in:
tegwick 2026-10-13 22:51:00 +02:00
parent 695060e38d
commit a6e360aaa5
27 changed files with 4013 additions and 3 deletions

23
ui/components/Hero.tsx Normal file
View file

@ -0,0 +1,23 @@
/**
* Hero structure adapted from OnCo home: section.hero, kicker, display heading, lede, action.
* 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 }) {
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">
<div className="max-w-3xl">
<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">
<a href="#records" className="btn btn-primary">
Browse records
</a>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { ResultsTable } from "@/components/ResultsTable";
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: "tldr", label: "Summary", sortable: true, render: (row: RecordRow) => row.tldr },
];
export function RecordBrowser({ rows }: { rows: RecordRow[] }) {
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>
<ResultsTable
columns={columns}
rows={ordered}
rowKey={(row) => row.id}
sort={sort}
onSort={(key) => setSort((current) => nextSort(current, key))}
/>
</section>
);
}

View file

@ -0,0 +1,36 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ResultsTable } from "./ResultsTable";
type Row = { title: string; kindLabel: string };
const columns = [
{ key: "title", label: "Title", sortable: true, render: (row: Row) => row.title },
{ key: "kindLabel", label: "Kind", sortable: true, render: (row: Row) => row.kindLabel },
];
describe("ResultsTable", () => {
it("renders an empty state", () => {
const html = renderToStaticMarkup(
<ResultsTable columns={columns} rows={[]} rowKey={() => "none"} onSort={() => undefined} />,
);
expect(html).toContain("No records.");
expect(html).not.toContain("aria-sort");
});
it("exposes sort buttons and aria-sort for the active column", () => {
const html = renderToStaticMarkup(
<ResultsTable
columns={columns}
rows={[{ title: "Feedback", kindLabel: "Concept" } satisfies Row]}
rowKey={(row) => row.title}
sort={{ key: "title", dir: 1 }}
onSort={() => undefined}
/>,
);
expect(html).toContain("Sort by Kind");
expect(html).toContain('aria-sort="ascending"');
expect(html).toContain("Feedback");
expect(html).toContain(">Title");
});
});

View file

@ -0,0 +1,100 @@
import type { ReactNode } from "react";
import type { SortState } from "@/lib/types";
/*
* Reduced from OnCo src/components/filters/ResultsTable.tsx
* https://github.com/judegomila/OnCo @ b594d7433bf4e408b44215e25e5304696d6f840e
* Copyright (c) 2026 OnCo. MIT License. See ui/NOTICE.
*
* The parent owns sorting. This table does not filter, resize, translate, or page.
*/
export type Column<T> = {
key: string;
label: string;
render: (row: T) => ReactNode;
sortable?: boolean;
};
export function ResultsTable<T>({
columns,
rows,
rowKey,
sort,
onSort,
empty = "No records.",
}: {
columns: Column<T>[];
rows: readonly T[];
rowKey: (row: T) => string;
sort?: SortState;
onSort?: (key: string) => void;
empty?: string;
}) {
return (
<div className="card results-table">
<div className="overflow-x-auto lg:overflow-x-visible">
<table className="records">
<thead>
<tr>
{columns.map((column) => {
const sorted = sort?.key === column.key;
const direction = sort?.dir === -1 ? "descending" : "ascending";
return (
<th
key={column.key}
scope="col"
aria-sort={sorted ? direction : undefined}
>
<ColumnHead column={column} sort={sort} onSort={onSort} />
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length}>{empty}</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.key}>{column.render(row)}</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
function ColumnHead<T>({
column,
sort,
onSort,
}: {
column: Column<T>;
sort?: SortState;
onSort?: (key: string) => void;
}) {
if (!column.sortable || !onSort) return <>{column.label}</>;
const sorted = sort?.key === column.key;
const title = sorted
? sort?.dir === -1
? `Sorted descending by ${column.label}`
: `Sorted ascending by ${column.label}`
: `Sort by ${column.label}`;
return (
<button type="button" onClick={() => onSort(column.key)} title={title}>
{column.label}
<span aria-hidden="true" className={sorted ? "text-accent" : "text-muted/40"}>
{sorted ? (sort?.dir === -1 ? " ↓" : " ↑") : " ↕"}
</span>
</button>
);
}

View file

@ -0,0 +1,13 @@
export function SiteFooter() {
return (
<footer className="site-footer mt-28 border-t border-border">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6">
<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.
</p>
</div>
</footer>
);
}

View file

@ -0,0 +1,22 @@
/**
* 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.
*/
export function SiteHeader() {
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">
<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>
</div>
</header>
);
}

View file

@ -0,0 +1,8 @@
/** First Tab jumps past the header to <main id="main">. Adapted from OnCo SkipLink; copy is Content Explorer's. */
export function SkipLink() {
return (
<a href="#main" className="skip-link">
Skip to content
</a>
);
}