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

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules/
.next/
*.tsbuildinfo
out/
upstream-onco/
__pycache__/

View file

@ -1,5 +1,5 @@
# Third-party materials and licensing
This seed does **not** contain OnCo source or OnCo oncology data. It uses factual references to the upstream code and site. Prior to copying/adapting source, inspect `https://github.com/judegomila/OnCo/blob/main/LICENSE` and retain its MIT copyright and permission notice alongside relevant copied files and in shipped notices. Check `LICENSE-DATA`: OnCo corpus is CC BY-NC 4.0 and is not automatically eligible for commercial product reuse. Independently clear artworks, images, logos and third-party source records. The independently authored illustrative sample descriptions are offered as CC0-1.0. The project must choose and add a definitive own-source LICENSE after review; upstream licensing does not by itself license this newly written research package.
This seed does **not** contain the OnCo corpus or OnCo oncology data. `ui/` adapts a reduced set of OnCo interface tokens and components from commit `b594d7433bf4e408b44215e25e5304696d6f840e`. Those portions stay under the MIT License. The copyright and permission notice is in `ui/NOTICE`, and each adapted file names the pin. The OnCo mark, garden art, and `src/data` were not copied. Check `LICENSE-DATA`: OnCo corpus is CC BY-NC 4.0 and is not automatically eligible for commercial product reuse. Independently clear artworks, images, logos and third-party source records. The independently authored illustrative sample descriptions are offered as CC0-1.0. The project must choose and add a definitive own-source LICENSE after review; upstream licensing does not by itself license this newly written research package.
**Reference testing rule:** OnCo `LICENSE-DATA` explicitly treats a company internal data pipeline as commercial use requiring permission. A small test slice is not automatically exempt. See `tests/onco-reference/README.md` and keep actual records out of public CI and redistribution without appropriate rights.

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Reject oncology vocabulary on UI import lines, and require the MIT notice."""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UI = ROOT / "ui"
IMPORT_LINE = re.compile(r"""^\s*(import|export)\b.*\bfrom\b|^\s*import\s*\(""")
FORBIDDEN = re.compile(r"oncology|cancers?|drugs?|targets?", re.IGNORECASE)
SOURCE_SUFFIXES = {".ts", ".tsx", ".css", ".mjs"}
def import_lines(path: Path) -> list[str]:
hits: list[str] = []
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if IMPORT_LINE.search(line) and FORBIDDEN.search(line):
hits.append(f"{path.relative_to(ROOT)}:{number}: {line.strip()}")
return hits
def main() -> int:
problems: list[str] = []
for path in sorted(UI.rglob("*")):
if not path.is_file() or "node_modules" in path.parts or ".next" in path.parts:
continue
if path.suffix in SOURCE_SUFFIXES:
problems.extend(import_lines(path))
notice = (UI / "NOTICE").read_text(encoding="utf-8")
if "Copyright (c) 2026 OnCo" not in notice or "MIT License" not in notice:
problems.append("ui/NOTICE is missing the OnCo MIT copyright and permission notice")
if problems:
print("\n".join(problems))
return 1
print("ui boundary: no oncology imports; NOTICE retains the OnCo MIT text")
return 0
if __name__ == "__main__":
sys.exit(main())

30
ui/NOTICE Normal file
View file

@ -0,0 +1,30 @@
The files under ui/ that adapt OnCo interface code include substantial
portions of the OnCo software. Those portions remain under the MIT License.
Content Explorer's own product name, page copy, and the systems-thinking
fixture are not OnCo materials.
Source: https://github.com/judegomila/OnCo
Pinned commit: b594d7433bf4e408b44215e25e5304696d6f840e
Adapted files name that commit in their provenance header.
MIT License
Copyright (c) 2026 OnCo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

246
ui/app/globals.css Normal file
View file

@ -0,0 +1,246 @@
/*
* Adapted from OnCo src/app/globals.css
* https://github.com/judegomila/OnCo @ b594d7433bf4e408b44215e25e5304696d6f840e
* Copyright (c) 2026 OnCo. MIT License. See ui/NOTICE.
*
* Kept: color tokens, type scale, card, chip, button, hero wash, skip link,
* and the sticky results-table rules. Dropped: garden art, graph explorer,
* oncology prose, and column-resize grips. The table class is `records`.
* Accent values are the upstream pink. Geist font files are not fetched;
* the sans stack is the system UI font.
*/
@import "tailwindcss";
:root {
--background: #f5f4ee;
--foreground: #1b1f1c;
--muted: #5b655e;
--border: #e0ded4;
--border-strong: #c9c6b9;
--card: #fcfbf8;
--surface: #eeece4;
--accent: #c2185b;
--accent-solid: #d6336c;
--accent-fg: #ffffff;
--accent-soft: #fce7f0;
--ring: #d6336c;
--garden-1: #9db59a;
--garden-3: #d9d2bf;
--shadow-1: 0 1px 2px rgb(31 40 33 / 0.04), 0 2px 6px -2px rgb(31 40 33 / 0.05);
--shadow-2: 0 10px 24px -12px rgb(31 40 33 / 0.16), 0 2px 6px -2px rgb(31 40 33 / 0.06);
--header-h: 3.5rem;
--sticky-top: var(--header-h);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-card: var(--card);
--color-surface: var(--surface);
--color-accent: var(--accent);
--color-accent-solid: var(--accent-solid);
--color-accent-fg: var(--accent-fg);
--color-accent-soft: var(--accent-soft);
--font-sans: ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, monospace;
--text-small: 0.8125rem;
--text-small--line-height: 1.25rem;
--text-body: 0.9375rem;
--text-body--line-height: 1.55rem;
--shadow-card: var(--shadow-1);
--shadow-lift: var(--shadow-2);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #121816;
--foreground: #e6e9e6;
--muted: #a6afa9;
--border: #2b3431;
--border-strong: #3c4642;
--card: #1a201e;
--surface: #212927;
--accent: #f9a8d4;
--accent-solid: #db2777;
--accent-fg: #ffffff;
--accent-soft: #3b1a2b;
--ring: #f87171;
--garden-1: #3d5a45;
--garden-3: #2a3330;
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.35);
--shadow-2: 0 8px 20px -10px rgb(0 0 0 / 0.6);
}
}
@layer base {
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, h3 { text-wrap: balance; letter-spacing: -0.015em; font-weight: 600; line-height: 1.15; }
p { text-wrap: pretty; }
table { font-variant-numeric: tabular-nums; }
::selection { background: color-mix(in srgb, var(--accent) 22%, transparent); }
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 4px; }
}
@layer components {
.display {
font-size: clamp(2.5rem, 1.6rem + 3.4vw, 4.25rem);
line-height: 1.02;
letter-spacing: -0.032em;
font-weight: 600;
text-wrap: balance;
}
.kicker {
font-size: 0.6875rem;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.14em;
font-weight: 600;
color: var(--muted);
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 0.875rem;
box-shadow: var(--shadow-1);
}
.chip {
display: inline-block;
max-width: 100%;
overflow: clip;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 9999px;
padding: 0.125rem 0.6rem;
font-size: 0.72rem;
line-height: 1.35;
font-weight: 600;
letter-spacing: 0.01em;
background: var(--surface);
color: var(--foreground);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 2.5rem;
padding: 0 1rem;
border-radius: 0.625rem;
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
border: 1px solid var(--border);
background: var(--card);
color: var(--foreground);
}
.btn:hover { background: var(--surface); border-color: var(--border-strong); }
.btn-primary { background: var(--accent-solid); border-color: var(--accent-solid); color: var(--accent-fg); }
.btn-primary:hover { filter: brightness(1.08); }
.hero {
background:
radial-gradient(52rem 24rem at 10% -8%, color-mix(in srgb, var(--accent) 7%, transparent), transparent 70%),
radial-gradient(48rem 30rem at 94% -4%, color-mix(in srgb, var(--garden-1) 24%, transparent), transparent 70%),
radial-gradient(44rem 18rem at 42% 108%, color-mix(in srgb, var(--garden-3) 50%, transparent), transparent 70%),
linear-gradient(to bottom, color-mix(in srgb, var(--surface) 55%, transparent), var(--background));
}
.site-footer {
background: linear-gradient(to bottom, color-mix(in srgb, var(--garden-3) 34%, var(--background)), color-mix(in srgb, var(--surface) 40%, var(--background)) 45%);
}
table.records {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.875rem;
line-height: 1.45;
}
table.records thead th {
position: sticky;
top: var(--sticky-top);
z-index: 2;
text-align: start;
vertical-align: bottom;
font-weight: 600;
color: var(--muted);
font-size: 0.6875rem;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 0.7rem 0.75rem 0.55rem;
background: var(--card);
border-bottom: 1px solid var(--border-strong);
white-space: nowrap;
}
table.records thead th:first-child { color: var(--foreground); }
table.records thead th button {
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
}
table.records thead th button:hover { color: var(--foreground); }
table.records td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
overflow-wrap: break-word;
}
table.records td:first-child { font-weight: 500; }
table.records tbody tr:last-child td { border-bottom-color: transparent; }
table.records tbody tr:hover td { background: color-mix(in srgb, var(--foreground) 3.5%, transparent); }
table.records th:first-child,
table.records td:first-child { padding-left: 1rem; }
table.records th:last-child,
table.records td:last-child { padding-right: 1rem; }
.overflow-x-auto table.records thead th { top: 0; }
@media (width >= 64rem) {
.results-table table.records thead th { top: var(--sticky-top); }
}
}
.overflow-x-auto > table.records thead th { position: static; }
@media (min-width: 64rem) {
.lg\:overflow-x-visible > table.records thead th { position: sticky; }
}
.skip-link {
position: absolute;
left: 0.75rem;
top: -3rem;
z-index: 200;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: var(--accent-solid);
color: var(--accent-fg);
font-weight: 600;
}
.skip-link:focus { top: 0.75rem; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

24
ui/app/layout.tsx Normal file
View file

@ -0,0 +1,24 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { SkipLink } from "@/components/SkipLink";
import "./globals.css";
export const metadata: Metadata = {
title: "Content Explorer",
description: "A domain-independent interface for browsing an interconnected field of knowledge.",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<SkipLink />
<SiteHeader />
<main id="main">{children}</main>
<SiteFooter />
</body>
</html>
);
}

13
ui/app/page.tsx Normal file
View file

@ -0,0 +1,13 @@
import { Hero } from "@/components/Hero";
import { RecordBrowser } from "@/components/RecordBrowser";
import { loadSystemsThinking } from "@/lib/fixture";
export default function HomePage() {
const fixture = loadSystemsThinking();
return (
<>
<Hero dataspace={fixture.title} description={fixture.description} />
<RecordBrowser rows={fixture.rows} />
</>
);
}

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

27
ui/lib/fixture.ts Normal file
View file

@ -0,0 +1,27 @@
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,
};
}

23
ui/lib/sort-rows.test.ts Normal file
View file

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { nextSort, sortRows } from "./sort-rows";
const rows = [
{ title: "Emergence" },
{ title: "Feedback" },
{ title: "Requisite variety" },
];
describe("sortRows", () => {
it("orders by the selected key and flips with the direction", () => {
const ascending = sortRows(rows, { key: "title", dir: 1 }, (row, key) => row[key as "title"]);
expect(ascending.map((row) => row.title)).toEqual(["Emergence", "Feedback", "Requisite variety"]);
const descending = sortRows(rows, { key: "title", dir: -1 }, (row, key) => row[key as "title"]);
expect(descending.map((row) => row.title)).toEqual(["Requisite variety", "Feedback", "Emergence"]);
});
it("toggles direction when the same key is chosen again", () => {
expect(nextSort(undefined, "title")).toEqual({ key: "title", dir: 1 });
expect(nextSort({ key: "title", dir: 1 }, "title")).toEqual({ key: "title", dir: -1 });
expect(nextSort({ key: "title", dir: -1 }, "kindLabel")).toEqual({ key: "kindLabel", dir: 1 });
});
});

13
ui/lib/sort-rows.ts Normal file
View file

@ -0,0 +1,13 @@
import type { SortState } from "./types";
export function sortRows<T>(rows: readonly T[], sort: SortState | undefined, value: (row: T, key: string) => string): T[] {
if (!sort) return [...rows];
const copy = [...rows];
copy.sort((a, b) => value(a, sort.key).localeCompare(value(b, sort.key)) * sort.dir);
return copy;
}
export function nextSort(current: SortState | undefined, key: string): SortState {
if (current?.key === key) return { key, dir: current.dir === 1 ? -1 : 1 };
return { key, dir: 1 };
}

9
ui/lib/types.ts Normal file
View file

@ -0,0 +1,9 @@
export type RecordRow = {
id: string;
kindId: string;
kindLabel: string;
title: string;
tldr: string;
};
export type SortState = { key: string; dir: 1 | -1 };

5
ui/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

12
ui/next.config.ts Normal file
View file

@ -0,0 +1,12 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
turbopack: {
root: path.dirname(fileURLToPath(import.meta.url)),
},
};
export default nextConfig;

3216
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
ui/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "content-explorer-ui",
"version": "0.1.0",
"private": true,
"description": "Content Explorer interface shell. Visual tokens and the results table are adapted from OnCo under the MIT License. See NOTICE.",
"scripts": {
"dev": "next dev --hostname 127.0.0.1 --port 3400",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
"@types/node": "^24.7.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

7
ui/postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

41
ui/tsconfig.json Normal file
View file

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"incremental": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

9
ui/vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
esbuild: { jsx: "automatic" },
test: {
environment: "node",
include: ["**/*.test.ts", "**/*.test.tsx"],
},
});

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 is the next task. No upstream component has been copied yet.
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`.
## Work packages
@ -24,7 +24,7 @@ Deliver the strongest OnCo-style interaction patterns on generic data. CONTENT-W
```task
id: CONTENT-WP-0002-T01
status: todo
status: done
priority: high
state_hub_task_id: "12db582c-24d1-5a93-a70b-e944c3e88535"
```

View file

@ -0,0 +1,35 @@
# CONTENT-WP-0002 implementation notes
Date: 2026-10-13
## T01 — tokens, layout, header, hero, table
Pinned upstream: `b594d7433bf4e408b44215e25e5304696d6f840e`.
R4 applies. `ResultsTable.tsx` in the pin depends on translation, column filters, column resize, and infinite scroll. `SiteChrome.tsx` and `src/app/layout.tsx` depend on the OnCo mark, garden art, command palette, and medical footer. Those files were not copied whole.
What landed under `ui/`:
- Color tokens, type classes (`.display`, `.kicker`), card, chip, button, hero wash, skip link, and sticky table rules, adapted from `src/app/globals.css`. The table class is `records`.
- A sticky header and a footer. The header links to `/` and `#records` only, so it does not point at kind routes that do not exist yet.
- A hero with the upstream structure and Content Explorer copy. The sample is `examples/dataspaces/systems-thinking/snapshot.json`.
- `ResultsTable` renders columns, an empty state, sort buttons, and `aria-sort`. The parent owns the sort. URL query state stays with T04.
Deviations:
- Accent values stay the upstream pink (`#c2185b` / `#d6336c`). That color is part of the token file being adapted. It is not used as a medical claim.
- Geist font files are not fetched. The sans stack is the system UI font.
- Garden keyframes, the OnCo mark, column resize, column filters, and paging are omitted.
- Sort state is local React state.
Provenance: `ui/NOTICE` contains `Copyright (c) 2026 OnCo` and the MIT permission text. Adapted files name the pin.
Checks on 2026-10-13:
- `npm test` in `ui/`: 4 tests passed (sort order, empty state, sort button, `aria-sort`).
- `npm run typecheck` in `ui/`: `tsc --noEmit` passed.
- `python3 scripts/check_ui_boundary.py`: no oncology, cancer, drug, or target token on UI import lines, and the notice is present. This does not close T07; that gate still has to cover the later components.
- `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.