Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
- run: npm ci
Expand All @@ -25,8 +25,8 @@ jobs:
vulnerability-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
- run: npm ci
Expand All @@ -36,7 +36,7 @@ jobs:
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist
*.local
coverage/
*claude*
sample-data/
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# tehillim-ui

The results UI for [tehillim-benchmarks](https://github.com/rdtaylorjr/tehillim-benchmarks):
a static page rendering every representation domain's parallelism, genre, and psalm-shape
benchmark tables, built from JSON files produced by that repo and hosted in
[tehillim-data](https://github.com/rdtaylorjr/tehillim-data).
**Deprecated.** The results UI now lives in
[tehillim](https://github.com/rdtaylorjr/tehillim), which serves the same benchmarks and carries
the Plotly model-detail charts developed here. This repo is kept for history and is no longer
built or published.

It was the first results UI for
[tehillim-benchmarks](https://github.com/rdtaylorjr/tehillim-benchmarks): a static page rendering
every representation domain's parallelism, genre, and psalm-shape benchmark tables. The payload
JSON it describes now ships with the site rather than from a data repo, so the `build_ui_page`
step below no longer describes how the published page is assembled.

## About

Expand Down Expand Up @@ -44,6 +50,13 @@ npm run format # prettier --write
`ui_export.scripts.build_ui_page` fills in with the domain JSON and the built bundle to produce
one final, self-contained `ui.html`.

## Data sources

The parallelism and genre classifications this page renders are derived from the Logos Psalms
Explorer Dataset, used with permission. Full citation in
[tehillim-benchmarks](https://github.com/rdtaylorjr/tehillim-benchmarks)'s README; a short credit
line appears in the page's own footer.

## Family

* [tehillim-benchmarks](https://github.com/rdtaylorjr/tehillim-benchmarks): produces the
Expand Down
79 changes: 77 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@
"lint": "eslint src",
"format": "prettier --write src",
"typecheck": "tsc --noEmit",
"build": "esbuild src/app/index.ts --bundle --format=iife --outfile=dist/app.bundle.js",
"build": "esbuild src/app/index.ts --bundle --format=iife --outfile=dist/app.bundle.js && npm run build:home",
"build:home": "esbuild src/app/index-home.ts --bundle --format=esm --splitting --outdir=dist/home --entry-names=[name]",
"arch-lint": "steiger src"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/plotly.js": "^3.0.13",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitest/coverage-v8": "^4.1.11",
"esbuild": "^0.28.2",
"eslint": "^10.8.1",
Expand All @@ -21,5 +25,10 @@
"typescript": "~6.0.2",
"typescript-eslint": "^8.67.0",
"vitest": "^4.1.11"
},
"dependencies": {
"plotly.js-dist-min": "^3.7.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
}
}
91 changes: 91 additions & 0 deletions src/app/index-home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { initBenchmarkTables } from "../widgets/benchmark-table-home";
import { createMountGuard } from "./mountGuard";
import type { PageController } from "./pageController";
import { parseRoute, type Route } from "./router";

function requireEl(id: string): HTMLElement {
const el = document.getElementById(id);
if (!el) throw new Error(`Missing #${id}`);
return el;
}

/** Syncs the header's Benchmarks/Detail toggle: Detail only enables and lights up on a model route. */
function syncPageNav(pageNav: HTMLElement, route: Route): void {
const benchmarksBtn = pageNav.querySelector(
'[data-page="benchmarks"]',
) as HTMLButtonElement;
const detailBtn = pageNav.querySelector(
'[data-page="detail"]',
) as HTMLButtonElement;
benchmarksBtn.classList.toggle("active", route.kind === "home");
detailBtn.classList.toggle("active", route.kind === "model");
detailBtn.disabled = route.kind !== "model";
}

/**
* Wires the hash router: home shows the always-present benchmark table markup, a model route
* dynamically imports the model-detail widget (and its Plotly dependency) only once entered, so
* the home page's bundle stays dependency-free. Mirrors tehillim v1's main.ts mountRoute pattern:
* `isStale` (see mountGuard.ts) is checked right after every await, so a mount superseded by a
* later navigation bails out before touching a DOM that now belongs to a different route.
*/
async function main(): Promise<void> {
const homeRoot = requireEl("home-root");
const detailRoot = requireEl("detail-root");
const pageNav = requireEl("page-nav");
const guard = createMountGuard();
let current: PageController | null = null;
let homeInitialized = false;

pageNav
.querySelector('[data-page="benchmarks"]')!
.addEventListener("click", () => {
location.hash = "";
});

const mountRoute = async (route: Route): Promise<void> => {
current?.unmount();
current = null;
const { isStale } = guard.next();
syncPageNav(pageNav, route);

if (route.kind === "home") {
detailRoot.hidden = true;
detailRoot.innerHTML = "";
homeRoot.hidden = false;
if (!homeInitialized) {
initBenchmarkTables();
homeInitialized = true;
}
current = { unmount(): void {} };
return;
}

homeRoot.hidden = true;
detailRoot.hidden = false;
const { mountModelDetail } = await import("../widgets/model-detail");
if (isStale()) return;
const controller = await mountModelDetail(
detailRoot,
route.domain,
route.model,
route.section,
isStale,
);
if (isStale()) {
controller.unmount();
return;
}
current = controller;
};

window.addEventListener("hashchange", () => {
void mountRoute(parseRoute(location.hash));
});

await mountRoute(parseRoute(location.hash));
}

main().catch((error: unknown) => {
console.error(error);
});
25 changes: 25 additions & 0 deletions src/app/mountGuard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { createMountGuard } from "./mountGuard";

describe("createMountGuard", () => {
it("reports the current turn as not stale", () => {
const guard = createMountGuard();
const { isStale } = guard.next();
expect(isStale()).toBe(false);
});

it("reports an earlier turn as stale once a later turn has been claimed", () => {
const guard = createMountGuard();
const first = guard.next();
guard.next();
expect(first.isStale()).toBe(true);
});

it("keeps the latest turn fresh even after several earlier turns", () => {
const guard = createMountGuard();
guard.next();
guard.next();
const latest = guard.next();
expect(latest.isStale()).toBe(false);
});
});
19 changes: 19 additions & 0 deletions src/app/mountGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Guards against a page-mount race on rapid client-side navigation: if the user navigates away
* before an in-flight route's data fetch (e.g. a model route's Plotly bundle + JSON) resolves,
* that fetch must not go on to wire itself onto the DOM the *new* route just rendered.
*
* Each call to `next()` claims the current turn and invalidates every earlier one still in
* flight; that turn's own `isStale()` reports whether a later turn has since been claimed, so a
* route-mount function can check it right after its await and bail out before touching the DOM.
*/
export function createMountGuard(): { next(): { isStale: () => boolean } } {
let currentTurn = 0;

return {
next(): { isStale: () => boolean } {
const myTurn = ++currentTurn;
return { isStale: () => myTurn !== currentTurn };
},
};
}
4 changes: 4 additions & 0 deletions src/app/pageController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** What the router needs from a mounted route: a way to tear it down before mounting the next one. */
export interface PageController {
unmount(): void;
}
Loading
Loading