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:
parent
695060e38d
commit
a6e360aaa5
27 changed files with 4013 additions and 3 deletions
23
ui/components/Hero.tsx
Normal file
23
ui/components/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
ui/components/RecordBrowser.tsx
Normal file
29
ui/components/RecordBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
ui/components/ResultsTable.test.tsx
Normal file
36
ui/components/ResultsTable.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
100
ui/components/ResultsTable.tsx
Normal file
100
ui/components/ResultsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
ui/components/SiteFooter.tsx
Normal file
13
ui/components/SiteFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
ui/components/SiteHeader.tsx
Normal file
22
ui/components/SiteHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
ui/components/SkipLink.tsx
Normal file
8
ui/components/SkipLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue