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

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