diff --git a/.github/workflows/deploy-ui.yml b/.github/workflows/deploy-ui.yml new file mode 100644 index 00000000..1bff2641 --- /dev/null +++ b/.github/workflows/deploy-ui.yml @@ -0,0 +1,76 @@ +name: deploy-ui + +on: + push: + branches: [main] + paths: + - 'site/**' + - '.github/workflows/deploy-ui.yml' + workflow_dispatch: + +permissions: + contents: write + pages: write + +concurrency: + group: deploy-ui-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node 20 + pnpm + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Enable pnpm + run: npm install --global pnpm@10 + + - name: Install site deps + working-directory: site + run: pnpm install --frozen-lockfile + + - name: Run JS unit tests (Vitest) + working-directory: site + run: pnpm test:unit --run + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install link-check deps + run: pip install requests pyyaml + + - name: References link check (FR-017) + # HEAD every URL in the references registry; non-zero exit blocks + # the deploy when a citation 4xx's or times out (T086 / T088). + run: PYTHONPATH=src python -m ohbm2026.ui_data.link_check specs/008-ui-rewrite/contracts/references.yaml + + - name: Build site + working-directory: site + env: + BASE_PATH: '' + VITE_BUILD_SHA: ${{ github.sha }} + VITE_BUILD_AT: ${{ github.event.head_commit.timestamp }} + VITE_DATA_PACKAGE_URL: ${{ vars.OHBM2026_UI_DATA_PACKAGE_URL }} + run: | + export VITE_BUILD_SHA_SHORT="${VITE_BUILD_SHA:0:7}" + pnpm build + + - name: Publish to gh-pages root + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site/build + publish_branch: gh-pages + destination_dir: . + keep_files: true + enable_jekyll: false + commit_message: 'deploy(ui): ${{ github.sha }}' diff --git a/.github/workflows/pr-preview-cleanup.yml b/.github/workflows/pr-preview-cleanup.yml new file mode 100644 index 00000000..c396e603 --- /dev/null +++ b/.github/workflows/pr-preview-cleanup.yml @@ -0,0 +1,71 @@ +name: pr-preview-cleanup + +on: + pull_request: + types: [closed] + workflow_dispatch: + inputs: + pr_number: + description: PR number to clean up + required: true + +permissions: + contents: write + deployments: write + pull-requests: read + +jobs: + cleanup: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch' + env: + PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} + steps: + - name: Checkout gh-pages + uses: actions/checkout@v4 + with: + ref: gh-pages + fetch-depth: 1 + + - name: Remove pr-${{ env.PR_NUMBER }} preview directory + run: | + if [ -d "pr-${PR_NUMBER}" ]; then + rm -rf "pr-${PR_NUMBER}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --staged --quiet; then + echo "Nothing staged after removal — skipping commit." + else + git commit -m "cleanup(ui): remove pr-${PR_NUMBER} preview" + git push + fi + else + echo "No preview directory pr-${PR_NUMBER} to remove." + fi + + - name: Mark pr-preview-${{ env.PR_NUMBER }} deployment inactive + uses: actions/github-script@v7 + with: + script: | + const envName = `pr-preview-${process.env.PR_NUMBER}`; + const { data: deployments } = await github.rest.repos.listDeployments({ + owner: context.repo.owner, + repo: context.repo.repo, + environment: envName, + per_page: 100, + }); + if (deployments.length === 0) { + core.info(`No deployments found for environment ${envName}`); + return; + } + for (const dep of deployments) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: dep.id, + state: 'inactive', + description: 'PR closed; preview cleaned up.', + }); + } + core.info(`Marked ${deployments.length} deployment(s) inactive for ${envName}.`); diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 00000000..18e7c456 --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,69 @@ +name: pr-preview + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'site/**' + - '.github/workflows/pr-preview.yml' + workflow_dispatch: + +permissions: + contents: write + pages: write + deployments: write + pull-requests: read + +concurrency: + group: deploy-ui-preview-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preview: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + environment: + name: pr-preview-${{ github.event.pull_request.number }} + url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/pr-${{ github.event.pull_request.number }}/ + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node 20 + pnpm + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Enable pnpm + run: npm install --global pnpm@10 + + - name: Install site deps + working-directory: site + run: pnpm install --frozen-lockfile + + - name: Run JS unit tests (Vitest) + working-directory: site + run: pnpm test:unit --run + + - name: Build site (PR preview) + working-directory: site + env: + BASE_PATH: /pr-${{ github.event.pull_request.number }} + VITE_BUILD_SHA: ${{ github.event.pull_request.head.sha }} + VITE_BUILD_AT: ${{ github.event.pull_request.head.repo.pushed_at }} + VITE_DATA_PACKAGE_URL: ${{ vars.OHBM2026_UI_DATA_PACKAGE_URL }} + run: | + export VITE_BUILD_SHA_SHORT="${VITE_BUILD_SHA:0:7}" + pnpm build + + - name: Publish to gh-pages /pr-${{ github.event.pull_request.number }} + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site/build + publish_branch: gh-pages + destination_dir: pr-${{ github.event.pull_request.number }} + keep_files: false + enable_jekyll: false + commit_message: 'preview(ui): pr-${{ github.event.pull_request.number }} ${{ github.sha }}' diff --git a/.specify/feature.json b/.specify/feature.json index 6ac78031..f6929eaf 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1 @@ -{"feature_directory": "specs/007-package-reorg"} +{"feature_directory": "specs/008-ui-rewrite"} diff --git a/CLAUDE.md b/CLAUDE.md index 538a8e92..ef6ba575 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ Subcommands group into stages (see README for full options): - enrichment: `enrich-abstracts`, `title-audit` (note: `enrich`, `analyze-figures`, `extract-claims`, `reference-metadata` REMOVED in Stage 2 rewire — FR-014. Operators run `enrich-abstracts` and use `--invalidate ` for targeted refresh.) - embeddings: `embed-matrix` (Stage 3 canonical — per-component bundles for one or more `(model, component)` pairs; supports voyage / minilm / openai / pubmedbert; NeuroScape is a derivation step). Per-model debugging subcommands kept for backward compat: `embed-minilm`, `embed-hf`, `embed-openai`, `embed-voyage`, `embed-stage2`, `apply-published-stage2`. Multi-component recipes (full-manuscript, methods+results, title+results+conclusion) are composed downstream via `neuroscape.compose_recipe([...], model_key=...)`. - analysis: `semantic-analysis`, `cluster-benchmark`, `umap-plot`, `compare-projections`, `optimize-projections` -- UI: `export-ui`, `build-ui` +- UI data package: `scripts/build_ui_data.py` (Stage 6 canonical; emits `site/static/data/` consumed by the SvelteKit site under `site/`). Poster layout/sequencing is **not** in `ohbmcli` AND is **parked** as of Stage 5 (see `ohbm2026.layout` parked package and `scripts/layout/` for the 15 companion scripts: `scripts/layout/optimize_poster_layout.py`, `scripts/layout/analyze_poster_layout.py`, `scripts/layout/benchmark_poster_sequencing.py`, etc.). Always pass explicit input paths and a fresh `--output-root`/`--output-dir`; do not rely on stale baked-in defaults. @@ -123,7 +123,7 @@ All library code lives in `src/ohbm2026/`: - `artifacts.py` — shared artifact-naming/state-key helpers used across stages. - `category_evaluation.py`, `category_rollup.py` — compare learned cluster families against submitter taxonomies. - `layout/` (**parked** as of Stage 5 — specs/007-package-reorg/) — poster_layout, poster_sequencing, nocd_experiments. Preserved verbatim for future revival; not actively maintained. Tests under `tests/test_poster_*.py` + `tests/test_nocd_experiments.py` still run with import-paths updated to `ohbm2026.layout.*`. The 15 companion scripts live under `scripts/layout/`. Revive when a new organizer cycle needs poster-layout work. -- `ui.py` — static UI export (`export-ui` writes a fresh bundle; `build-ui` also mirrors to `export/ui-site/`). +- `ui_data/` — **Stage 6** UI data-package builders. `manifest.py`, `abstracts.py`, `authors.py`, `cells.py`, `topics.py`, `neighbors.py`, `enrichment.py`, `vectors.py` produce per-shard envelopes (every shard carries a top-level `build_info` block — FR-019 + CA-008). `state_key.py` discovers the corpus + Stage 4 rollup state-keys at build time (CA-007). `builder.py` orchestrates + enforces the 8 cross-shard invariants from `specs/008-ui-rewrite/data-model.md` §8. `link_check.py` HEAD-validates `specs/008-ui-rewrite/contracts/references.yaml` (every external citation from the About page; non-zero exit blocks the deploy — FR-017). CLI entry: `scripts/build_ui_data.py`. Schema: every emitted JSON shard validates against `specs/008-ui-rewrite/contracts/ui_data.linkml.yaml` via `scripts/validate_ui_data.sh`. The SvelteKit site lives at `site/` (self-contained pnpm project; gh-pages deploy via `.github/workflows/{deploy-ui,pr-preview,pr-preview-cleanup}.yml`; runtime data fetched from the Dropbox tarball at `vars.OHBM2026_UI_DATA_PACKAGE_URL`). - `cli.py` — single dispatch entrypoint that wires the above into subcommands. Tests in `tests/` mirror the module names and use `unittest`. @@ -168,28 +168,33 @@ Current canonical defaults (the UI consumes these): For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan -at `specs/007-package-reorg/plan.md`. The companion design artifacts +at `specs/008-ui-rewrite/plan.md`. The companion design artifacts under the same directory — `research.md`, `data-model.md`, `contracts/`, -and `quickstart.md` — pin Stage 5 package-reorganization design: three -independent commit series that collapse the legacy `src/ohbm2026/ -enrichment.py` (1,361 LOC, 62 def/class symbols, ~22+ unused) into -focused `enrich/` submodules (`text.py`, `cache_paths.py`, -`markdown_render.py`, `openai_compat.py`); park `poster_layout.py`, -`poster_sequencing.py`, and `nocd_experiments.py` under -`src/ohbm2026/layout/` (no scheduled maintenance; revive when a new -organizer cycle needs it) along with their 15 `scripts/` companions -under `scripts/layout/`; and split the monolithic `src/ohbm2026/ui.py` -(1,361 LOC) into a `ui/` package with leaf/mid/trunk submodules -(`text.py`, `figures.py`, `references.py`, `manifest.py`, -`payload_legacy.py`, `payload_stage4.py`, `cli.py`). No backward-compat -shim at any `__init__.py`; every consumer imports from the explicit -submodule that owns the symbol (Stage 4 / Q2 / T108b precedent). Tests -are explicitly waived for the refactor body per the user's "OK to skip -tests" guidance; the existing test suite (583 / 1 pre-existing failure) -stays green, with `tests/test_enrichment.py` deleted because it covers -only the legacy Stage 2 path that Stage 2.1 already replaced. +and `quickstart.md` — pin Stage 6 UI-rewrite design: a static +SvelteKit + Vite site served from GitHub Pages, built and deployed by +a GitHub Action with per-PR preview management, consuming a static- +JSON-shard data package (DuckDB-WASM ruled out at 3,244-abstract scale +per the Session-2026-05-17 clarification). Adds previously-missing +capabilities: program-assigned poster ids (not submission ids), +restored author + affiliation details, 3D UMAP alongside 2D + lasso, +typo-tolerant lexical + semantic search (MiniLM-L6 ONNX via +transformers.js with int8-quantized corpus vectors), interactive +facet recomputation, a saved-list shopping cart with mailto-based +export, an optional walkthrough (shepherd.js), an About page with +build-time verified references, and mobile-first responsive layout. +Accepted abstracts only; withdrawn submissions never surface +anywhere. Three workflows: `deploy-ui.yml` (main → root), `pr- +preview.yml` (PR → `/pr-/`), `pr-preview-cleanup.yml` (close → +remove). 8 user stories; MVP = US1 (search + browse on every device). +No new secrets beyond the default `GITHUB_TOKEN`. The Python data- +builder package lives at `src/ohbm2026/ui_data/`; the site lives at +`site/`. Previous-stage plans: +- Stage 5 package reorganization: `specs/007-package-reorg/plan.md` + (collapsed `enrichment.py` into `enrich/{text, markdown_render}.py`; + parked `layout/`; split `ui.py` into `ui/{payload, cli}.py`; + consolidated `load_json`/`write_json` into `ohbm2026/util/json_io.py`). - Stage 4 analysis & annotation: `specs/006-analysis-annotation/plan.md` (canonical `ohbmcli analyze-matrix` producing 48 bundles + canonical rollup `annotations__.{parquet,sqlite}`; joblib-parallel diff --git a/README.md b/README.md index 9a047e67..5b23ae8e 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,68 @@ The latest end state of the project is: - two semantic cluster lenses: - `25-cluster benchmark` - `claims 28-cluster benchmark` +9. **Stage 6 — UI** — static SvelteKit site served from GitHub Pages, with per-PR preview deploys surfaced in the PR's Deployments box (NOT bot comments). US1–US7 + US8 all shipped on `008-ui-rewrite`. Production at `abstractatlas.brainkb.org`; PR previews at `/pr-/`. See `specs/008-ui-rewrite/` for spec/plan/tasks and `specs/008-ui-rewrite/quickstart.md` for the local-dev recipe. + +## Stage 6: UI + +The Stage 6 site lives under `site/` (a self-contained SvelteKit project; SvelteKit 2 + Vite 6 + Svelte 5). The data-package builder lives under `src/ohbm2026/ui_data/`. Capabilities: typo-tolerant lexical search, transformers.js-backed semantic search (MiniLM-L6 ONNX in a Web Worker against an int8-quantised corpus matrix), 2D + 3D UMAP with lasso + cluster colour-coding, interactive facets, cart + email-my-list, a guided tour (shepherd.js), and an About page whose external citations are HEAD-checked at build time (`link_check.py`). + +Build the data package + the site locally: + +```bash +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --output site/static/data + +cd site && pnpm install && pnpm dev # http://localhost:5173 +``` + +Run the JS test suite: + +```bash +cd site && pnpm test:unit --run # Vitest unit tests +cd site && UI_DATA_AVAILABLE=1 pnpm exec playwright test --project=chromium # e2e (needs the data package built first) +``` + +### Refreshing the deployed data package + +CI doesn't materialize the Stage 1–4 inputs. Instead, the maintainer builds the data package locally and hosts the tarball at a URL the deploy workflow reads from the `OHBM2026_UI_DATA_PACKAGE_URL` repo variable (sha256-pinned via `OHBM2026_UI_DATA_PACKAGE_SHA256`). + +**Dropbox in-place write is required to preserve the share link** ([Dropbox docs](https://help.dropbox.com/share/force-download)). The recipe below uses `tar -czf ` which opens the destination with `O_WRONLY|O_CREAT|O_TRUNC` — preserves the inode, which Dropbox observes as an overwrite (link stays). Do NOT use `cp newfile.tar.gz ` from a different filesystem or `rm canonical && cp …` — both look like delete+create to Dropbox and break the share URL. + +```bash +# 1. Build the data package in place (writes to site/static/data/). +# Each shard is written via O_TRUNC so the inode stays stable for any +# later in-place tar write; mtimes are pinned to a fixed timestamp for +# byte-identical tarballs across reruns. +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --output site/static/data + +# 2. Write the tarball directly to the canonical Dropbox path. The first +# time you do this, share-link the resulting file via the Dropbox UI +# and record the `?dl=0` URL as `OHBM2026_UI_DATA_PACKAGE_URL`. Every +# refresh hereafter re-uses the same path → same inode → same share URL. +tar -czf ~/dbm/shares/ohbm2026/ui-data.tar.gz -C site/static data + +# 3. Update the sha256 repo variable so CI verifies the new bytes. +NEW_SHA=$(shasum -a 256 ~/dbm/shares/ohbm2026/ui-data.tar.gz | awk '{print $1}') +gh variable set OHBM2026_UI_DATA_PACKAGE_SHA256 --body "$NEW_SHA" +``` + +The URL repo variable never changes after the first share-link is generated; only the sha256 var bumps per refresh. Next PR-preview deploy fetches + sha256-verifies the new package. + +Per-PR previews surface in the **PR's Deployments box** (top-of-PR, via the `environment:` declaration in `.github/workflows/pr-preview.yml`) — NOT as a bot comment. The short committish (first 7 chars of git SHA) bakes into the page `` + the persistent footer affordance via the `VITE_BUILD_SHA` env var injected by the deploy workflows, so reviewers can verify each PR-preview reflects the latest pushed commit at-a-glance (FR-022 + SC-011). ## External Requirements diff --git a/docs/reproducibility-vision.md b/docs/reproducibility-vision.md index a4f2af35..9dbbfc29 100644 --- a/docs/reproducibility-vision.md +++ b/docs/reproducibility-vision.md @@ -196,7 +196,31 @@ The usual order is: 4. embedding commands such as `embed-minilm`, `embed-voyage`, or `apply-published-stage2` 5. `ohbmcli cluster-benchmark` and related semantic-analysis steps -6. `ohbmcli build-ui` +6. **Stage 6 UI data package** (canonical site): + ``` + PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --minilm-root data/outputs/embeddings/minilm \ + --output site/static/data + ``` + Validate the package against the LinkML schema: + ``` + scripts/validate_ui_data.sh + ``` + Then HEAD-check the references registry (FR-017): + ``` + PYTHONPATH=src .venv/bin/python -m ohbm2026.ui_data.link_check \ + specs/008-ui-rewrite/contracts/references.yaml + ``` + Re-tar onto the Dropbox shared link (in-place to preserve the inode + + share URL) and bump `OHBM2026_UI_DATA_PACKAGE_SHA256` so the deploy + workflow picks up the new bundle. The SvelteKit site itself is built + + deployed by `.github/workflows/deploy-ui.yml` on merge to `main`. ### Level 3: Rebuild from the upstream abstract source diff --git a/pyproject.toml b/pyproject.toml index 4d707a71..2c8422c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohbm2026" -version = "0.1.0" +version = "0.2.0" description = "Local ingestion pipeline for OHBM 2026 Oxford Abstracts data" readme = "README.md" requires-python = ">=3.11" @@ -22,6 +22,13 @@ analysis = [ "hdbscan>=0.8", ] analysis-sci = ["scispacy>=0.5"] +ui = [ + "numpy>=1.26", + "sentence-transformers>=2.7", + "pyyaml>=6.0", + "requests>=2.31", +] +ui-dev = ["playwright>=1.58.0"] [project.scripts] ohbm-ingest = "ohbm2026.assets:main" diff --git a/scripts/build_ui_data.py b/scripts/build_ui_data.py new file mode 100755 index 00000000..0efdd032 --- /dev/null +++ b/scripts/build_ui_data.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +"""Stage 6 — build the UI data package (T019). + +Thin CLI wrapper around :func:`ohbm2026.ui_data.builder.build_ui_data_package`. +See specs/008-ui-rewrite/quickstart.md for the canonical invocations. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="build_ui_data.py", + description="Build the static-JSON-shard data package consumed by the Stage 6 site.", + ) + parser.add_argument("--corpus", required=True, type=Path) + parser.add_argument("--withdrawn", type=Path, default=None) + parser.add_argument("--authors", required=True, type=Path) + parser.add_argument("--enriched", type=Path, default=None) + parser.add_argument( + "--references", + type=Path, + default=Path("data/cache/reference_metadata/openalex_resolved.json"), + help="OpenAlex-resolved references shard (curated). Defaults to the Stage 2.1 canonical path.", + ) + parser.add_argument("--analysis-root", dest="analysis_root", type=Path, default=None) + parser.add_argument("--rollup", type=Path, default=None, help="Explicit Stage 4 rollup .sqlite path.") + parser.add_argument( + "--discover-rollup", + dest="discover_rollup", + action="store_true", + help="Discover the active rollup state-key under --analysis-root.", + ) + parser.add_argument( + "--minilm-root", + dest="minilm_root", + type=Path, + default=Path("data/outputs/embeddings/minilm"), + help="Root of MiniLM component bundles (introduction__*/, methods__*/, …) for the int8 vector buffer.", + ) + parser.add_argument("--minilm-bundle", dest="minilm_bundle", type=Path, default=None, + help="Deprecated single-bundle alias; use --minilm-root.") + parser.add_argument("--references-yaml", dest="references_yaml", type=Path, default=None) + parser.add_argument("--output", required=True, type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + from ohbm2026.ui_data.builder import Stage6BuildError, build_ui_data_package + + try: + return build_ui_data_package( + corpus_path=args.corpus, + withdrawn_path=args.withdrawn, + authors_path=args.authors, + enriched_path=args.enriched, + references_path=args.references, + analysis_root=args.analysis_root, + rollup=args.rollup, + discover_rollup=args.discover_rollup, + output_dir=args.output, + minilm_root=args.minilm_root, + ) + except Stage6BuildError as exc: + print(f"build_ui_data.py: {exc}", file=sys.stderr) + return 3 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/compute_neighbors.py b/scripts/compute_neighbors.py new file mode 100755 index 00000000..7482dd31 --- /dev/null +++ b/scripts/compute_neighbors.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python +"""Compute k-nearest + k-farthest neighbors per (model, input) cell. + +For each `data/outputs/analysis/<cell_key>/projections__<state-key>/` bundle +this script loads `ids.npy` + `reference_matrix.npy`, computes the pairwise +cosine-distance matrix, and writes: + + data/outputs/analysis/<cell_key>/neighbors__<state-key>/ + ids.npy (N,) int64 abstract_id per row + nearest_ids.npy (N, K) int64 abstract_id of k-th nearest (excl. self) + nearest_distances.npy (N, K) float32 corresponding distance + farthest_ids.npy (N, K) int64 + farthest_distances.npy (N, K) float32 + provenance.json run metadata + state-keys + +K defaults to 10. Cosine distance is `1 - cos(u, v)` on the L2-normalized +embeddings; `M @ M.T` gives the cosine similarity matrix in one BLAS call. + +Run via: + PYTHONPATH=src .venv/bin/python scripts/compute_neighbors.py \ + --analysis-root data/outputs/analysis [--k 10] +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np + + +def _argmin_k(distances: np.ndarray, k: int, exclude: int) -> np.ndarray: + """Indices of the *k* smallest values in `distances`, skipping `exclude` (the row's own index).""" + + n = distances.shape[0] + # argpartition is O(N) per row; take k+1 to leave room for the self-skip. + take = min(n, k + 1) + candidate_idx = np.argpartition(distances, take - 1)[:take] + # Sort by actual distance for deterministic order. + candidate_idx = candidate_idx[np.argsort(distances[candidate_idx])] + out = candidate_idx[candidate_idx != exclude][:k] + return out + + +def _argmax_k(distances: np.ndarray, k: int) -> np.ndarray: + """Indices of the *k* largest values.""" + + n = distances.shape[0] + take = min(n, k) + candidate_idx = np.argpartition(distances, n - take)[n - take:] + candidate_idx = candidate_idx[np.argsort(-distances[candidate_idx])] + return candidate_idx[:k] + + +def compute_cell_neighbors( + bundle_dir: Path, k: int = 10 +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return `(ids, nearest_ids, nearest_dist, farthest_ids, farthest_dist)`.""" + + ids = np.load(bundle_dir / "ids.npy").astype(np.int64) + matrix = np.load(bundle_dir / "reference_matrix.npy").astype(np.float32) + n, d = matrix.shape + if ids.shape[0] != n: + raise RuntimeError(f"shape mismatch in {bundle_dir}: ids={ids.shape}, matrix={matrix.shape}") + # L2-normalize → cosine similarity is just M @ M.T. + norms = np.linalg.norm(matrix, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + normed = matrix / norms + sim = normed @ normed.T # (N, N) + np.clip(sim, -1.0, 1.0, out=sim) + dist = 1.0 - sim # cosine distance + nearest_ids = np.zeros((n, k), dtype=np.int64) + nearest_dist = np.zeros((n, k), dtype=np.float32) + farthest_ids = np.zeros((n, k), dtype=np.int64) + farthest_dist = np.zeros((n, k), dtype=np.float32) + for i in range(n): + n_idx = _argmin_k(dist[i], k=k, exclude=i) + f_idx = _argmax_k(dist[i], k=k) + nearest_ids[i, : len(n_idx)] = ids[n_idx] + nearest_dist[i, : len(n_idx)] = dist[i, n_idx] + farthest_ids[i, : len(f_idx)] = ids[f_idx] + farthest_dist[i, : len(f_idx)] = dist[i, f_idx] + return ids, nearest_ids, nearest_dist, farthest_ids, farthest_dist + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--analysis-root", + type=Path, + default=Path("data/outputs/analysis"), + help="Root containing per-cell <cell_key>/projections__*/ bundles.", + ) + parser.add_argument("--k", type=int, default=10, help="Number of nearest + farthest to store.") + parser.add_argument( + "--force", + action="store_true", + help="Recompute even if the neighbors__<state-key>/ output already exists.", + ) + args = parser.parse_args(argv) + + analysis_root = args.analysis_root + if not analysis_root.exists(): + print(f"analysis-root {analysis_root} does not exist", file=sys.stderr) + return 2 + cell_dirs = sorted(p for p in analysis_root.iterdir() if p.is_dir() and "_" in p.name) + if not cell_dirs: + print(f"no cell directories under {analysis_root}", file=sys.stderr) + return 2 + + total_start = time.time() + n_done = 0 + for cell_dir in cell_dirs: + proj = sorted(cell_dir.glob("projections__*")) + if not proj: + continue + bundle = proj[-1] + state_key = bundle.name.split("__", 1)[1] + out_dir = cell_dir / f"neighbors__{state_key}" + if out_dir.exists() and not args.force: + print(f"skip {cell_dir.name}: neighbors already at {out_dir}") + continue + out_dir.mkdir(parents=True, exist_ok=True) + + t0 = time.time() + try: + ids, n_ids, n_dist, f_ids, f_dist = compute_cell_neighbors(bundle, k=args.k) + except FileNotFoundError as exc: + print(f"skip {cell_dir.name}: missing input {exc}", file=sys.stderr) + continue + np.save(out_dir / "ids.npy", ids) + np.save(out_dir / "nearest_ids.npy", n_ids) + np.save(out_dir / "nearest_distances.npy", n_dist) + np.save(out_dir / "farthest_ids.npy", f_ids) + np.save(out_dir / "farthest_distances.npy", f_dist) + provenance = { + "cell_key": cell_dir.name, + "state_key": state_key, + "k": args.k, + "n": int(ids.shape[0]), + "distance_metric": "cosine", + "source_bundle": str(bundle.relative_to(analysis_root.parent.parent)) + if analysis_root.is_absolute() is False + else str(bundle), + "built_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + (out_dir / "provenance.json").write_text(json.dumps(provenance, indent=2) + "\n") + n_done += 1 + print(f" {cell_dir.name:30s} N={ids.shape[0]} k={args.k} {time.time() - t0:.2f}s") + + print(f"done — {n_done} cells / {time.time() - total_start:.2f}s total") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/fetch_ui_inputs.sh b/scripts/fetch_ui_inputs.sh new file mode 100755 index 00000000..f4710048 --- /dev/null +++ b/scripts/fetch_ui_inputs.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# T007 / Stage 6 — fetch_ui_inputs.sh +# +# Materialize the data package the SvelteKit site consumes. The script has +# three modes (priority order): +# +# 1. OHBM2026_UI_DATA_PACKAGE_URL set → download + extract a pre-built +# `data/` tarball directly into `site/static/data/`. This is the +# production CI path: a local operator builds the package once via +# `scripts/build_ui_data.py`, places the tarball at a shared URL +# (Dropbox, S3, release artifact, …), and the deploy workflow consumes +# it. No Stage 1–4 inputs need to land in CI; `build_ui_data.py` is +# skipped downstream. Exit 0 + write a marker file. +# +# 2. Stage 1–4 inputs present in the working tree → exit 0 (local dev or +# a runner that mounts the inputs). Downstream `build_ui_data.py` +# builds the shards from scratch. +# +# 3. Neither → exit 2 with a clear error pointing operators at the docs. +# Downstream skips the data-package build; the site still ships with +# the build-info-only placeholder. +# +# Per CA-007, no state-key is hardcoded; the rollup state-key is discovered +# inside `build_ui_data.py` via `ohbm2026.ui_data.state_key`. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +UI_DATA_DIR="site/static/data" + +# --- Mode 1: download pre-built tarball --------------------------------- +if [[ -n "${OHBM2026_UI_DATA_PACKAGE_URL:-}" ]]; then + echo "fetch_ui_inputs.sh: downloading pre-built data package from \$OHBM2026_UI_DATA_PACKAGE_URL" + mkdir -p "$UI_DATA_DIR" + tmp=$(mktemp -d) + trap "rm -rf '$tmp'" EXIT + url="$OHBM2026_UI_DATA_PACKAGE_URL" + # Sanity check that we got binary content, not an HTML interstitial + # (Dropbox / Google Drive both wrap shared links in HTML now). If the + # downloaded file doesn't gzip-decompress we exit early so the workflow + # fails loudly instead of falling back to stale gh-pages data. + if ! curl -fsSL "$url" -o "$tmp/package.tar.gz"; then + echo "fetch_ui_inputs.sh: download failed from $url" >&2 + exit 3 + fi + if ! gzip -t "$tmp/package.tar.gz" 2>/dev/null; then + echo "fetch_ui_inputs.sh: downloaded file is not a valid gzip archive — the URL probably returned an HTML interstitial" >&2 + echo " first 200 bytes follow:" >&2 + head -c 200 "$tmp/package.tar.gz" >&2 + echo "" >&2 + exit 5 + fi + # Optional sha256 verify when OHBM2026_UI_DATA_PACKAGE_SHA256 is set. + if [[ -n "${OHBM2026_UI_DATA_PACKAGE_SHA256:-}" ]]; then + actual=$(shasum -a 256 "$tmp/package.tar.gz" | awk '{print $1}') + if [[ "$actual" != "$OHBM2026_UI_DATA_PACKAGE_SHA256" ]]; then + echo "fetch_ui_inputs.sh: sha256 mismatch — expected $OHBM2026_UI_DATA_PACKAGE_SHA256, got $actual" >&2 + exit 4 + fi + echo "fetch_ui_inputs.sh: sha256 verified: $actual" + fi + # Extract — the tarball contains a top-level `data/` directory; we want + # its contents to land directly under `site/static/data/`, so strip the + # leading component. + rm -rf "$UI_DATA_DIR" + mkdir -p "$UI_DATA_DIR" + tar -xzf "$tmp/package.tar.gz" -C "$UI_DATA_DIR" --strip-components=1 + echo "fetch_ui_inputs.sh: extracted $(find "$UI_DATA_DIR" -type f | wc -l | tr -d ' ') files into $UI_DATA_DIR" + # Marker file so the deploy workflow knows to skip the local build_ui_data.py step. + touch "$UI_DATA_DIR/.fetched-from-package" + exit 0 +fi + +# --- Mode 2: raw inputs already in the working tree --------------------- +REQUIRED_PATHS=( + "data/primary/abstracts.json" + "data/primary/abstracts_withdrawn.json" + "data/primary/authors.json" + "data/primary/abstracts_enriched.sqlite" + "data/outputs/analysis" +) + +missing=() +for path in "${REQUIRED_PATHS[@]}"; do + if [[ ! -e "$path" ]]; then + missing+=("$path") + fi +done + +if [[ ${#missing[@]} -eq 0 ]]; then + echo "fetch_ui_inputs.sh: Stage 1–4 inputs present locally; build_ui_data.py will run." + exit 0 +fi + +# --- Mode 3: neither ---------------------------------------------------- +echo "fetch_ui_inputs.sh: no data source available." >&2 +echo "" >&2 +echo "Required (mode 1 — package URL):" >&2 +echo " OHBM2026_UI_DATA_PACKAGE_URL=<url to ui-data-<state-key>.tar.gz>" >&2 +echo " Optional: OHBM2026_UI_DATA_PACKAGE_SHA256=<sha256 hex>" >&2 +echo "" >&2 +echo "OR (mode 2 — local inputs):" >&2 +for path in "${missing[@]}"; do + echo " $path (missing)" >&2 +done +echo "" >&2 +echo "Local dev: see specs/008-ui-rewrite/quickstart.md." >&2 +echo "CI: set OHBM2026_UI_DATA_PACKAGE_URL as a repo secret or workflow var." >&2 +exit 2 diff --git a/scripts/validate_ui_data.sh b/scripts/validate_ui_data.sh new file mode 100755 index 00000000..742aa620 --- /dev/null +++ b/scripts/validate_ui_data.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Validate every shard of a UI data package against the LinkML schema. +# +# Usage: +# scripts/validate_ui_data.sh [DATA_ROOT] +# +# DATA_ROOT defaults to `site/static/data/` — the locally-built package. +# Exits 0 if every shard validates, 1 otherwise. + +set -euo pipefail + +ROOT="${1:-site/static/data}" +SCH="specs/008-ui-rewrite/contracts/ui_data.linkml.yaml" +LINKML="${LINKML:-.venv/bin/linkml-validate}" + +if [[ ! -d "$ROOT" ]]; then + echo "no data dir at $ROOT" >&2 + exit 1 +fi +if [[ ! -x "$LINKML" ]]; then + echo "linkml-validate not found at $LINKML — install via 'uv pip install linkml'" >&2 + exit 1 +fi + +PASS=0 +FAIL=0 +declare -a FAILED=() + +check() { + local cls="$1" path="$2" + local out + if [[ ! -f "$path" ]]; then return; fi + out=$("$LINKML" --schema "$SCH" --target-class "$cls" "$path" 2>&1 | grep -E "ERROR|No issues" | head -1 || true) + if [[ "$out" == *"No issues found"* ]]; then + PASS=$((PASS+1)) + else + FAIL=$((FAIL+1)) + FAILED+=("$cls / $path: $out") + fi +} + +check Manifest "$ROOT/manifest.json" +check AbstractsShard "$ROOT/abstracts.json" +check AuthorsShard "$ROOT/authors.json" +check EnrichmentShard "$ROOT/enrichment.json" +check MinilmVectorsSidecar "$ROOT/search/minilm_vectors.build_info.json" +for f in "$ROOT"/cells/*.json; do check CellShard "$f"; done +for f in "$ROOT"/topics/*.json; do check TopicShard "$f"; done +for f in "$ROOT"/neighbors/*.json; do check NeighborsShard "$f"; done + +echo "passed: $PASS failed: $FAIL" +if [[ $FAIL -gt 0 ]]; then + printf ' FAIL: %s\n' "${FAILED[@]}" + exit 1 +fi diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 00000000..e4b9e03a --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,22 @@ +node_modules +.svelte-kit +build +package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Build output of the data-package builder — populated at deploy time per quickstart.md +static/data/ + +# Playwright +playwright-report +test-results + +# Editor / OS +.DS_Store +.idea +.vscode +*.log diff --git a/site/.prettierrc b/site/.prettierrc new file mode 100644 index 00000000..ff2677ef --- /dev/null +++ b/site/.prettierrc @@ -0,0 +1,6 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100 +} diff --git a/site/package.json b/site/package.json new file mode 100644 index 00000000..f52ac3d6 --- /dev/null +++ b/site/package.json @@ -0,0 +1,38 @@ +{ + "name": "ohbm2026-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test:unit": "vitest", + "test:e2e": "playwright test", + "test": "pnpm test:unit -- --run && pnpm test:e2e", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "@sveltejs/adapter-static": "^3.0.0", + "@sveltejs/kit": "^2.21.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/node": "^22.10.0", + "eslint": "^9.18.0", + "prettier": "^3.4.0", + "svelte": "^5.16.0", + "svelte-check": "^4.1.0", + "typescript": "^5.7.0", + "jsdom": "^25.0.0", + "vite": "^6.0.0", + "vitest": "^2.1.0" + }, + "dependencies": { + "@xenova/transformers": "^2.17.0", + "plotly.js-gl3d-dist-min": "^2.35.0", + "shepherd.js": "^14.0.0" + } +} diff --git a/site/playwright.config.ts b/site/playwright.config.ts new file mode 100644 index 00000000..93d7fc49 --- /dev/null +++ b/site/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: 'src/tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + webServer: { + // Re-use the existing build/ directory — callers are expected to run + // `pnpm build` with the right env (VITE_BUILD_SHA etc.) themselves. + // This avoids re-running the build in a subshell that has lost env vars. + command: 'pnpm preview --port 4173 --host 127.0.0.1', + port: 4173, + reuseExistingServer: !process.env.CI + }, + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + { + name: 'mobile', + use: { + ...devices['Pixel 5'], + viewport: { width: 360, height: 640 } + } + } + ] +}); diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml new file mode 100644 index 00000000..b7437354 --- /dev/null +++ b/site/pnpm-lock.yaml @@ -0,0 +1,3447 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@xenova/transformers': + specifier: ^2.17.0 + version: 2.17.2 + plotly.js-gl3d-dist-min: + specifier: ^2.35.0 + version: 2.35.3 + shepherd.js: + specifier: ^14.0.0 + version: 14.5.1 + devDependencies: + '@playwright/test': + specifier: ^1.50.0 + version: 1.60.0 + '@sveltejs/adapter-static': + specifier: ^3.0.0 + version: 3.0.10(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19))) + '@sveltejs/kit': + specifier: ^2.21.0 + version: 2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19)) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)) + '@types/node': + specifier: ^22.10.0 + version: 22.19.19 + eslint: + specifier: ^9.18.0 + version: 9.39.4 + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + prettier: + specifier: ^3.4.0 + version: 3.8.3 + svelte: + specifier: ^5.16.0 + version: 5.55.7 + svelte-check: + specifier: ^4.1.0 + version: 4.4.8(picomatch@4.0.4)(svelte@5.55.7)(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.2(@types/node@22.19.19) + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@huggingface/jinja@0.2.2': + resolution: {integrity: sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==} + engines: {node: '>=18'} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-static@3.0.10': + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.60.1': + resolution: {integrity: sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 || ^6.0.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + '@xenova/transformers@2.17.2': + resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.8.3: + resolution: {integrity: sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrap@2.2.9: + resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatbuffers@1.12.0: + resolution: {integrity: sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onnx-proto@4.0.4: + resolution: {integrity: sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==} + + onnxruntime-common@1.14.0: + resolution: {integrity: sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==} + + onnxruntime-node@1.14.0: + resolution: {integrity: sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==} + os: [win32, darwin, linux] + + onnxruntime-web@1.14.0: + resolution: {integrity: sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + + plotly.js-gl3d-dist-min@2.35.3: + resolution: {integrity: sha512-rEGAn7M/0C9q6bEEdG1X4teI6WjXCfJsqKizBpS4QUD70UsoYlhKipTJPuYSKJfnxREV6XYRydP+C5Ns6fwvrQ==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + protobufjs@6.11.6: + resolution: {integrity: sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==} + hasBin: true + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shepherd.js@14.5.1: + resolution: {integrity: sha512-VuvPvLG1QjNOLP7AIm2HGyfmxEIz8QdskvWOHwUcxLDibYWjLRBmCWd8LSL5FlwhBW7D/GU+3gNVC/ASxAWdxg==} + engines: {node: 18.* || >= 20} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + svelte-check@4.4.8: + resolution: {integrity: sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte@5.55.7: + resolution: {integrity: sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==} + engines: {node: '>=18'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@huggingface/jinja@0.2.2': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + + '@polka/url@1.0.0-next.29': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.1 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.1': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19)))': + dependencies: + '@sveltejs/kit': 2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19)) + + '@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.8.1 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.1.0 + sirv: 3.0.2 + svelte: 5.55.7 + vite: 6.4.2(@types/node@22.19.19) + optionalDependencies: + typescript: 5.9.3 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)) + debug: 4.4.3 + svelte: 5.55.7 + vite: 6.4.2(@types/node@22.19.19) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.55.7 + vite: 6.4.2(@types/node@22.19.19) + vitefu: 1.1.3(vite@6.4.2(@types/node@22.19.19)) + transitivePeerDependencies: + - supports-color + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/long@4.0.2': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/trusted-types@2.0.7': {} + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.19) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@xenova/transformers@2.17.2': + dependencies: + '@huggingface/jinja': 0.2.2 + onnxruntime-web: 1.14.0 + sharp: 0.32.6 + optionalDependencies: + onnxruntime-node: 1.14.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.1: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + axobject-query@4.1.0: {} + + b4a@1.8.1: {} + + balanced-match@1.0.2: {} + + bare-events@2.8.3: {} + + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.3 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.3) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.3): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.3 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.0 + + base64-js@1.5.1: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@1.1.4: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + cookie@0.6.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + devalue@5.8.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + esm-env@1.2.2: {} + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrap@2.2.9: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.3 + transitivePeerDependencies: + - bare-abort-controller + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatbuffers@1.12.0: {} + + flatted@3.4.2: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fs-constants@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + github-from-package@0.0.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + gopd@1.2.0: {} + + guid-typescript@1.0.9: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + is-arrayish@0.3.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + isexe@2.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-character@3.0.0: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + long@4.0.0: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-response@3.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + napi-build-utils@2.0.0: {} + + natural-compare@1.4.0: {} + + node-abi@3.92.0: + dependencies: + semver: 7.8.0 + + node-addon-api@6.1.0: {} + + nwsapi@2.2.23: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onnx-proto@4.0.4: + dependencies: + protobufjs: 6.11.6 + + onnxruntime-common@1.14.0: {} + + onnxruntime-node@1.14.0: + dependencies: + onnxruntime-common: 1.14.0 + optional: true + + onnxruntime-web@1.14.0: + dependencies: + flatbuffers: 1.12.0 + guid-typescript: 1.0.9 + long: 4.0.0 + onnx-proto: 4.0.4 + onnxruntime-common: 1.14.0 + platform: 1.3.6 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + platform@1.3.6: {} + + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + + plotly.js-gl3d-dist-min@2.35.3: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + protobufjs@6.11.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.1 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/long': 4.0.2 + '@types/node': 22.19.19 + long: 4.0.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + resolve-from@4.0.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.8.0: {} + + set-cookie-parser@3.1.0: {} + + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + node-addon-api: 6.1.0 + prebuild-install: 7.1.3 + semver: 7.8.0 + simple-get: 4.0.1 + tar-fs: 3.1.2 + tunnel-agent: 0.6.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shepherd.js@14.5.1: + dependencies: + '@floating-ui/dom': 1.7.6 + '@scarf/scarf': 1.4.0 + deepmerge-ts: 7.1.5 + + siginfo@2.0.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.7)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.55.7 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte@5.55.7: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.9 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + symbol-tree@3.2.4: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + totalist@3.0.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-node@2.1.9(@types/node@22.19.19): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.19): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.14 + rollup: 4.60.4 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vite@6.4.2(@types/node@22.19.19): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@6.4.2(@types/node@22.19.19)): + optionalDependencies: + vite: 6.4.2(@types/node@22.19.19) + + vitest@2.1.9(@types/node@22.19.19)(jsdom@25.0.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.19) + vite-node: 2.1.9(@types/node@22.19.19) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + ws@8.20.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yocto-queue@0.1.0: {} + + zimmerframe@1.1.4: {} diff --git a/site/src/app.css b/site/src/app.css new file mode 100644 index 00000000..f2230179 --- /dev/null +++ b/site/src/app.css @@ -0,0 +1,75 @@ +/* Global tokens — both themes. Components read these via var(--name). */ + +:root { + color-scheme: light; + --bg: #ffffff; + --bg-subtle: #fafafa; + --bg-sunken: #f4f4f4; + --bg-elevated: #ffffff; + --text: #222222; + --text-muted: #666666; + --text-faint: #999999; + --border: #e6e6e6; + --border-strong: #d0d0d0; + --accent: #2c5fa3; + --accent-text: #ffffff; + --accent-soft-bg: #eef3fa; + --accent-soft-text: #2c5fa3; + --success: #2a7; + --warning-bg: #fff8e1; + --warning-border: #f0d27a; + --warning-text: #a26000; + --danger: #b00; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + --chart-paper: #ffffff; + --chart-plot: #fafafa; +} + +:root[data-theme='dark'] { + color-scheme: dark; + --bg: #0f1419; + --bg-subtle: #161b22; + --bg-sunken: #1a2027; + --bg-elevated: #1a2027; + --text: #e8e8e8; + --text-muted: #a8b0b8; + --text-faint: #707880; + --border: #2a3138; + --border-strong: #3a4148; + --accent: #6ba0e0; + --accent-text: #0f1419; + --accent-soft-bg: #1d2c40; + --accent-soft-text: #9cc4f0; + --success: #5ad79f; + --warning-bg: #2b2418; + --warning-border: #5a4a20; + --warning-text: #e0c478; + --danger: #ff6b6b; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + --chart-paper: #0f1419; + --chart-plot: #161b22; +} + +html, +body { + background: var(--bg); + color: var(--text); + margin: 0; + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Arial, + sans-serif; +} + +a { + color: var(--accent); +} + +code { + background: var(--bg-sunken); + color: var(--text); +} diff --git a/site/src/app.d.ts b/site/src/app.d.ts new file mode 100644 index 00000000..743f07b2 --- /dev/null +++ b/site/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/site/src/app.html b/site/src/app.html new file mode 100644 index 00000000..96b68d29 --- /dev/null +++ b/site/src/app.html @@ -0,0 +1,37 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <script> + // Theme boot — runs before any rendering to avoid FOUC. + // Resolves `light` | `dark` | `auto` from localStorage and sets + // data-theme on <html> + the CSS color-scheme so native form + // controls + scrollbars adopt the right palette immediately. + (function () { + try { + var raw = localStorage.getItem('ohbm2026.ui.theme.v1'); + var choice = raw === 'light' || raw === 'dark' || raw === 'auto' ? raw : 'auto'; + var effective = + choice === 'auto' + ? window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + : choice; + document.documentElement.setAttribute('data-theme', effective); + document.documentElement.style.colorScheme = effective; + } catch (e) { + // localStorage might be unavailable; default to system pref. + document.documentElement.setAttribute( + 'data-theme', + window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + ); + } + })(); + </script> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/site/src/lib/cart_email.ts b/site/src/lib/cart_email.ts new file mode 100644 index 00000000..f9287c33 --- /dev/null +++ b/site/src/lib/cart_email.ts @@ -0,0 +1,120 @@ +/** + * Cart → email helpers (US5 / FR-015). + * + * `buildMailtoLink(items, options)` returns a `mailto:` URL whose subject / + * body are pre-populated with the user's saved abstract list. Bodies are + * truncated to MAX_BODY_CHARS so the URL stays below the 2000-character + * limit that some mail clients (Outlook, system handlers on Windows) impose + * on `mailto:` strings. Truncated bodies carry a "(more items not shown)" + * marker so the user knows to copy the full list from the cart drawer. + */ + +import type { AbstractRecord } from '$lib/shards'; + +/** Conservative mailto-URL length budget. RFC has no hard ceiling but + * Outlook caps around 2083 characters and Mac Mail tolerates ~2000. */ +export const MAX_MAILTO_LENGTH = 1900; + +const TRUNCATION_NOTICE = '\n\n…(more items not shown — open the full list at '; + +export interface CartEmailOptions { + /** Public site origin + path, used to embed permalinks per item. Trailing slash optional. */ + siteUrl: string; + /** Optional subject override. Defaults to "My OHBM 2026 abstract list". */ + subject?: string; +} + +function trimSlash(s: string): string { + return s.endsWith('/') ? s.slice(0, -1) : s; +} + +function permalinkFor(siteUrl: string, posterId: string): string { + return `${trimSlash(siteUrl)}/abstract/${encodeURIComponent(posterId)}/`; +} + +/** + * Render one cart item as a four-line block: + * + * 1. [M-AM-101] Title goes here, wrapped if it's long + * — Lead Author + * → Open: https://abstractatlas.brainkb.org/abstract/M-AM-101/ + * + * The `→ Open: <url>` line uses an arrow prefix + label so the URL reads + * unambiguously as "click here to view the abstract" inside any email + * client. Most clients auto-linkify a bare URL on its own line, which is + * why the URL ends the block. + */ +function renderItemLine( + record: AbstractRecord, + leadAuthor: string, + siteUrl: string, + index: number +): string { + const id = record.poster_id || `id ${record.abstract_id}`; + const url = record.poster_id ? permalinkFor(siteUrl, record.poster_id) : ''; + const lines: string[] = [`${index}. [${id}] ${record.title}`]; + if (leadAuthor) lines.push(` — ${leadAuthor}`); + if (url) lines.push(` → Open: ${url}`); + return lines.join('\n'); +} + +/** + * Build the mailto: URL for a cart of abstracts. + * + * @param items Records the user has saved (already filtered to those in cart). + * @param leadAuthorByAbstractId Maps abstract_id → first-author display string. + * Empty string if unknown. Caller computes this. + * @param options Site URL + optional subject override. + */ +export function buildMailtoLink( + items: AbstractRecord[], + leadAuthorByAbstractId: Map<number, string>, + options: CartEmailOptions +): string { + const subject = options.subject ?? 'My OHBM 2026 abstract list'; + const subjectPart = 'mailto:?subject=' + encodeURIComponent(subject) + '&body='; + const siteHome = trimSlash(options.siteUrl); + const truncationSuffix = + TRUNCATION_NOTICE + siteHome + ' )'; + const header = + `Saved abstracts from the OHBM 2026 Atlas (${items.length} item${items.length === 1 ? '' : 's'}).\n` + + `Each entry below has an "Open:" link that lands directly on its full-detail page.\n\n`; + const footer = `\n\n— Browse the rest at ${siteHome}/`; + const lines: string[] = []; + let included = 0; + let truncated = false; + for (const rec of items) { + const lead = leadAuthorByAbstractId.get(rec.abstract_id) ?? ''; + const line = renderItemLine(rec, lead, options.siteUrl, included + 1); + const tentativeBody = header + [...lines, line].join('\n\n') + truncationSuffix + footer; + const tentativeUrlLength = subjectPart.length + encodeURIComponent(tentativeBody).length; + if (tentativeUrlLength > MAX_MAILTO_LENGTH && included > 0) { + truncated = true; + break; + } + lines.push(line); + included += 1; + } + let body = header + lines.join('\n\n'); + if (truncated) body += truncationSuffix; + body += footer; + return subjectPart + encodeURIComponent(body); +} + +/** Plain-text rendering (for the clipboard fallback). */ +export function buildPlainTextList( + items: AbstractRecord[], + leadAuthorByAbstractId: Map<number, string>, + siteUrl: string +): string { + const siteHome = trimSlash(siteUrl); + const header = + `Saved abstracts from the OHBM 2026 Atlas (${items.length} item${items.length === 1 ? '' : 's'}).\n` + + `Each entry below has an "Open:" link that lands directly on its full-detail page.\n\n`; + const body = items + .map((rec, i) => + renderItemLine(rec, leadAuthorByAbstractId.get(rec.abstract_id) ?? '', siteUrl, i + 1) + ) + .join('\n\n'); + return header + body + `\n\n— Browse the rest at ${siteHome}/`; +} diff --git a/site/src/lib/components/BuildInfo.svelte b/site/src/lib/components/BuildInfo.svelte new file mode 100644 index 00000000..80d49c39 --- /dev/null +++ b/site/src/lib/components/BuildInfo.svelte @@ -0,0 +1,134 @@ +<script lang="ts"> + import type { BuildInfo } from '$lib/shards'; + + /** Build info from the live deploy workflow (VITE_BUILD_SHA env var). */ + export let deployBuildInfo: BuildInfo | null = null; + /** Build info from the data package's manifest.json (when the data SHA differs from the deploy SHA). */ + export let dataBuildInfo: BuildInfo | null = null; + + let expanded = false; + + function toggle() { + expanded = !expanded; + } + + $: deploySha = deployBuildInfo?.code_revision_short ?? ''; + $: dataSha = dataBuildInfo?.code_revision_short ?? ''; + $: shasDiffer = deploySha && dataSha && deploySha !== dataSha; + $: primary = deployBuildInfo ?? dataBuildInfo; +</script> + +<footer class="build-info" data-testid="build-info-footer"> + {#if primary} + <button + type="button" + on:click={toggle} + aria-expanded={expanded} + aria-controls="build-info-detail" + class="summary" + > + <span class="label">build</span> + <code data-testid="build-info-short-sha">{deploySha || dataSha}</code> + <!-- + Timestamp prefers the DEPLOY time — that's the version-of-this- + page time, baked at build and stable across the session. The + data package's timestamp shows up only when the SHAs differ + (so the disclosure has a job to do), and is labelled `data` so + it's distinguishable from the deploy time. Earlier versions + flipped the displayed timestamp from deploy → data when the + manifest loaded, which looked like the build time "rolled back". + --> + {#if deployBuildInfo} + <span class="sep">·</span> + <time datetime={deployBuildInfo.built_at}>{deployBuildInfo.built_at}</time> + {:else if dataBuildInfo} + <span class="sep">·</span> + <time datetime={dataBuildInfo.built_at}>{dataBuildInfo.built_at}</time> + {/if} + {#if shasDiffer} + <span class="sep">·</span> + <span class="label">data</span> + <code data-testid="build-info-data-sha">{dataSha}</code> + <time class="data-time" datetime={dataBuildInfo!.built_at}> + ({dataBuildInfo!.built_at}) + </time> + {/if} + {#if dataBuildInfo} + <span class="sep">·</span> + <span class="corpus" data-testid="build-info-corpus-state"> + corpus {dataBuildInfo.corpus_state_key} + </span> + {/if} + </button> + {#if expanded} + <dl id="build-info-detail" class="detail"> + {#if deployBuildInfo} + <dt>deploy code_revision</dt> + <dd><code>{deployBuildInfo.code_revision}</code></dd> + <dt>deploy built_at</dt> + <dd><code>{deployBuildInfo.built_at}</code></dd> + {/if} + {#if dataBuildInfo} + <dt>data code_revision</dt> + <dd><code>{dataBuildInfo.code_revision}</code></dd> + <dt>data corpus_state_key</dt> + <dd><code>{dataBuildInfo.corpus_state_key}</code></dd> + <dt>data stage4_rollup_state_key</dt> + <dd><code>{dataBuildInfo.stage4_rollup_state_key}</code></dd> + <dt>data built_at</dt> + <dd><code>{dataBuildInfo.built_at}</code></dd> + {/if} + </dl> + {/if} + {:else} + <span class="label" data-testid="build-info-missing">build info unavailable</span> + {/if} +</footer> + +<style> + .build-info { + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 0.75rem; + color: var(--text-muted); + padding: 0.5rem 1rem; + border-top: 1px solid var(--border); + background: var(--bg-subtle); + } + .summary { + all: unset; + cursor: pointer; + display: inline-flex; + gap: 0.4rem; + align-items: baseline; + flex-wrap: wrap; + } + .summary:hover code { + text-decoration: underline; + } + .label { + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-faint); + } + .sep { + color: var(--text-faint); + } + .data-time { + color: var(--text-faint); + font-size: 0.9em; + } + .detail { + margin: 0.5rem 0 0; + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.15rem 0.75rem; + } + .detail dt { + font-weight: 600; + color: var(--text-muted); + } + .detail dd { + margin: 0; + } +</style> diff --git a/site/src/lib/components/CartDrawer.svelte b/site/src/lib/components/CartDrawer.svelte new file mode 100644 index 00000000..2c4288d8 --- /dev/null +++ b/site/src/lib/components/CartDrawer.svelte @@ -0,0 +1,267 @@ +<script lang="ts"> + import { cartStore } from '$lib/stores/cart'; + import { focusedAbstract } from '$lib/stores/selection'; + import { buildMailtoLink, buildPlainTextList } from '$lib/cart_email'; + import type { AbstractRecord, AuthorRecord } from '$lib/shards'; + + export let open = false; + export let abstracts: AbstractRecord[] = []; + export let authorsById: Map<number, AuthorRecord> = new Map(); + + let clipboardStatus: 'idle' | 'copied' | 'error' = 'idle'; + + $: byPosterId = (() => { + const m = new Map<string, AbstractRecord>(); + for (const a of abstracts) if (a.poster_id) m.set(a.poster_id, a); + return m; + })(); + $: items = [...$cartStore] + .map((pid) => byPosterId.get(pid)) + .filter((r): r is AbstractRecord => r !== undefined); + $: leadAuthorByAbstractId = (() => { + const m = new Map<number, string>(); + for (const rec of items) { + const id = rec.author_ids[0]; + if (id === undefined) continue; + const name = authorsById.get(id)?.name; + if (name) m.set(rec.abstract_id, name); + } + return m; + })(); + $: siteUrl = typeof window !== 'undefined' ? window.location.origin + window.location.pathname.replace(/\/abstract\/.+$/, '') : ''; + + function close() { + open = false; + } + function emailList() { + if (items.length === 0) return; + const url = buildMailtoLink(items, leadAuthorByAbstractId, { siteUrl }); + window.location.href = url; + } + async function copyList() { + if (items.length === 0) return; + try { + const text = buildPlainTextList(items, leadAuthorByAbstractId, siteUrl); + await navigator.clipboard.writeText(text); + clipboardStatus = 'copied'; + setTimeout(() => (clipboardStatus = 'idle'), 2000); + } catch { + clipboardStatus = 'error'; + setTimeout(() => (clipboardStatus = 'idle'), 2000); + } + } + function openDetail(posterId: string) { + $focusedAbstract = posterId; + open = false; + } +</script> + +{#if open} + <div class="cart-backdrop" on:click={close} role="presentation"></div> + <aside class="cart-drawer" role="dialog" aria-label="Saved list" data-testid="cart-drawer"> + <header class="cart-header"> + <h2>Your list <span class="muted">({items.length})</span></h2> + <button type="button" class="close" on:click={close} aria-label="Close list">×</button> + </header> + + {#if items.length === 0} + <p class="empty"> + No saved abstracts yet. Click the cart icon on any result to save it for later. + </p> + {:else} + <ul class="items"> + {#each items as record (record.abstract_id)} + <li class="item"> + <button + type="button" + class="item-body" + on:click={() => openDetail(record.poster_id)} + data-testid="cart-item" + > + <span class="poster">{record.poster_id}</span> + <span class="title">{record.title}</span> + </button> + <button + type="button" + class="remove" + on:click={() => cartStore.remove(record.poster_id)} + aria-label="Remove from list" + title="Remove from list" + data-testid="cart-remove" + > + × + </button> + </li> + {/each} + </ul> + + <footer class="cart-footer"> + <button + type="button" + class="cart-action primary" + on:click={emailList} + data-testid="cart-email" + > + ✉ Email my list + </button> + <button + type="button" + class="cart-action secondary" + on:click={copyList} + data-testid="cart-copy" + > + {clipboardStatus === 'copied' ? '✓ Copied' : clipboardStatus === 'error' ? 'Copy failed' : '📋 Copy'} + </button> + <button + type="button" + class="cart-action danger" + on:click={() => cartStore.clear()} + data-testid="cart-clear" + > + Clear + </button> + </footer> + {/if} + </aside> +{/if} + +<style> + .cart-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + z-index: 100; + } + .cart-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(26rem, 100vw); + background: var(--bg-elevated); + border-left: 1px solid var(--border); + z-index: 101; + display: flex; + flex-direction: column; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.15); + } + .cart-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + } + .cart-header h2 { + margin: 0; + font-size: 1rem; + color: var(--text); + } + .cart-header .muted { + color: var(--text-faint); + font-weight: 400; + } + .close { + all: unset; + cursor: pointer; + font-size: 1.4rem; + color: var(--text-muted); + padding: 0 0.5rem; + } + .close:hover { + color: var(--text); + } + .empty { + padding: 1rem; + color: var(--text-muted); + font-style: italic; + } + .items { + list-style: none; + padding: 0.5rem 0.75rem; + margin: 0; + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.35rem; + } + .item { + display: flex; + align-items: stretch; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + } + .item-body { + all: unset; + cursor: pointer; + flex: 1; + padding: 0.5rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + } + .item-body:hover { + background: var(--bg-sunken); + } + .poster { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.78rem; + color: var(--accent); + font-weight: 600; + } + .title { + font-size: 0.88rem; + color: var(--text); + line-height: 1.3; + } + .remove { + all: unset; + cursor: pointer; + padding: 0 0.6rem; + color: var(--text-faint); + font-size: 1.2rem; + border-left: 1px solid var(--border); + display: flex; + align-items: center; + } + .remove:hover { + color: var(--text); + background: var(--bg-sunken); + } + .cart-footer { + display: flex; + gap: 0.4rem; + padding: 0.75rem 1rem; + border-top: 1px solid var(--border); + background: var(--bg-sunken); + } + .cart-action { + all: unset; + cursor: pointer; + flex: 1; + text-align: center; + padding: 0.5rem 0.6rem; + border-radius: 4px; + font-size: 0.85rem; + } + .cart-action.primary { + background: var(--accent); + color: var(--accent-text); + } + .cart-action.secondary { + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border); + } + .cart-action.danger { + background: var(--bg-elevated); + color: var(--text-muted); + border: 1px solid var(--border); + } + .cart-action:hover { + filter: brightness(1.05); + } +</style> diff --git a/site/src/lib/components/DetailPanel.svelte b/site/src/lib/components/DetailPanel.svelte new file mode 100644 index 00000000..8c6722ef --- /dev/null +++ b/site/src/lib/components/DetailPanel.svelte @@ -0,0 +1,1513 @@ +<script lang="ts"> + import { base } from '$app/paths'; + import { goto } from '$app/navigation'; + import { focusedAbstract, authorChips } from '$lib/stores/selection'; + import { cartStore } from '$lib/stores/cart'; + import { + loadAllCellsWithTopics, + loadAllNeighbors, + loadEnrichment, + type AbstractRecord, + type AuthorRecord, + type CellShard, + type EnrichmentRecord, + type EnrichmentShard, + type NeighborsShard, + type TopicShard + } from '$lib/shards'; + + export let abstract: AbstractRecord | null = null; + export let authorsById: Map<number, AuthorRecord>; + export let abstractsById: Map<number, AbstractRecord> = new Map(); + export let dismissable = true; + /** + * In the home page's inline detail pane there isn't room (or attention + * budget) for the full abstract — we show only authors (compact, no + * affiliations), cross-cell cluster membership, related abstracts, and + * AI-extracted claims. The permalink page passes `compact={false}` to + * get the full read-everything view. + */ + export let compact = false; + + let showAllAuthors = false; + // Section expansion: each body section starts collapsed; user opens what + // they want to read. Per-claim and per-figure cards also follow this + // open/closed pattern so the panel stays a scannable overview. + let openSections: Record<string, boolean> = {}; + let openClaims: Record<number, boolean> = {}; + let openFigures: Record<number, boolean> = {}; + + function toggleSection(key: string) { + openSections = { ...openSections, [key]: !openSections[key] }; + } + function toggleClaim(i: number) { + openClaims = { ...openClaims, [i]: !openClaims[i] }; + } + function toggleFigure(i: number) { + openFigures = { ...openFigures, [i]: !openFigures[i] }; + } + + // Reset per-section/per-card open state when the focused abstract changes + // so the next abstract starts in the same collapsed default. + let prevAbstractId: number | null = null; + $: if (abstract && abstract.abstract_id !== prevAbstractId) { + prevAbstractId = abstract.abstract_id; + openSections = {}; + openClaims = {}; + openFigures = {}; + } + + // --- Cross-cell cluster membership ----------------------------------- + // For every (model, input) cell, look up which community this abstract + // belongs to + the community's label. Lets the user compare what + // different embeddings consider "this abstract's neighbourhood". + let allCells: Map<string, { cell: CellShard; topics: TopicShard | null }> | null = null; + $: void (async () => { + if (allCells !== null) return; + allCells = await loadAllCellsWithTopics(); + })(); + type ClusterRow = { cellKey: string; communityId: number; label: string }; + $: clusterMemberships = (() => { + if (!abstract || !allCells) return [] as ClusterRow[]; + const rows: ClusterRow[] = []; + for (const [cellKey, { cell, topics }] of allCells) { + const row = cell.rows.find((r) => r.abstract_id === abstract!.abstract_id); + if (!row) continue; + const topicMap = new Map<number, string>(); + if (topics) { + for (const t of topics.topics) { + const label = t.title + ? t.title + : t.keywords?.length + ? t.keywords.slice(0, 3).join(', ') + : `cluster ${t.cluster_id}`; + topicMap.set(t.cluster_id, label); + } + } + rows.push({ + cellKey, + communityId: row.community_id, + label: topicMap.get(row.community_id) ?? `community ${row.community_id}` + }); + } + rows.sort((a, b) => a.cellKey.localeCompare(b.cellKey)); + return rows; + })(); + + // --- Stage 2.1 enrichment (claims + figure interpretations) ---------- + let enrichmentShard: EnrichmentShard | null = null; + let enrichmentLoaded = false; + $: void (async () => { + if (enrichmentLoaded) return; + const shard = await loadEnrichment(); + enrichmentShard = shard; + enrichmentLoaded = true; + })(); + $: enrichment = (() => { + if (!abstract || !enrichmentShard) return null; + const rec = enrichmentShard.records[String(abstract.abstract_id)]; + return (rec as EnrichmentRecord | undefined) ?? null; + })(); + $: claimsModelId = enrichmentShard?.ai_provenance.claims_model_id ?? null; + $: figuresModelId = enrichmentShard?.ai_provenance.figures_model_id ?? null; + + $: authorList = abstract + ? abstract.author_ids + .map((id) => authorsById.get(id)) + .filter((a): a is AuthorRecord => a !== undefined) + : []; + $: visibleAuthors = showAllAuthors ? authorList : authorList.slice(0, 6); + + function close() { + $focusedAbstract = null; + } + + function inCart(posterId: string): boolean { + return $cartStore.has(posterId); + } + + // --- Related abstracts: aggregated across ALL cells ----------------- + // The earlier single-cell view biased the "most similar" list toward the + // active (model, input) embedding. Aggregating over every cell surfaces + // abstracts that are consistently close (or distant) across multiple + // embeddings — a stronger signal of true topical similarity. Each row + // shows the min distance plus the count of cells in which the abstract + // appeared in the focused record's nearest/farthest 10. + let allNeighbors: Map<string, NeighborsShard> | null = null; + let neighborsLoading = false; + $: void (async () => { + if (allNeighbors !== null) return; + neighborsLoading = true; + allNeighbors = await loadAllNeighbors(); + neighborsLoading = false; + })(); + + type RelatedEntry = { + abstract: AbstractRecord; + minDistance: number; + meanDistance: number; + cellCount: number; + cellKeys: string[]; + }; + + function aggregateRelated( + shards: Map<string, NeighborsShard> | null, + focusedId: number | undefined, + kind: 'nearest' | 'farthest' + ): RelatedEntry[] { + if (!shards || focusedId === undefined) return []; + // abstract_id → { distances: [], cellKeys: [] } + const buckets = new Map<number, { distances: number[]; cellKeys: string[] }>(); + for (const [cellKey, shard] of shards) { + const row = shard.abstract_ids.indexOf(focusedId); + if (row < 0) continue; + const ids = kind === 'nearest' ? shard.nearest_ids[row] : shard.farthest_ids[row]; + const dist = + kind === 'nearest' ? shard.nearest_distances[row] : shard.farthest_distances[row]; + for (let i = 0; i < ids.length; i++) { + const aid = ids[i]; + const d = dist[i]; + let b = buckets.get(aid); + if (!b) { + b = { distances: [], cellKeys: [] }; + buckets.set(aid, b); + } + b.distances.push(d); + b.cellKeys.push(cellKey); + } + } + const out: RelatedEntry[] = []; + for (const [aid, b] of buckets) { + const rec = abstractsById.get(aid); + if (!rec) continue; + const minD = Math.min(...b.distances); + const meanD = b.distances.reduce((s, x) => s + x, 0) / b.distances.length; + out.push({ + abstract: rec, + minDistance: minD, + meanDistance: meanD, + cellCount: b.cellKeys.length, + cellKeys: b.cellKeys + }); + } + // "Closest 5" → sort by min-distance ascending; "Most different" by + // max-distance descending (negated min for "farthest" mode). + if (kind === 'nearest') { + out.sort( + (a, b) => a.minDistance - b.minDistance || b.cellCount - a.cellCount + ); + } else { + // For farthest, use mean for ranking — a single outlier cell shouldn't + // dominate; we want consistently distant abstracts at the top. + out.sort( + (a, b) => b.meanDistance - a.meanDistance || b.cellCount - a.cellCount + ); + } + return out; + } + + $: focusedId = abstract?.abstract_id; + $: nearest = aggregateRelated(allNeighbors, focusedId, 'nearest'); + $: farthest = aggregateRelated(allNeighbors, focusedId, 'farthest'); + + function focusRelated(posterId: string) { + if (posterId) $focusedAbstract = posterId; + } + + /** + * Add the author's name to the active author-chip set and (on the + * permalink page) navigate back to the home page so the result list + * shows the filter result. Non-destructive: chips coexist with the + * search query, facets, and lasso, and can be removed individually. + */ + async function searchByAuthor(name: string): Promise<void> { + if (!name) return; + authorChips.update((s) => { + if (s.has(name)) return s; + const next = new Set(s); + next.add(name); + return next; + }); + if (!compact) { + await goto(`${base}/`); + } else if (typeof window !== 'undefined') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + } + + function leadAuthor(record: AbstractRecord): string { + const id = record.author_ids[0]; + if (id === undefined) return ''; + return authorsById.get(id)?.name ?? ''; + } +</script> + +{#if abstract} + <aside class="detail" data-testid="detail-panel" data-poster-id={abstract.poster_id}> + <header class="detail-header"> + <div class="ids"> + <span + class="poster-id" + data-testid="detail-poster-id" + title="Program-assigned poster id" + > + {abstract.poster_id || `(no poster id)`} + </span> + <span class="accepted-for">{abstract.accepted_for}</span> + </div> + <div class="header-actions"> + {#if abstract.poster_id} + {@const headerInCart = $cartStore.has(abstract.poster_id)} + <button + type="button" + class="cart-icon detail-cart-icon" + class:in-cart={headerInCart} + on:click={() => + headerInCart + ? cartStore.remove(abstract.poster_id) + : cartStore.add(abstract.poster_id)} + aria-label={headerInCart ? 'Remove from your list' : 'Add to your list'} + aria-pressed={headerInCart ? 'true' : 'false'} + title={headerInCart ? 'In your list — click to remove' : 'Add to your list'} + data-testid={headerInCart ? 'detail-cart-remove' : 'detail-cart-add'} + > + {#if headerInCart} + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> + <circle cx="9" cy="21" r="1.2" /> + <circle cx="18" cy="21" r="1.2" /> + <path d="M2 3h2.5L5.5 7H21l-2 9H7L5.5 7 4.5 3H2zM7 9l1 5h11l1-5z" /> + </svg> + <span class="check-pip" aria-hidden="true">✓</span> + {:else} + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> + <circle cx="9" cy="21" r="1.2" /> + <circle cx="18" cy="21" r="1.2" /> + <path d="M2 3h2.5L5.5 7H21l-2 9H7L5.5 7" /> + </svg> + {/if} + </button> + {/if} + {#if abstract.poster_id && compact} + <a + class="permalink permalink-top" + href={`${base}/abstract/${abstract.poster_id}/`} + data-testid="detail-permalink" + title="Open the full-detail page for this abstract" + > + full details ↗ + </a> + {/if} + {#if dismissable} + <button + type="button" + class="close" + on:click={close} + aria-label="Close detail panel" + data-testid="detail-close" + > + × + </button> + {/if} + </div> + </header> + + <h1 class="detail-title" data-testid="detail-title">{abstract.title}</h1> + + <section class="authors" data-testid="detail-authors"> + <h2>Authors <span class="hint-inline">click to filter by author</span></h2> + {#if compact} + <p class="author-compact"> + {#each authorList as author, i (author.author_id)}<!-- + -->{#if i > 0}<span class="author-sep">, </span>{/if}<!-- + --><button + type="button" + class="author-link" + on:click={() => searchByAuthor(author.name)} + title={`Search abstracts by ${author.name}`} + data-testid="author-search" + >{author.name}</button><!-- + -->{/each} + </p> + {:else} + <ol class="author-list"> + {#each visibleAuthors as author (author.author_id)} + <li> + <button + type="button" + class="author-link" + on:click={() => searchByAuthor(author.name)} + title={`Search abstracts by ${author.name}`} + data-testid="author-search" + >{author.name}</button> + {#if author.affiliations[0]} + <span class="author-aff">— {author.affiliations[0]}</span> + {/if} + </li> + {/each} + </ol> + {#if authorList.length > 6} + <button + type="button" + class="link" + on:click={() => (showAllAuthors = !showAllAuthors)} + data-testid="detail-toggle-authors" + > + {showAllAuthors ? 'Show fewer' : `Show all ${authorList.length} authors`} + </button> + {/if} + {/if} + </section> + + + <!-- + Two-zone layout for the permalink view: submitter content on + the left, computed + AI insights on the right. The DOM has two + INDEPENDENT flex-column containers inside a 2-column CSS grid + so each column's items stack tightly without their row heights + being yoked to the other column's items. Compact mode (home + pane) renders a flat linear flow with no zones. + --> + <div class="detail-content" class:zoned={!compact}> + {#if !compact} + <!-- LEFT zone — submitter-authored content (verbatim) --> + <div class="zone zone-submitter" data-zone="submitter"> + <div class="zone-header"> + <span class="zone-title">From the submitter</span> + <span class="zone-sub">verbatim from the submission</span> + </div> + {@render bodyBlock()} + <!-- + FR-011: only Topics + Methods of the submission-form extras render + here. Other extra-question fields (study_type, population, + field_strength, processing_packages, …) are stored in `facets` for + filtering but MUST NOT surface in the detail panel. + --> + {@render topicsBlock()} + {@render methodsBlock()} + {@render referencesBlock()} + </div> + <!-- RIGHT zone — algorithmic + AI-derived --> + <div class="zone zone-computed" data-zone="computed"> + <div class="zone-header"> + <span class="zone-title">Computed insights</span> + <span class="zone-sub"> + algorithmic; <span class="ai-pill-inline">✨ AI</span> sections labelled + </span> + </div> + <!-- AI-derived sections first so the most distinctive + surfaces (extracted claims + figure interpretations) sit + at the top of the computed column; algorithmic context + (cluster membership + neighbour rails) follows below. --> + {@render claimsBlock()} + {@render figuresBlock()} + {@render clusterBlock()} + {@render relatedBlock()} + </div> + {:else} + <!-- Compact mode: linear flow, only the home-pane essentials. --> + {@render claimsBlock()} + {@render clusterBlock()} + {@render relatedBlock()} + {/if} + </div><!-- /.detail-content --> + + {#snippet bodyBlock()} + {#if !compact} + {#each [ ['introduction','Introduction'], ['methods','Methods'], ['results','Results'], ['conclusion','Conclusion'] ] as [skey, slabel] (skey)} + {@const sbody = abstract.sections[skey]} + {#if sbody} + <section class="section collapsible" data-testid={`section-${skey}`} data-zone="submitter"> + <button + type="button" + class="section-header" + on:click={() => toggleSection(skey)} + aria-expanded={!!openSections[skey]} + > + <span class="caret">{openSections[skey] ? '▾' : '▸'}</span> + <span class="section-label">{slabel}</span> + </button> + {#if openSections[skey]} + <p class="section-body">{sbody}</p> + {/if} + </section> + {/if} + {/each} + {/if} + {/snippet} + + {#snippet claimsBlock()} + {#if enrichment && enrichment.claims.length} + <section class="section collapsible" data-testid="section-claims" data-zone="computed"> + <button + type="button" + class="section-header" + on:click={() => toggleSection('claims')} + aria-expanded={!!openSections.claims} + > + <span class="caret">{openSections.claims ? '▾' : '▸'}</span> + <span class="section-label"> + Claims <span class="badge">{enrichment.claims.length}</span> + </span> + {#if claimsModelId} + <span class="ai-pill" title={`AI-extracted by ${claimsModelId}`}> + ✨ AI + </span> + {/if} + </button> + {#if openSections.claims} + <ul class="card-list"> + {#each enrichment.claims as claim, i (i)} + {@const isOpen = !!openClaims[i]} + <li class="card-item"> + <button + type="button" + class="card-header" + on:click={() => toggleClaim(i)} + aria-expanded={isOpen} + > + <span class="caret">{isOpen ? '▾' : '▸'}</span> + <span class="card-summary">{claim.claim}</span> + {#if claim.claim_type} + <span class="card-tag">{claim.claim_type}</span> + {/if} + </button> + {#if isOpen} + <div class="card-body"> + {#if claim.evidence} + <dl class="kv"> + <dt>Evidence</dt> + <dd>{claim.evidence}</dd> + {#if claim.evidence_eco_codes?.length} + <dt>ECO codes</dt> + <dd> + <code>{claim.evidence_eco_codes.join(', ')}</code> + </dd> + {/if} + {#if claim.source} + <dt>Source quote</dt> + <dd class="quote"> + “{claim.source}” + {#if claim.source_quote_verified} + <span class="verified" title="verified against the abstract">✓</span> + {/if} + </dd> + {/if} + </dl> + {/if} + </div> + {/if} + </li> + {/each} + </ul> + {/if} + </section> + {/if} + {/snippet} + + {#snippet figuresBlock()} + {#if !compact && enrichment && enrichment.figures.length} + <section class="section collapsible" data-testid="section-figures" data-zone="computed"> + <button + type="button" + class="section-header" + on:click={() => toggleSection('figures')} + aria-expanded={!!openSections.figures} + > + <span class="caret">{openSections.figures ? '▾' : '▸'}</span> + <span class="section-label"> + Figure interpretations <span class="badge">{enrichment.figures.length}</span> + </span> + {#if figuresModelId} + <span class="ai-pill" title={`AI-interpreted by ${figuresModelId}`}> + ✨ AI + </span> + {/if} + </button> + {#if openSections.figures} + <ul class="card-list"> + {#each enrichment.figures as fig, i (i)} + {@const isOpen = !!openFigures[i]} + <li class="card-item"> + <button + type="button" + class="card-header" + on:click={() => toggleFigure(i)} + aria-expanded={isOpen} + > + <span class="caret">{isOpen ? '▾' : '▸'}</span> + <span class="card-summary"> + {fig.question_name || `Figure ${i + 1}`} + </span> + </button> + {#if isOpen} + <div class="card-body"> + <p class="fig-interpretation">{fig.interpretation}</p> + <dl class="kv"> + {#if fig.keywords?.length} + <dt>Keywords</dt> + <dd> + <ul class="chips chips-sm"> + {#each fig.keywords as kw (kw)}<li>{kw}</li>{/each} + </ul> + </dd> + {/if} + {#if fig.ocr_text} + <dt>OCR text</dt> + <dd class="ocr"><code>{fig.ocr_text}</code></dd> + {/if} + {#if fig.model_quality_estimate} + <dt>Model quality</dt> + <dd>{fig.model_quality_estimate}</dd> + {/if} + </dl> + </div> + {/if} + </li> + {/each} + </ul> + {/if} + </section> + {/if} + {/snippet} + + {#snippet topicsBlock()} + {#if !compact && (abstract.topics.primary || abstract.topics.secondary)} + <section class="extra topics" data-testid="extra-topics" data-zone="submitter"> + <h2>Topics</h2> + <dl> + {#if abstract.topics.primary} + <dt>Primary</dt> + <dd> + {abstract.topics.primary}{#if abstract.topics.primary_subcategory} + <span class="muted"> / {abstract.topics.primary_subcategory}</span> + {/if} + </dd> + {/if} + {#if abstract.topics.secondary} + <dt>Secondary</dt> + <dd> + {abstract.topics.secondary}{#if abstract.topics.secondary_subcategory} + <span class="muted"> / {abstract.topics.secondary_subcategory}</span> + {/if} + </dd> + {/if} + </dl> + </section> + {/if} + {/snippet} + + {#snippet methodsBlock()} + {#if !compact && abstract.methods_checklist.length} + <section class="extra methods-checklist" data-testid="extra-methods" data-zone="submitter"> + <h2>Methods</h2> + <ul class="chips"> + {#each abstract.methods_checklist as m (m)} + <li>{m}</li> + {/each} + </ul> + </section> + {/if} + {/snippet} + + {#snippet clusterBlock()} + {#if clusterMemberships.length} + <section class="extra clusters" data-testid="extra-clusters" data-zone="computed"> + <h2>Cluster membership <span class="muted">— per (model × input)</span></h2> + <ul class="cluster-grid"> + {#each clusterMemberships as row (row.cellKey)} + <li class="cluster-row"> + <code class="cluster-cell">{row.cellKey}</code> + <span class="cluster-id">#{row.communityId}</span> + <span class="cluster-label" title={row.label}>{row.label}</span> + </li> + {/each} + </ul> + </section> + {/if} + {/snippet} + + {#snippet relatedBlock()} + {#if allNeighbors && (nearest.length || farthest.length)} + <section class="related" data-testid="detail-related" data-zone="computed"> + <h2> + Related abstracts + <span class="muted">— across all {allNeighbors.size} maps</span> + </h2> + {#if nearest.length} + <div class="related-block"> + <h3 class="related-heading"> + Most similar + <span class="hint">closest 5 shown; scroll for more</span> + </h3> + <ul class="related-list related-scroll" data-testid="related-nearest-list"> + {#each nearest as entry, i (entry.abstract.abstract_id)} + {@const inCartNow = $cartStore.has(entry.abstract.poster_id)} + <li> + <div class="related-link"> + <button + type="button" + class="related-body" + on:click={() => focusRelated(entry.abstract.poster_id)} + disabled={!entry.abstract.poster_id} + data-testid="related-nearest" + > + <span class="related-rank">#{i + 1}</span> + <span class="related-poster-pile"> + <span class="related-poster">{entry.abstract.poster_id || '—'}</span> + <span class="related-distance" title="min cosine distance across maps"> + d={entry.minDistance.toFixed(3)} + </span> + </span> + <span class="related-title">{entry.abstract.title}</span> + <span + class="related-cells" + title={`appears in ${entry.cellCount} of ${allNeighbors.size} maps: ${entry.cellKeys.join(', ')}`} + > + ×{entry.cellCount} + </span> + </button> + <button + type="button" + class="related-cart" + class:in-cart={inCartNow} + disabled={!entry.abstract.poster_id} + on:click={() => + inCartNow + ? cartStore.remove(entry.abstract.poster_id) + : cartStore.add(entry.abstract.poster_id)} + aria-label={inCartNow ? 'Remove from your list' : 'Add to your list'} + aria-pressed={inCartNow ? 'true' : 'false'} + title={inCartNow ? 'In your list — click to remove' : 'Add to your list'} + data-testid={inCartNow ? 'related-cart-remove' : 'related-cart-add'} + > + {#if inCartNow} + <svg + width="16" + height="16" + viewBox="0 0 24 24" + fill="currentColor" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + > + <circle cx="9" cy="21" r="1.2" /> + <circle cx="18" cy="21" r="1.2" /> + <path d="M2 3h2.5L5.5 7H21l-2 9H7L5.5 7 4.5 3H2zM7 9l1 5h11l1-5z" /> + </svg> + <span class="check-pip" aria-hidden="true">✓</span> + {:else} + <svg + width="16" + height="16" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + > + <circle cx="9" cy="21" r="1.2" /> + <circle cx="18" cy="21" r="1.2" /> + <path d="M2 3h2.5L5.5 7H21l-2 9H7L5.5 7" /> + </svg> + {/if} + </button> + </div> + </li> + {/each} + </ul> + </div> + {/if} + {#if farthest.length} + <div class="related-block"> + <h3 class="related-heading"> + Most different + <span class="hint">farthest 5 shown; scroll for more</span> + </h3> + <ul class="related-list related-scroll" data-testid="related-farthest-list"> + {#each farthest as entry, i (entry.abstract.abstract_id)} + {@const inCartFar = $cartStore.has(entry.abstract.poster_id)} + <li> + <div class="related-link"> + <button + type="button" + class="related-body" + on:click={() => focusRelated(entry.abstract.poster_id)} + disabled={!entry.abstract.poster_id} + data-testid="related-farthest" + > + <span class="related-rank">#{i + 1}</span> + <span class="related-poster-pile"> + <span class="related-poster">{entry.abstract.poster_id || '—'}</span> + <span class="related-distance" title="mean cosine distance across maps"> + d={entry.meanDistance.toFixed(3)} + </span> + </span> + <span class="related-title">{entry.abstract.title}</span> + <span + class="related-cells" + title={`appears in ${entry.cellCount} of ${allNeighbors.size} maps`} + > + ×{entry.cellCount} + </span> + </button> + <button + type="button" + class="related-cart" + class:in-cart={inCartFar} + disabled={!entry.abstract.poster_id} + on:click={() => + inCartFar + ? cartStore.remove(entry.abstract.poster_id) + : cartStore.add(entry.abstract.poster_id)} + aria-label={inCartFar ? 'Remove from your list' : 'Add to your list'} + aria-pressed={inCartFar ? 'true' : 'false'} + title={inCartFar ? 'In your list — click to remove' : 'Add to your list'} + data-testid={inCartFar ? 'related-cart-remove-far' : 'related-cart-add-far'} + > + {#if inCartFar} + <svg + width="16" + height="16" + viewBox="0 0 24 24" + fill="currentColor" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + > + <circle cx="9" cy="21" r="1.2" /> + <circle cx="18" cy="21" r="1.2" /> + <path d="M2 3h2.5L5.5 7H21l-2 9H7L5.5 7 4.5 3H2zM7 9l1 5h11l1-5z" /> + </svg> + <span class="check-pip" aria-hidden="true">✓</span> + {:else} + <svg + width="16" + height="16" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + > + <circle cx="9" cy="21" r="1.2" /> + <circle cx="18" cy="21" r="1.2" /> + <path d="M2 3h2.5L5.5 7H21l-2 9H7L5.5 7" /> + </svg> + {/if} + </button> + </div> + </li> + {/each} + </ul> + </div> + {/if} + </section> + {:else if neighborsLoading} + <section class="related" data-testid="detail-related-loading" data-zone="computed"> + <h2>Related abstracts</h2> + <p class="muted">Loading neighbors…</p> + </section> + {/if} + {/snippet} + + {#snippet referencesBlock()} + {#if !compact && (abstract.reference_urls.some(Boolean) || abstract.reference_dois.some(Boolean) || (abstract.reference_titles ?? []).some(Boolean))} + <section class="references" data-testid="detail-references" data-zone="submitter"> + <h2>References</h2> + <ol> + {#each abstract.reference_urls as url, i (url + i)} + {@const doi = abstract.reference_dois[i] || ''} + {@const title = (abstract.reference_titles ?? [])[i] || ''} + {@const linkUrl = url || (doi ? `https://doi.org/${doi}` : '')} + <li> + {#if linkUrl} + <a href={linkUrl} target="_blank" rel="noopener noreferrer"> + {title || doi || linkUrl} + </a> + {#if title && doi} + <span class="ref-doi" title="DOI">{doi}</span> + {/if} + {:else if title} + <span>{title}</span> + {/if} + </li> + {/each} + </ol> + </section> + {/if} + {/snippet} + + <footer class="detail-footer"> + {#if !compact} + <!-- Permalink page: the last thing on the panel is a way back + to the main atlas. (Cart action moved to the header next to + the poster id; the permalink button itself only renders in + compact / home-pane mode.) --> + <a class="back-to-atlas" href={`${base}/`} data-testid="detail-back-to-atlas"> + ← back to the atlas + </a> + {/if} + </footer> + </aside> +{/if} + +<style> + /* Two-zone layout. `.detail-content` is the post-header container. In + compact (home pane) mode + on mobile (< 980 px), sections stack + linearly. On landscape desktop the wrapper becomes a 2-column CSS + grid containing TWO independent flex-column zones (`.zone`); each + zone packs its own sections tightly, so the columns don't yoke + each other's row heights. */ + .detail-content { + display: flex; + flex-direction: column; + gap: 0.6rem; + } + .zone { + display: contents; /* mobile / compact: don't introduce extra boxes */ + } + .zone-header { + display: none; /* hidden on mobile / compact */ + } + @media (min-width: 980px) { + .detail-content.zoned { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 1.5rem; + align-items: start; + } + .detail-content.zoned > .zone { + display: flex; + flex-direction: column; + gap: 0.55rem; + min-width: 0; + align-self: start; + } + .detail-content.zoned > .zone-computed { + padding-left: 0.5rem; + border-left: 1px solid var(--border); + margin-left: -0.5rem; + } + .detail-content.zoned > .zone > .zone-header { + display: flex; + flex-direction: column; + gap: 0.1rem; + margin-bottom: 0.2rem; + padding-bottom: 0.4rem; + border-bottom: 1.5px solid var(--border-strong); + } + } + .zone-title { + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text); + } + .zone-sub { + font-size: 0.75rem; + color: var(--text-muted); + } + .ai-pill-inline { + display: inline-block; + font-size: 0.65rem; + font-weight: 600; + color: var(--accent-soft-text); + background: var(--accent-soft-bg); + padding: 0 0.4rem; + border-radius: 999px; + letter-spacing: 0.04em; + vertical-align: middle; + } + .author-link { + all: unset; + cursor: pointer; + color: var(--accent); + border-bottom: 1px dotted transparent; + } + .author-link:hover { + border-bottom-color: var(--accent); + } + .author-sep { + color: var(--text-faint); + } + .hint-inline { + font-size: 0.7rem; + text-transform: none; + letter-spacing: 0; + color: var(--text-faint); + font-weight: 400; + margin-left: 0.4rem; + } + + .detail { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; + } + .detail-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + } + .header-actions { + display: flex; + align-items: center; + gap: 0.6rem; + } + .permalink-top { + font-size: 0.78rem; + color: var(--accent); + text-decoration: none; + padding: 0.2rem 0.45rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-sunken); + white-space: nowrap; + } + .permalink-top:hover { + background: var(--accent-soft-bg); + text-decoration: none; + } + .author-compact { + margin: 0; + font-size: 0.85rem; + line-height: 1.45; + color: var(--text); + } + .ids { + display: flex; + gap: 0.5rem; + align-items: baseline; + } + .poster-id { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 700; + font-size: 1rem; + color: var(--accent); + } + .accepted-for { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + } + .close { + all: unset; + cursor: pointer; + font-size: 1.5rem; + color: var(--text-muted); + padding: 0 0.25rem; + } + .close:hover { + color: var(--text); + } + .detail-title { + font-size: 1.2rem; + margin: 0; + line-height: 1.3; + color: var(--text); + } + h2 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin: 0 0 0.25rem; + } + .section p, + .detail dd { + margin: 0; + font-size: 0.95rem; + line-height: 1.55; + color: var(--text); + } + .section-body { + white-space: pre-wrap; + padding: 0.4rem 0 0.2rem 1.3rem; + } + .collapsible { + border-top: 1px solid var(--border); + padding-top: 0.4rem; + } + .section-header { + all: unset; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + font-weight: 600; + } + .section-header:hover { + color: var(--text); + } + .section-header .caret { + font-size: 0.7rem; + color: var(--text-muted); + width: 0.7rem; + } + .section-label { + flex: 1; + } + .badge { + display: inline-block; + background: var(--bg-sunken); + color: var(--text-muted); + font-size: 0.7rem; + padding: 0.05rem 0.4rem; + border-radius: 999px; + margin-left: 0.3rem; + text-transform: none; + letter-spacing: 0; + font-weight: 500; + } + .ai-pill { + font-size: 0.65rem; + font-weight: 600; + color: var(--accent-soft-text); + background: var(--accent-soft-bg); + padding: 0.1rem 0.4rem; + border-radius: 999px; + letter-spacing: 0.04em; + text-transform: none; + flex-shrink: 0; + } + .card-list { + list-style: none; + padding: 0 0 0.25rem 1.3rem; + margin: 0.3rem 0 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + .card-item { + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-elevated); + } + .card-header { + all: unset; + cursor: pointer; + display: flex; + align-items: flex-start; + gap: 0.4rem; + padding: 0.4rem 0.55rem; + font-size: 0.85rem; + line-height: 1.35; + color: var(--text); + width: 100%; + box-sizing: border-box; + } + .card-header:hover { + background: var(--bg-sunken); + } + .card-header .caret { + font-size: 0.65rem; + color: var(--text-muted); + width: 0.7rem; + margin-top: 0.2rem; + } + .card-summary { + flex: 1; + min-width: 0; + word-break: break-word; + } + .card-tag { + font-size: 0.65rem; + text-transform: uppercase; + color: var(--text-muted); + background: var(--bg-sunken); + padding: 0.1rem 0.35rem; + border-radius: 3px; + letter-spacing: 0.04em; + flex-shrink: 0; + } + .card-body { + padding: 0 0.55rem 0.55rem 1.55rem; + font-size: 0.83rem; + color: var(--text); + } + .kv { + grid-template-columns: max-content 1fr; + gap: 0.2rem 0.6rem; + font-size: 0.82rem; + } + .kv dt { + color: var(--text-muted); + font-weight: 500; + } + .kv dd code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + color: var(--text); + background: var(--bg-sunken); + padding: 0 0.3rem; + border-radius: 3px; + } + .quote { + font-style: italic; + color: var(--text-muted); + } + .verified { + color: var(--success); + font-weight: 700; + margin-left: 0.2rem; + } + .chips-sm li { + font-size: 0.72rem; + padding: 0.1rem 0.4rem; + } + .fig-interpretation { + margin: 0 0 0.4rem; + font-size: 0.85rem; + line-height: 1.5; + color: var(--text); + } + .ocr code { + white-space: pre-wrap; + display: block; + padding: 0.3rem 0.5rem; + font-size: 0.75rem; + } + .author-list { + margin: 0; + padding-left: 1.25rem; + font-size: 0.9rem; + } + .author-aff { + color: var(--text-muted); + } + .link { + all: unset; + color: var(--accent); + cursor: pointer; + font-size: 0.85rem; + margin-top: 0.25rem; + } + .link:hover { + text-decoration: underline; + } + dl { + margin: 0; + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 0.75rem; + font-size: 0.9rem; + } + dt { + color: var(--text-muted); + font-weight: 500; + } + dd { + margin: 0; + } + .muted { + color: var(--text-faint); + } + .chips { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + .cluster-grid { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: 1fr; + gap: 0.2rem; + max-height: 14rem; + overflow-y: auto; + padding-right: 0.3rem; + } + .cluster-row { + display: grid; + grid-template-columns: minmax(8rem, max-content) 2.5rem 1fr; + gap: 0.5rem; + align-items: baseline; + padding: 0.25rem 0.4rem; + border-bottom: 1px solid var(--border); + font-size: 0.8rem; + } + .cluster-row:last-child { + border-bottom: none; + } + .cluster-cell { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + color: var(--accent); + background: var(--bg-sunken); + padding: 0 0.3rem; + border-radius: 3px; + } + .cluster-id { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + color: var(--text-faint); + } + .cluster-label { + color: var(--text); + min-width: 0; + word-break: break-word; + } + .chips li { + background: var(--accent-soft-bg); + color: var(--accent-soft-text); + padding: 0.2rem 0.5rem; + border-radius: 999px; + font-size: 0.8rem; + } + .references ol { + margin: 0; + padding-left: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.85rem; + } + .references a { + color: var(--accent); + word-break: normal; + } + .references a:hover { + text-decoration: underline; + } + .ref-doi { + color: var(--text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.78rem; + margin-left: 0.5rem; + } + .detail-footer { + display: flex; + gap: 0.75rem; + align-items: center; + justify-content: flex-start; + border-top: 1px solid var(--border); + padding-top: 0.5rem; + } + .back-to-atlas { + color: var(--accent); + text-decoration: none; + font-size: 0.9rem; + padding: 0.35rem 0.6rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg-sunken); + } + .back-to-atlas:hover { + background: var(--accent-soft-bg); + text-decoration: none; + } + .cart-icon.detail-cart-icon { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 4px; + color: var(--text-faint); + cursor: pointer; + } + .cart-icon.detail-cart-icon:hover { + background: var(--accent-soft-bg); + color: var(--accent); + } + .cart-icon.detail-cart-icon.in-cart { + color: var(--accent); + } + .cart-icon.detail-cart-icon .check-pip { + position: absolute; + bottom: 0px; + right: 0px; + background: var(--success); + color: var(--bg-elevated); + border-radius: 999px; + width: 0.9rem; + height: 0.9rem; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; + line-height: 1; + border: 1.5px solid var(--bg-elevated); + } + .permalink { + font-size: 0.85rem; + color: var(--text-muted); + text-decoration: none; + } + .permalink:hover { + color: var(--accent); + text-decoration: underline; + } + .related h2 { + display: flex; + gap: 0.4rem; + align-items: baseline; + } + .related h2 code { + font-size: 0.7rem; + text-transform: none; + letter-spacing: 0; + font-weight: 400; + } + .related-block { + margin-top: 0.4rem; + } + .related-heading { + margin: 0 0 0.25rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-faint); + font-weight: 600; + } + .related-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + .related-link { + display: flex; + align-items: stretch; + border-radius: 4px; + border: 1px solid transparent; + } + .related-link:hover { + background: var(--bg-sunken); + border-color: var(--border); + } + .related-body { + all: unset; + flex: 1; + min-width: 0; + cursor: pointer; + display: grid; + /* rank · (poster + distance stacked) · title (wraps, full) · cellCount */ + grid-template-columns: 2rem minmax(4.5rem, 6rem) 1fr auto; + gap: 0.5rem; + align-items: start; + padding: 0.4rem 0.5rem; + font-size: 0.85rem; + color: var(--text); + } + .related-cart { + all: unset; + cursor: pointer; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + flex-shrink: 0; + color: var(--text-faint); + border-left: 1px solid var(--border); + } + .related-cart:hover { + background: var(--accent-soft-bg); + color: var(--accent); + } + .related-cart.in-cart { + color: var(--accent); + } + .related-cart .check-pip { + position: absolute; + bottom: 0.2rem; + right: 0.2rem; + background: var(--success); + color: var(--bg-elevated); + border-radius: 999px; + width: 0.8rem; + height: 0.8rem; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.55rem; + font-weight: 700; + line-height: 1; + border: 1.2px solid var(--bg-elevated); + } + .related-cart:disabled { + opacity: 0.3; + cursor: not-allowed; + } + .related-poster-pile { + display: flex; + flex-direction: column; + gap: 0.1rem; + align-items: flex-start; + } + .related-scroll { + max-height: 14rem; /* ~5 rows; rest reachable by scroll */ + overflow-y: auto; + padding-right: 0.3rem; + } + .related-cells { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.7rem; + color: var(--text-faint); + cursor: help; + } + .related-heading .hint { + font-size: 0.7rem; + text-transform: none; + letter-spacing: 0; + color: var(--text-faint); + font-weight: 400; + margin-left: 0.4rem; + } + .related-body:disabled { + opacity: 0.4; + cursor: not-allowed; + } + .related-rank { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + color: var(--text-faint); + } + .related-poster { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + color: var(--accent); + font-weight: 600; + } + .related-title { + color: var(--text); + line-height: 1.3; + word-break: break-word; + min-width: 0; + } + .related-distance { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + color: var(--text-muted); + } +</style> diff --git a/site/src/lib/components/FacetSidebar.svelte b/site/src/lib/components/FacetSidebar.svelte new file mode 100644 index 00000000..698bf986 --- /dev/null +++ b/site/src/lib/components/FacetSidebar.svelte @@ -0,0 +1,223 @@ +<script lang="ts"> + import { activeFilters } from '$lib/stores/selection'; + import { + FACET_KEYS_ORDERED, + FACET_LABELS, + clearAllFilters, + toggleFilter, + type FacetCounts, + type FacetKey + } from '$lib/facets'; + + export let counts: FacetCounts; + /** + * Facets that start closed. Default: everything EXCEPT `cluster` — the + * sidebar shows ~13 facet sections and that's a lot of vertical noise + * if every one is open. The cluster facet stays open because it's the + * most navigationally useful (per the active UMAP cell) and because it + * comes first in the order, so collapsing it would just feel empty. + */ + export let collapsedByDefault: FacetKey[] = [ + 'topic', + 'subcategory', + 'methods', + 'study_type', + 'population', + 'field_strength', + 'processing_packages', + 'species', + 'recording_technology', + 'brain_regions', + 'brain_networks', + 'keywords', + 'accepted_for' + ]; + + /** When a facet has more than this many options, the list becomes + * vertically scrollable (capped via `.options.scroll`). Below the + * threshold there's nothing to scroll, so we render the bare list. */ + const SCROLL_THRESHOLD = 5; + + let expanded: Record<string, boolean> = {}; + $: for (const key of FACET_KEYS_ORDERED) { + if (!(key in expanded)) expanded[key] = !collapsedByDefault.includes(key); + } + + function toggle(key: FacetKey, option: string) { + $activeFilters = toggleFilter($activeFilters, key, option); + } + + function clear() { + $activeFilters = clearAllFilters(); + } + + function isActive(key: FacetKey, option: string): boolean { + return $activeFilters.get(key)?.has(option) ?? false; + } + + $: activeCount = [...$activeFilters.values()].reduce((sum, s) => sum + s.size, 0); +</script> + +<aside class="facets" data-testid="facet-sidebar"> + <header> + <h2>Filters</h2> + {#if activeCount > 0} + <button type="button" class="clear" on:click={clear} data-testid="facets-clear"> + Clear ({activeCount}) + </button> + {/if} + </header> + + {#each FACET_KEYS_ORDERED as key (key)} + {@const options = counts.get(key) ?? []} + {@const isOpen = expanded[key]} + {@const useScroll = options.length > SCROLL_THRESHOLD} + {#if options.length} + <section class="facet" data-testid={`facet-${key}`}> + <button + type="button" + class="facet-header" + on:click={() => (expanded[key] = !isOpen)} + aria-expanded={isOpen} + > + <span class="caret">{isOpen ? '▾' : '▸'}</span> + <span class="facet-label">{FACET_LABELS[key]}</span> + <span class="facet-count">{options.length}</span> + </button> + {#if isOpen} + <ul class="options" class:scroll={useScroll}> + {#each options as opt (opt.value)} + <li> + <label + class="opt" + class:active={isActive(key, opt.value)} + data-testid={`facet-option-${key}`} + > + <input + type="checkbox" + checked={isActive(key, opt.value)} + on:change={() => toggle(key, opt.value)} + /> + <span class="opt-label" title={opt.value}>{opt.value}</span> + <span class="opt-count">{opt.count}</span> + </label> + </li> + {/each} + </ul> + {/if} + </section> + {/if} + {/each} +</aside> + +<style> + .facets { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--text); + } + header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); + } + h2 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + } + .clear { + all: unset; + cursor: pointer; + font-size: 0.75rem; + color: var(--accent); + padding: 0.2rem 0.5rem; + border-radius: 3px; + border: 1px solid var(--border); + } + .clear:hover { + background: var(--bg-sunken); + } + .facet { + display: flex; + flex-direction: column; + } + .facet-header { + all: unset; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0; + font-weight: 500; + color: var(--text); + } + .facet-header:hover { + color: var(--accent); + } + .caret { + font-size: 0.65rem; + color: var(--text-muted); + width: 0.7rem; + } + .facet-label { + flex: 1; + font-size: 0.85rem; + } + .facet-count { + font-size: 0.7rem; + color: var(--text-faint); + } + .options { + list-style: none; + padding: 0 0 0.25rem 1.1rem; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; + } + .options.scroll { + max-height: 12rem; + overflow-y: auto; + padding-right: 0.4rem; + } + .opt { + display: flex; + align-items: flex-start; + gap: 0.4rem; + padding: 0.15rem 0.35rem; + border-radius: 3px; + cursor: pointer; + font-size: 0.8rem; + } + .opt input[type='checkbox'] { + margin-top: 0.18rem; /* line up with the wrapped label's first line */ + } + .opt:hover { + background: var(--bg-sunken); + } + .opt.active { + background: var(--accent-soft-bg); + color: var(--accent-soft-text); + } + .opt input[type='checkbox'] { + margin: 0; + } + .opt-label { + flex: 1; + min-width: 0; + line-height: 1.25; + word-break: break-word; /* topic names like "Functional Connectivity (Resting-State)" wrap cleanly */ + } + .opt-count { + font-size: 0.72rem; + color: var(--text-faint); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + } +</style> diff --git a/site/src/lib/components/ModelSelector.svelte b/site/src/lib/components/ModelSelector.svelte new file mode 100644 index 00000000..be5c810b --- /dev/null +++ b/site/src/lib/components/ModelSelector.svelte @@ -0,0 +1,98 @@ +<script lang="ts"> + import { selectedCell } from '$lib/stores/selection'; + import type { Manifest } from '$lib/shards'; + + export let manifest: Manifest | null = null; + + const labels: Record<string, string> = { + neuroscape: 'NeuroScape', + voyage: 'Voyage', + minilm: 'MiniLM', + openai: 'OpenAI', + pubmedbert: 'PubMedBERT', + abstract: 'Abstract', + claims: 'Claims', + methods: 'Methods' + }; + + function label(key: string): string { + return labels[key] ?? key; + } +</script> + +<div class="model-selector" data-testid="model-selector"> + <label> + <span class="caption">Model</span> + <select bind:value={$selectedCell.model} data-testid="model-selector-model" disabled={!manifest}> + {#if manifest} + {#each manifest.models as m (m)} + <option value={m}>{label(m)}</option> + {/each} + {/if} + </select> + </label> + <span class="sep">×</span> + <label> + <span class="caption">Input</span> + <select bind:value={$selectedCell.input} data-testid="model-selector-input" disabled={!manifest}> + {#if manifest} + {#each manifest.inputs as i (i)} + <option value={i}>{label(i)}</option> + {/each} + {/if} + </select> + </label> +</div> + +<style> + .model-selector { + display: flex; + align-items: flex-end; + gap: 0.4rem; + font-size: 0.85rem; + } + label { + display: flex; + flex-direction: column; + gap: 0.15rem; + } + .caption { + color: var(--text-muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } + select { + font-size: 0.85rem; + padding: 0.3rem 0.5rem; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--bg); + color: var(--text); + min-width: 6rem; + max-width: 100%; + } + @media (max-width: 480px) { + .model-selector { + gap: 0.3rem; + font-size: 0.78rem; + } + select { + min-width: 4.5rem; + padding: 0.3rem 0.35rem; + font-size: 0.8rem; + } + .caption { + font-size: 0.65rem; + } + } + select:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; + border-color: var(--accent); + } + .sep { + color: var(--text-faint); + padding-bottom: 0.45rem; + } +</style> diff --git a/site/src/lib/components/ResultList.svelte b/site/src/lib/components/ResultList.svelte new file mode 100644 index 00000000..3e66859a --- /dev/null +++ b/site/src/lib/components/ResultList.svelte @@ -0,0 +1,427 @@ +<script lang="ts"> + import { focusedAbstract, authorChips } from '$lib/stores/selection'; + import { cartStore } from '$lib/stores/cart'; + import type { AbstractRecord, AuthorRecord } from '$lib/shards'; + + export let abstracts: AbstractRecord[]; + export let authorsById: Map<number, AuthorRecord>; + /** + * If set, only abstracts whose `abstract_id` is in this set are rendered. + * `null` means "show all" (no filter). + */ + export let filteredIds: Set<number> | null = null; + /** When semantic search is active, a map `abstract_id → cosine similarity` so + * we can show a per-card score badge and sort by score. */ + export let semanticScores: Map<number, number> | null = null; + /** When lexical search is active, a map `abstract_id → number of EXACT + * query-token matches`. Used as the primary sort key so an abstract that + * literally contains the user's query (e.g. "pydra") surfaces above + * fuzzy/proximal matches (hydra, pydry, etc.). */ + export let lexicalExactness: Map<number, number> | null = null; + /** Per-abstract default rank — applied only when no search/semantic + * ranking is active. The parent shuffles abstract_ids on every page load + * so the home grid surfaces a different sample each visit. The semantic + * worker still indexes by the original positional order in + * `abstracts.json`, so we MUST NOT shuffle the underlying array. */ + export let defaultRank: Map<number, number> | null = null; + /** Truncate to this many cards to keep DOM size bounded; "Show more" appends. */ + export let initialWindow = 60; + + let revealed = initialWindow; + + $: visible = (() => { + const matched = abstracts.filter((a) => filteredIds === null || filteredIds.has(a.abstract_id)); + const hasExactness = lexicalExactness !== null && lexicalExactness.size > 0; + const hasSemantic = semanticScores !== null && semanticScores.size > 0; + if (!hasExactness && !hasSemantic) { + // No search ranking — apply the random default order if provided. + if (!defaultRank) return matched; + return matched + .slice() + .sort((a, b) => (defaultRank!.get(a.abstract_id) ?? 0) - (defaultRank!.get(b.abstract_id) ?? 0)); + } + // Sort key: primary = lexical exactness (higher first), secondary = + // semantic score (higher first). Exactness wins because the user's + // expectation when typing a rare technical term ("pydra") is that the + // actual abstract containing the word lands at the top, not buried + // among fuzzy proximal matches. + return matched.slice().sort((a, b) => { + const ea = (hasExactness ? lexicalExactness!.get(a.abstract_id) : undefined) ?? 0; + const eb = (hasExactness ? lexicalExactness!.get(b.abstract_id) : undefined) ?? 0; + if (ea !== eb) return eb - ea; + const sa = hasSemantic ? semanticScores!.get(a.abstract_id) : undefined; + const sb = hasSemantic ? semanticScores!.get(b.abstract_id) : undefined; + if (sa === undefined && sb === undefined) return 0; + if (sa === undefined) return 1; + if (sb === undefined) return -1; + return sb - sa; + }); + })(); + $: pageItems = visible.slice(0, revealed); + + function loadMore() { + revealed = Math.min(revealed + initialWindow, visible.length); + } + + function focus(posterId: string) { + $focusedAbstract = posterId; + } + + function leadAuthor(record: AbstractRecord): string { + const id = record.author_ids[0]; + if (id === undefined) return ''; + return authorsById.get(id)?.name ?? ''; + } + + function addAuthorChip(name: string) { + if (!name) return; + authorChips.update((s) => { + if (s.has(name)) return s; + const next = new Set(s); + next.add(name); + return next; + }); + } + + // Visible poster_ids in the current filter/result state, used by the + // bulk add control. The control is ADD-ONLY now — "Remove N from list" + // was a foot-gun in Saved-only mode (one click wipes the cart) and + // adding/removing in bulk should only ever grow the cart. Use the + // drawer's per-item × or the "Clear" footer button to remove. + $: visiblePosterIds = visible.map((r) => r.poster_id).filter(Boolean); + $: missingFromCart = visiblePosterIds.filter((id) => !$cartStore.has(id)); + + function addAllVisible() { + if (missingFromCart.length > 0) cartStore.addMany(missingFromCart); + } +</script> + +<section class="results" data-testid="result-list"> + <header class="results-header"> + <span data-testid="result-count">{visible.length}</span> abstract{visible.length === 1 ? '' : 's'} + {#if filteredIds !== null} + <span class="muted">(filtered)</span> + {/if} + {#if missingFromCart.length > 0} + <button + type="button" + class="bulk-action" + on:click={addAllVisible} + title={`Add the ${missingFromCart.length} visible abstract${missingFromCart.length === 1 ? '' : 's'} not yet in your list`} + data-testid="bulk-cart-add" + > + + Add {missingFromCart.length} to list + </button> + {/if} + </header> + + {#if visible.length === 0} + <p class="empty">No abstracts match the current search.</p> + {:else} + <ul class="cards"> + {#each pageItems as record (record.abstract_id)} + {@const lead = leadAuthor(record)} + <li class="card" class:focused={$focusedAbstract === record.poster_id}> + <!-- The card-body used to be a single <button>, which prevented + nesting a real <button> for the author click. Switched to a + role="button" div + keyboard handler so the meta row can host + a separate author button. --> + <div + class="card-body" + role="button" + tabindex="0" + on:click={() => focus(record.poster_id)} + on:keydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + focus(record.poster_id); + } + }} + data-testid="result-card" + data-poster-id={record.poster_id} + > + <div class="card-top"> + <span class="poster-id">{record.poster_id || `id ${record.abstract_id}`}</span> + {#if semanticScores && semanticScores.has(record.abstract_id)} + <span + class="semantic-badge" + title={`cosine similarity ${semanticScores.get(record.abstract_id)?.toFixed(3)} (distance ${(1 - (semanticScores.get(record.abstract_id) ?? 0)).toFixed(3)})`} + data-testid="semantic-score" + > + ✨ d={(1 - (semanticScores.get(record.abstract_id) ?? 0)).toFixed(3)} + </span> + {/if} + </div> + <div class="title">{record.title}</div> + <div class="lead-author"> + {#if lead} + <button + type="button" + class="lead-author-link" + on:click|stopPropagation={() => addAuthorChip(lead)} + title={`Filter by ${lead}`} + data-testid="card-author-search" + >{lead}</button> + {/if} + {#if record.topics.primary} + <span class="sep">·</span><span class="topic">{record.topics.primary}</span> + {/if} + </div> + </div> + <div class="card-actions"> + {#if $cartStore.has(record.poster_id)} + <button + type="button" + class="cart-icon in-cart" + on:click={() => cartStore.remove(record.poster_id)} + aria-label="Remove from your list" + aria-pressed="true" + title="In your list — click to remove" + data-testid="card-cart-remove" + > + <!-- filled cart with checkmark indicates "in your list" --> + <svg + width="20" + height="20" + viewBox="0 0 24 24" + fill="currentColor" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + > + <circle cx="9" cy="21" r="1.2" /> + <circle cx="18" cy="21" r="1.2" /> + <path + fill="currentColor" + stroke="currentColor" + d="M2 3h2.5L5.5 7H21l-2 9H7L5.5 7 4.5 3H2zM7 9l1 5h11l1-5z" + /> + </svg> + <span class="check-pip" aria-hidden="true">✓</span> + </button> + {:else} + <button + type="button" + class="cart-icon" + on:click={() => cartStore.add(record.poster_id)} + disabled={!record.poster_id} + aria-label="Add to your list" + aria-pressed="false" + title="Add to your list" + data-testid="card-cart-add" + > + <!-- outlined cart indicates "not in list" --> + <svg + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + > + <circle cx="9" cy="21" r="1.2" /> + <circle cx="18" cy="21" r="1.2" /> + <path d="M2 3h2.5L5.5 7H21l-2 9H7L5.5 7" /> + </svg> + </button> + {/if} + </div> + </li> + {/each} + </ul> + {#if pageItems.length < visible.length} + <button type="button" class="load-more" on:click={loadMore} data-testid="load-more"> + Show {Math.min(initialWindow, visible.length - pageItems.length)} more + </button> + {/if} + {/if} +</section> + +<style> + .results { + min-width: 0; + } + .results-header { + font-size: 0.85rem; + color: var(--text-muted); + margin: 0 0 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + .results-header .muted { + color: var(--text-faint); + } + .cards { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; + } + .card { + display: flex; + align-items: stretch; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-elevated); + overflow: hidden; + } + .card.focused { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + } + .card-body { + flex: 1; + cursor: pointer; + padding: 0.6rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + } + .card-body:hover { + background: var(--bg-sunken); + } + .card-body:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + } + .lead-author-link { + all: unset; + cursor: pointer; + color: var(--text-muted); + border-bottom: 1px dotted transparent; + } + .lead-author-link:hover { + color: var(--accent); + border-bottom-color: var(--accent); + } + .card-top { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; + } + .poster-id { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + color: var(--accent); + font-weight: 600; + } + .semantic-badge { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + color: var(--text-muted); + background: var(--accent-soft-bg); + padding: 0.1rem 0.4rem; + border-radius: 999px; + flex-shrink: 0; + } + .title { + font-size: 0.95rem; + font-weight: 500; + line-height: 1.3; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .lead-author { + font-size: 0.8rem; + color: var(--text-muted); + } + .sep { + color: var(--text-faint); + margin: 0 0.25rem; + } + .topic { + color: var(--text-muted); + } + .card-actions { + display: flex; + align-items: center; + padding: 0 0.5rem; + border-left: 1px solid var(--border); + } + .cart-icon { + all: unset; + cursor: pointer; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.1rem; + height: 2.1rem; + border-radius: 4px; + color: var(--text-faint); + } + .cart-icon:hover { + background: var(--accent-soft-bg); + color: var(--accent); + } + .cart-icon.in-cart { + color: var(--accent); + } + .cart-icon.in-cart:hover { + color: var(--warning-text, var(--accent)); + } + .cart-icon .check-pip { + position: absolute; + bottom: -2px; + right: -2px; + background: var(--success); + color: var(--bg-elevated); + border-radius: 999px; + width: 0.9rem; + height: 0.9rem; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; + line-height: 1; + border: 1.5px solid var(--bg-elevated); + } + .cart-icon:disabled { + opacity: 0.4; + cursor: not-allowed; + } + .bulk-action { + all: unset; + cursor: pointer; + margin-left: auto; + padding: 0.25rem 0.6rem; + font-size: 0.72rem; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--accent); + background: var(--bg-elevated); + } + .bulk-action:hover { + background: var(--accent-soft-bg); + } + .load-more { + margin-top: 0.5rem; + align-self: center; + padding: 0.5rem 1rem; + border: 1px solid var(--border-strong); + background: var(--bg); + color: var(--text); + border-radius: 4px; + cursor: pointer; + } + .empty { + color: var(--text-muted); + font-style: italic; + } +</style> diff --git a/site/src/lib/components/SearchBar.svelte b/site/src/lib/components/SearchBar.svelte new file mode 100644 index 00000000..78d01ad7 --- /dev/null +++ b/site/src/lib/components/SearchBar.svelte @@ -0,0 +1,79 @@ +<script lang="ts"> + import { searchQuery } from '$lib/stores/selection'; + + let value = ''; + $: $searchQuery = value; + + function onClear() { + value = ''; + } +</script> + +<div class="searchbar" role="search"> + <label for="search-input" class="visually-hidden">Search abstracts</label> + <input + id="search-input" + type="search" + bind:value + placeholder="Search keyword, author, topic, poster id… (typos OK)" + autocomplete="off" + spellcheck="false" + data-testid="search-input" + /> + {#if value} + <button type="button" on:click={onClear} aria-label="Clear search" data-testid="search-clear"> + × + </button> + {/if} +</div> + +<style> + .searchbar { + position: relative; + display: flex; + align-items: center; + width: 100%; + } + input[type='search'] { + width: 100%; + padding: 0.6rem 2.5rem 0.6rem 0.8rem; + font-size: 1rem; + border: 1px solid var(--border-strong); + border-radius: 6px; + box-sizing: border-box; + background: var(--bg); + color: var(--text); + } + input[type='search']:focus { + outline: 2px solid var(--accent); + outline-offset: 0; + border-color: var(--accent); + } + button { + position: absolute; + right: 0.4rem; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: 0; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + color: var(--text-muted); + padding: 0.2rem 0.4rem; + } + button:hover { + color: var(--text); + } + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +</style> diff --git a/site/src/lib/components/ThemeToggle.svelte b/site/src/lib/components/ThemeToggle.svelte new file mode 100644 index 00000000..29a097b5 --- /dev/null +++ b/site/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,59 @@ +<script lang="ts"> + import { themeChoice, effectiveTheme } from '$lib/stores/theme'; + + $: choice = $themeChoice; + $: effective = $effectiveTheme; + + $: label = + choice === 'auto' + ? `Theme: auto (currently ${effective})` + : choice === 'dark' + ? 'Theme: dark' + : 'Theme: light'; + + $: icon = choice === 'light' ? '☀' : choice === 'dark' ? '☾' : '◐'; + + function handleClick() { + themeChoice.cycle(); + } +</script> + +<button + type="button" + class="theme-toggle" + on:click={handleClick} + aria-label={label} + title={label} + data-testid="theme-toggle" + data-theme-choice={choice} +> + <span class="icon" aria-hidden="true">{icon}</span> + <span class="label-text">{choice}</span> +</button> + +<style> + .theme-toggle { + all: unset; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.7rem; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font-size: 0.85rem; + line-height: 1; + } + .theme-toggle:hover { + background: var(--bg-sunken); + } + .icon { + font-size: 1rem; + } + .label-text { + text-transform: capitalize; + color: var(--text-muted); + } +</style> diff --git a/site/src/lib/components/Tour.svelte b/site/src/lib/components/Tour.svelte new file mode 100644 index 00000000..d191b0ff --- /dev/null +++ b/site/src/lib/components/Tour.svelte @@ -0,0 +1,320 @@ +<script lang="ts"> + import { onDestroy } from 'svelte'; + import { page } from '$app/stores'; + import { focusedAbstract } from '$lib/stores/selection'; + import { tourStore, tourPhase } from '$lib/stores/tour'; + + /** + * Shepherd.js wrapper for the US6 guided tour. + * + * Three route-keyed tour configurations: + * + * home (`/`) — search, model, map, lasso (desktop), + * facets, click-a-card to open the + * related-works detail pane, the + * per-card cart icon, then the + * "full details" permalink link. + * detail (`/abstract/<poster_id>/`) — the two zones (submitter vs. + * computed), claims, figures, + * related abstracts, cluster + * membership, cart action. + * about (`/about/`) — overview, per-stage TL;DR toggles. + * + * When the user navigates between routes mid-tour, the current shepherd + * instance is cancelled — we do NOT auto-continue with a stale step list + * pointing at elements that don't exist on the new page. + * + * Lazy-loaded: shepherd.js + its CSS only load when a tour actually starts. + */ + + type TourKind = 'home' | 'detail' | 'about' | 'unknown'; + + let shepherdInstance: { + start: () => void; + cancel: () => void; + complete: () => void; + } | null = null; + let activePathname: string | null = null; + + function detectKind(pathname: string): TourKind { + // Route detection is base-path-agnostic: we only look at whether the + // URL contains `/abstract/<id>/` or `/about/`. Anything else is home. + if (/\/abstract\/[^/]+\/?$/.test(pathname)) return 'detail'; + if (/\/about\/?$/.test(pathname)) return 'about'; + return 'home'; + } + + $: void onPhaseOrPathChange($tourPhase, $page.url.pathname); + + async function onPhaseOrPathChange(p: 'idle' | 'running' | 'dismissed', pathname: string) { + if (p !== 'running') { + if (shepherdInstance) { + shepherdInstance.cancel(); + shepherdInstance = null; + activePathname = null; + } + return; + } + // Phase is running. If a shepherd is up but the URL changed, kill it. + if (shepherdInstance && activePathname !== pathname) { + shepherdInstance.cancel(); + shepherdInstance = null; + activePathname = null; + } + if (!shepherdInstance) { + activePathname = pathname; + await launch(detectKind(pathname)); + } + } + + async function launch(kind: TourKind) { + const { default: Shepherd } = await import('shepherd.js'); + await import('shepherd.js/dist/css/shepherd.css'); + + const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024; + const tour = new Shepherd.Tour({ + useModalOverlay: true, + defaultStepOptions: { + cancelIcon: { enabled: true }, + classes: 'ohbm-shepherd', + scrollTo: { behavior: 'smooth', block: 'center' } + } + }); + + const goNext = (): void => { + tourStore.next(); + tour.next(); + }; + const goBack = (): void => { + tourStore.prev(); + tour.back(); + }; + const skip = (): void => { + tourStore.skip(); + tour.cancel(); + }; + const finish = (): void => { + tourStore.complete(); + tour.complete(); + }; + + const place = (preferred: 'bottom' | 'right' | 'top' | 'left'): + 'bottom' | 'right' | 'top' | 'left' => (isMobile ? 'bottom' : preferred); + + const buttons = ( + isFirst: boolean, + isLast: boolean + ): Array<{ text: string; classes?: string; action: () => void }> => { + const out: Array<{ text: string; classes?: string; action: () => void }> = [ + { text: 'Skip', classes: 'shepherd-button-secondary', action: skip } + ]; + if (!isFirst) out.push({ text: 'Back', classes: 'shepherd-button-secondary', action: goBack }); + out.push(isLast ? { text: 'Done', action: finish } : { text: 'Next', action: goNext }); + return out; + }; + + const steps: Array<{ + id: string; + title: string; + text: string; + attachTo?: { element: string; on: 'bottom' | 'right' | 'top' | 'left' }; + beforeShowPromise?: () => Promise<void> | void; + }> = []; + + if (kind === 'home') { + steps.push( + { + id: 'home-search', + title: 'Search', + text: 'Type a keyword, author, topic, or poster id — typos and accents are tolerated. Semantic search runs in the background, so a query like "memory aging" also surfaces abstracts that never use those exact words.', + attachTo: { element: '[data-testid="search-input"]', on: place('bottom') } + }, + { + id: 'home-model', + title: 'Pick a lens', + text: 'Switch the (model × input) pair to view the corpus through a different embedding. Each lens gives the UMAP its own colouring, clusters, and "most similar" lists.', + attachTo: { element: '[data-testid="model-selector"]', on: place('bottom') } + }, + { + id: 'home-map', + title: 'The map', + text: 'Every dot is one accepted abstract, coloured + shaped by its cluster. Click a dot to focus that abstract; on desktop, lasso a region to filter the result list. The 3D side can be paused / orbited.', + attachTo: { element: '[data-testid="toggle-map"]', on: place('bottom') } + } + ); + if (!isMobile) { + steps.push({ + id: 'home-lasso', + title: 'Lasso on the 2D map', + text: 'On the 2D map, drag the lasso tool (top-right of the chart toolbar) to select a region. The result list, facets, and 3D map all narrow to that selection — and the focused dot gets a halo.', + attachTo: { element: '[data-testid="umap-chart-2d"]', on: 'right' } + }); + } + steps.push( + { + id: 'home-facets', + title: 'Refine', + text: 'Filter by cluster, topic, methods, population, and a dozen more facets. Each filter narrows the result list AND dims the map; combine them freely.', + attachTo: { + element: isMobile ? '[data-testid="toggle-facets"]' : '[data-testid="facet-sidebar"]', + on: place(isMobile ? 'bottom' : 'right') + } + }, + { + id: 'home-card', + title: 'Open an abstract', + text: 'Click any result card to open its detail pane on the right (or full-screen on mobile). You\'ll see the authors (clickable to filter), AI-extracted claims, cross-cell cluster membership, and the "Most similar" / "Most different" related-works lists aggregated across every map.', + attachTo: { element: '[data-testid="result-card"]', on: place('right') }, + beforeShowPromise: () => { + // Auto-focus the first card so the related-works panel actually + // renders for the next step. + const card = document.querySelector<HTMLElement>('[data-testid="result-card"]'); + const posterId = card?.getAttribute('data-poster-id'); + if (posterId) focusedAbstract.set(posterId); + } + }, + { + id: 'home-card-cart', + title: 'Save it for later', + text: 'Each card has a 🛒 icon. Click it once to save the abstract to your list — the icon flips to a filled cart with a ✓ pip. Keep saving; the 🛒 button in the header shows the running count.', + attachTo: { + element: '[data-testid="card-cart-add"], [data-testid="card-cart-remove"]', + on: place('left') + } + }, + { + id: 'home-permalink', + title: 'Full-detail (permalink) page', + text: 'The "full details ↗" link in the detail pane opens a shareable permalink for this abstract. It lays out the submission verbatim on the left (intro / methods / results / conclusion / topics / methods checklist / references) and the AI + algorithmic insights on the right (extracted claims, figure interpretations, cluster membership, related abstracts). A "back to the atlas" link sits at the bottom of the page so you can always return.', + attachTo: { element: '[data-testid="detail-permalink"]', on: place('left') } + }, + { + id: 'home-cart-toggle', + title: 'Your saved list', + text: "When you're ready, the 🛒 button in the top bar opens your saved list. From there you can email it to yourself (the mailto body includes a per-poster Open link back to the atlas), copy it to the clipboard, or clear it.", + attachTo: { element: '[data-testid="toggle-cart"]', on: place('bottom') } + }, + { + id: 'home-about', + title: 'About + methodology', + text: 'The "About" link in the header opens a page describing how the data + the AI surfaces are produced — a short lay paragraph per stage, plus a "Technical details" toggle that expands code-grounded specifics (algorithms, parameters, file paths, cache keys). Every external citation is HEAD-checked at build time so the references stay live.', + attachTo: { element: '[data-testid="header-about-link"]', on: place('bottom') } + } + ); + } else if (kind === 'detail') { + steps.push( + { + id: 'detail-zone-submitter', + title: 'Submitter content', + text: 'The left column is verbatim from the submission: title, authors, body sections (Intro / Methods / Results / Conclusion), Topics + Methods checklist, and curated references.', + attachTo: { element: '[data-testid="zone-submitter"], .zone-submitter', on: place('right') } + }, + { + id: 'detail-zone-computed', + title: 'Computed insights', + text: "The right column is everything we computed about this abstract. AI-extracted claims + figure interpretations sit at the top (clearly tagged ✨ AI), followed by the algorithmic cluster membership + related-abstract rails.", + attachTo: { element: '[data-testid="zone-computed"], .zone-computed', on: place('left') } + }, + { + id: 'detail-claims', + title: 'AI-extracted claims', + text: 'Each claim is shown with its evidence quote (verified against the source text), an ECO ontology code for the kind of evidence, and the LLM model identifier — so you can decide how much to trust each one.', + attachTo: { element: '[data-testid="section-claims"]', on: place('left') } + }, + { + id: 'detail-related', + title: 'Related abstracts', + text: 'Per-cell precomputed neighbours, aggregated across all 15 (model, input) maps. The "×N" badge shows how many maps include this abstract in the focused abstract\'s nearest/farthest 10 — more = more robust similarity.', + attachTo: { element: '[data-testid="detail-related"]', on: place('left') } + }, + { + id: 'detail-clusters', + title: 'Cluster membership', + text: 'Each row is one (model, input) cell\'s view of where this abstract sits. Cluster labels are LLM-grouped (✨ AI) and reflect that map\'s own community-detection run.', + attachTo: { element: '[data-testid="extra-clusters"]', on: place('left') } + }, + { + id: 'detail-cart', + title: 'Save it', + text: 'The cart icon next to the poster id adds this abstract to your saved list — the same list the 🛒 button in the home-page header surfaces.', + attachTo: { element: '[data-testid="detail-cart-add"], [data-testid="detail-cart-remove"]', on: place('bottom') } + }, + { + id: 'detail-back', + title: 'Back to the atlas', + text: 'When you\'re done reading, the link at the bottom returns you to the search + map view with your saved list and any filters still intact.', + attachTo: { element: '[data-testid="detail-back-to-atlas"]', on: place('top') } + } + ); + } else if (kind === 'about') { + steps.push( + { + id: 'about-intro', + title: 'About this site', + text: 'A short overview of where the data comes from and how the AI surfaces (figure interpretations, claims, cluster labels) are produced.', + attachTo: { element: 'header h1', on: place('bottom') } + }, + { + id: 'about-stages', + title: 'Per-stage deep dives', + text: 'Five collapsible stages walk through the pipeline. Each opens with a lay paragraph; click "Technical details" beneath it to expand a code-grounded breakdown (algorithms, file paths, cache keys, parameters).', + attachTo: { element: '[data-testid^="about-stage-"]', on: place('right') } + }, + { + id: 'about-references', + title: 'External citations', + text: 'Every link goes to a real, accessible page. They\'re HEAD-checked at build time — a broken citation blocks the deploy — so the references stay current.', + attachTo: { element: '.stage-body a[target="_blank"]', on: place('right') } + } + ); + } else { + // Unknown route → just dismiss the tour cleanly. + tourStore.skip(); + return; + } + + steps.forEach((s, i) => + tour.addStep({ ...s, buttons: buttons(i === 0, i === steps.length - 1) }) + ); + + tour.on('cancel', () => tourStore.skip()); + tour.on('complete', () => tourStore.complete()); + + shepherdInstance = tour; + tour.start(); + } + + onDestroy(() => { + if (shepherdInstance) { + shepherdInstance.cancel(); + shepherdInstance = null; + } + }); +</script> + +<style global> + .ohbm-shepherd { + --shepherd-primary: var(--accent, #2c5fa3); + } + .shepherd-element { + border-radius: 6px; + max-width: min(28rem, 90vw); + } + .shepherd-text { + font-size: 0.92rem; + line-height: 1.55; + } + .shepherd-button { + background: var(--accent); + color: var(--accent-text, white); + padding: 0.4rem 0.9rem; + font-size: 0.85rem; + border-radius: 4px; + margin-right: 0.4rem; + } + .shepherd-button-secondary { + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border); + } +</style> diff --git a/site/src/lib/components/UmapPanel.svelte b/site/src/lib/components/UmapPanel.svelte new file mode 100644 index 00000000..c4ea00f1 --- /dev/null +++ b/site/src/lib/components/UmapPanel.svelte @@ -0,0 +1,791 @@ +<script lang="ts"> + import { onMount, onDestroy } from 'svelte'; + import { selectedCell, lassoSelection, focusedAbstract } from '$lib/stores/selection'; + import { effectiveTheme } from '$lib/stores/theme'; + import { loadCell, loadTopics, type CellShard, type TopicShard } from '$lib/shards'; + import type { AbstractRecord } from '$lib/shards'; + + export let abstracts: AbstractRecord[] = []; + /** + * Set of abstract_ids the rest of the app considers "currently selected" + * — i.e. the intersection of search ∩ lasso ∩ facets. The UMAP dims + * everything outside this set so picking a cluster or facet visually + * narrows the map alongside the result list. `null` = no narrowing + * (every point at full opacity). + */ + export let selection: Set<number> | null = null; + /** + * Mobile breakpoint — desktop ≥ 1024px renders 2D + 3D side-by-side + * with lasso on the 2D pane. Smaller viewports stack vertically and + * use tap-to-filter-by-community for the 2D pane (FR-005 Edge Case). + */ + export let mobileBreakpoint = 1024; + + type PlotlyApi = typeof import('plotly.js-gl3d-dist-min'); + + let plotly: PlotlyApi | null = null; + let plotlyLoading = false; + let plotlyError: string | null = null; + + let chart2dEl: HTMLDivElement | null = null; + let chart3dEl: HTMLDivElement | null = null; + let cellShard: CellShard | null = null; + let topicsShard: TopicShard | null = null; + let cellLoading = false; + let cellError: string | null = null; + + let viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1280; + let mobile = viewportWidth < mobileBreakpoint; + + let autoRotate = true; + let rotateFrame: number | null = null; + let rotateAngle = 0; + + // Track whether each chart already has event listeners attached so we + // don't stack duplicate handlers on every render (Plotly's `on(...)` + // stacks; only `react()` is idempotent for the chart itself). + let handlers2dAttached = false; + let handlers3dAttached = false; + let chart3dInitialized = false; + + // Authoritative camera eye for the 3D chart, kept in sync with both + // programmatic rotation frames AND user mouse interactions via the + // `plotly_relayout` event. Reading `_fullLayout.scene.camera.eye` after + // a pause+zoom turned out to be unreliable in this Plotly bundle — + // listening to the event gives us a deterministic source of truth. + let currentEye3D: { x: number; y: number; z: number } | null = null; + + $: cellKey = `${$selectedCell.model}_${$selectedCell.input}`; + + function onResize() { + viewportWidth = window.innerWidth; + mobile = viewportWidth < mobileBreakpoint; + if (plotly) { + if (chart2dEl) plotly.Plots.resize(chart2dEl); + if (chart3dEl) plotly.Plots.resize(chart3dEl); + } + } + + onMount(async () => { + window.addEventListener('resize', onResize); + await ensurePlotly(); + }); + + onDestroy(() => { + if (typeof window !== 'undefined') { + window.removeEventListener('resize', onResize); + } + stopRotate(); + if (plotly) { + if (chart2dEl) plotly.purge(chart2dEl); + if (chart3dEl) plotly.purge(chart3dEl); + } + }); + + async function ensurePlotly() { + if (plotly || plotlyLoading) return; + plotlyLoading = true; + try { + plotly = (await import('plotly.js-gl3d-dist-min')).default as PlotlyApi; + } catch (err) { + plotlyError = (err as Error).message; + } finally { + plotlyLoading = false; + } + } + + $: void (async () => { + const key = cellKey; + cellLoading = true; + cellError = null; + const [shard, topics] = await Promise.all([ + loadCell(key), + loadTopics(key, 'communities') + ]); + if (key === cellKey) { + cellShard = shard; + topicsShard = topics; + cellLoading = false; + if (shard === null) cellError = 'cell shard not available'; + } + })(); + + $: theme = $effectiveTheme; + $: topicByCluster = (() => { + const map = new Map<number, string>(); + if (topicsShard) { + for (const t of topicsShard.topics) { + const label = t.title || (t.keywords.length ? t.keywords.slice(0, 3).join(', ') : `cluster ${t.cluster_id}`); + map.set(t.cluster_id, label); + } + } + return map; + })(); + // abstract_id of the user-focused abstract (the one whose detail panel is + // open). Highlighted on both charts with a halo marker. + $: focusedAbstractId = (() => { + if (!$focusedAbstract) return null; + const rec = abstracts.find((a) => a.poster_id === $focusedAbstract); + return rec ? rec.abstract_id : null; + })(); + $: void renderChart2D(plotly, chart2dEl, cellShard, abstracts, selection, mobile, theme, topicByCluster, focusedAbstractId); + $: void renderChart3D(plotly, chart3dEl, cellShard, abstracts, selection, theme, topicByCluster, focusedAbstractId); + + // Paul Tol's "bright" qualitative palette — high-contrast, deuteranopia / + // protanopia / tritanopia safe. Communities are CATEGORICAL (the integer + // ids are labels, not magnitudes) so we MUST use a discrete palette and + // NOT a continuous colorscale. To extend past 7 distinct communities the + // renderer also cycles through marker symbols, giving 7 × N_SYMBOLS + // (~35–40) perceptually distinct combinations before any pair repeats. + const TOL_BRIGHT = [ + '#4477AA', // blue + '#EE6677', // red-pink + '#228833', // green + '#CCBB44', // yellow + '#66CCEE', // cyan + '#AA3377', // purple + '#BBBBBB' // grey + ]; + const SYMBOLS_2D = ['circle', 'diamond', 'square', 'triangle-up', 'cross']; + // scatter3d supports a narrower set; use only those. + const SYMBOLS_3D = ['circle', 'diamond', 'square', 'cross', 'x']; + + function colorFor(communityId: number): string { + const idx = ((communityId % TOL_BRIGHT.length) + TOL_BRIGHT.length) % TOL_BRIGHT.length; + return TOL_BRIGHT[idx]; + } + function symbol2DFor(communityId: number): string { + const bucket = Math.floor(communityId / TOL_BRIGHT.length); + return SYMBOLS_2D[((bucket % SYMBOLS_2D.length) + SYMBOLS_2D.length) % SYMBOLS_2D.length]; + } + function symbol3DFor(communityId: number): string { + const bucket = Math.floor(communityId / TOL_BRIGHT.length); + return SYMBOLS_3D[((bucket % SYMBOLS_3D.length) + SYMBOLS_3D.length) % SYMBOLS_3D.length]; + } + + function buildSeries( + shard: CellShard, + records: AbstractRecord[], + selected: Set<number> | null, + topicMap: Map<number, string> + ) { + const xs2: number[] = []; + const ys2: number[] = []; + const xs3: number[] = []; + const ys3: number[] = []; + const zs3: number[] = []; + const posters: string[] = []; + const titles: string[] = []; + const communityLabels: string[] = []; + const communityIds: number[] = []; + const markerColors: string[] = []; + const markerSymbols2D: string[] = []; + const markerSymbols3D: string[] = []; + const selectedIdx: number[] = []; + const idx2dByAbstract = new Map<number, number>(); + for (let i = 0; i < shard.rows.length; i++) { + const row = shard.rows[i]; + const rec = records[i]; + if (!rec) continue; + if (row.umap_missing) continue; + xs2.push(row.umap2d[0]); + ys2.push(row.umap2d[1]); + xs3.push(row.umap3d[0]); + ys3.push(row.umap3d[1]); + zs3.push(row.umap3d[2]); + posters.push(rec.poster_id); + titles.push(rec.title); + communityLabels.push(topicMap.get(row.community_id) ?? `community ${row.community_id}`); + communityIds.push(row.community_id); + markerColors.push(colorFor(row.community_id)); + markerSymbols2D.push(symbol2DFor(row.community_id)); + markerSymbols3D.push(symbol3DFor(row.community_id)); + idx2dByAbstract.set(row.abstract_id, xs2.length - 1); + if (selected !== null && selected.has(row.abstract_id)) selectedIdx.push(xs2.length - 1); + } + return { + xs2, + ys2, + xs3, + ys3, + zs3, + posters, + titles, + communityLabels, + communityIds, + markerColors, + markerSymbols2D, + markerSymbols3D, + selectedIdx, + idx2dByAbstract + }; + } + + function themedColors(t: 'light' | 'dark') { + return t === 'dark' + ? { paper: '#0f1419', plot: '#161b22', font: '#e8e8e8', grid: '#2a3138' } + : { paper: '#ffffff', plot: '#fafafa', font: '#222222', grid: '#d6d6d6' }; + } + + function renderChart2D( + api: PlotlyApi | null, + el: HTMLDivElement | null, + shard: CellShard | null, + records: AbstractRecord[], + selected: Set<number> | null, + isMobile: boolean, + t: 'light' | 'dark', + topicMap: Map<number, string>, + focusedId: number | null + ) { + if (!api || !el || !shard) return; + const s = buildSeries(shard, records, selected, topicMap); + // Locate the focused abstract's position in the visible-points arrays. + let focusedIdx = -1; + if (focusedId !== null) { + let visibleIdx = 0; + for (const row of shard.rows) { + if (row.umap_missing) continue; + if (row.abstract_id === focusedId) { + focusedIdx = visibleIdx; + break; + } + visibleIdx += 1; + } + } + const t1 = { + type: 'scatter' as const, + mode: 'markers' as const, + x: s.xs2, + y: s.ys2, + marker: { + size: 7, + color: s.markerColors, + symbol: s.markerSymbols2D, + opacity: 0.85, + line: { width: 0 } + }, + customdata: s.posters.map((p, i) => [p, s.titles[i], s.communityLabels[i]]) as unknown as number[][], + hovertemplate: + '<b>%{customdata[0]}</b><br>%{customdata[1]}<br><i>%{customdata[2]}</i><extra></extra>', + selectedpoints: s.selectedIdx.length ? s.selectedIdx : undefined, + unselected: { marker: { opacity: 0.2 } }, + selected: { marker: { opacity: 1 } } + }; + // Second trace: a single halo marker for the user-focused abstract so + // it pops above the colour-cluster carpet. Drawn last (on top). + // For Plotly's `circle-open` symbol the OUTLINE colour comes from + // `marker.color` (not `marker.line.color`). Use a thick filled-circle + // outline behind a smaller filled circle in the cluster colour — that + // way the highlight reads even if the underlying point is dimmed by + // the lasso/facet selection. + const traces2d: unknown[] = [t1]; + if (focusedIdx >= 0) { + const haloOutline = t === 'dark' ? '#FFFFFF' : '#000000'; + traces2d.push({ + type: 'scatter' as const, + mode: 'markers' as const, + name: 'focused', + x: [s.xs2[focusedIdx]], + y: [s.ys2[focusedIdx]], + marker: { + size: 22, + color: haloOutline, + symbol: 'circle-open', + line: { width: 3, color: haloOutline }, + opacity: 1 + }, + hovertemplate: + '<b>FOCUSED · %{customdata[0]}</b><br>%{customdata[1]}<extra></extra>', + customdata: [[s.posters[focusedIdx], s.titles[focusedIdx]]] as unknown as number[][], + hoverinfo: undefined, + showlegend: false + }); + } + const c = themedColors(t); + const layout = { + margin: { l: 0, r: 0, t: 0, b: 0 }, + showlegend: false, + hovermode: 'closest', + dragmode: isMobile ? 'pan' : 'lasso', + paper_bgcolor: c.paper, + plot_bgcolor: c.plot, + font: { color: c.font }, + xaxis: { visible: false, scaleanchor: 'y' }, + yaxis: { visible: false }, + // uirevision pinned constant so axis zoom + pan + selection state + // survive react() calls (selection / theme / cell switches all + // reach this code path). + uirevision: 'umap-2d', + selectionrevision: 'umap-2d-sel' + }; + const config = { + responsive: true, + displaylogo: false, + modeBarButtonsToRemove: ['autoScale2d'], + scrollZoom: true + }; + (api as unknown as { react: (...args: unknown[]) => Promise<unknown> }) + .react(el, traces2d, layout, config) + .then(() => { + if (handlers2dAttached) return; + handlers2dAttached = true; + const node = el as unknown as { on: (e: string, h: (e: unknown) => void) => void }; + node.on('plotly_selected', (e: unknown) => { + const ev = e as { points?: Array<{ pointIndex: number }> } | null; + // Plotly fires a *second* `plotly_selected` with an empty + // `points` array immediately after its internal + // `plotly_relayout` — that's a no-op for us, not a real + // deselect. The real deselect comes via `plotly_deselect` + // (double-click on empty space). So ignore empty events. + if (!ev || !ev.points || ev.points.length === 0) return; + // Capture the CURRENT shard via the cellShard module + // variable so a model-switch since render uses fresh data. + const shardNow = cellShard; + if (!shardNow) return; + const visibleIds: number[] = []; + for (let i = 0; i < shardNow.rows.length; i++) { + const row = shardNow.rows[i]; + if (row.umap_missing) continue; + visibleIds.push(row.abstract_id); + } + const ids: Set<number> = new Set(); + for (const p of ev.points) { + const aid = visibleIds[p.pointIndex]; + if (aid !== undefined) ids.add(aid); + } + if (ids.size > 0) $lassoSelection = ids; + }); + node.on('plotly_deselect', () => { + $lassoSelection = null; + }); + node.on('plotly_click', (e: unknown) => { + const ev = e as { points?: Array<{ pointIndex: number }> } | null; + const pt = ev?.points?.[0]; + if (!pt) return; + const shardNow = cellShard; + if (!shardNow) return; + const visibleRows = shardNow.rows.filter((r) => !r.umap_missing); + const row = visibleRows[pt.pointIndex]; + if (!row) return; + if (isMobile) { + const commId = row.community_id; + const ids: Set<number> = new Set(); + for (const r of shardNow.rows) { + if (!r.umap_missing && r.community_id === commId) ids.add(r.abstract_id); + } + $lassoSelection = ids; + } + const idxInAbstracts = shardNow.rows.indexOf(row); + const rec = records[idxInAbstracts]; + if (rec?.poster_id) $focusedAbstract = rec.poster_id; + }); + }) + .catch((err: Error) => { + plotlyError = err.message; + }); + } + + function renderChart3D( + api: PlotlyApi | null, + el: HTMLDivElement | null, + shard: CellShard | null, + records: AbstractRecord[], + selected: Set<number> | null, + t: 'light' | 'dark', + topicMap: Map<number, string>, + focusedId: number | null + ) { + if (!api || !el || !shard) return; + const s = buildSeries(shard, records, selected, topicMap); + // Locate focused abstract index in the visible arrays. + let focusedIdx3 = -1; + if (focusedId !== null) { + let visIdx = 0; + for (const row of shard.rows) { + if (row.umap_missing) continue; + if (row.abstract_id === focusedId) { + focusedIdx3 = visIdx; + break; + } + visIdx += 1; + } + } + // scatter3d only accepts a SCALAR marker.opacity; per-point arrays are + // silently ignored. Split the data into two traces (selected + + // unselected), each with its own scalar opacity, so the selection + // visually dims the unselected points. + const hasSelection = selected !== null && s.selectedIdx.length > 0; + const selSet = hasSelection ? new Set(s.selectedIdx) : null; + const partition = (i: number) => (!selSet || selSet.has(i) ? 'sel' : 'unsel'); + // Collect indices for each partition. + const traces: unknown[] = []; + const customdata = s.posters.map((p, i) => [p, s.titles[i], s.communityLabels[i]]); + function buildTrace(name: string, indices: number[], opacity: number, showColorbar: boolean) { + if (indices.length === 0) return null; + return { + type: 'scatter3d' as const, + mode: 'markers' as const, + name, + x: indices.map((i) => s.xs3[i]), + y: indices.map((i) => s.ys3[i]), + z: indices.map((i) => s.zs3[i]), + marker: { + size: 3, + color: indices.map((i) => s.markerColors[i]), + symbol: indices.map((i) => s.markerSymbols3D[i]), + opacity, + showscale: showColorbar, + line: { width: 0 } + }, + customdata: indices.map((i) => customdata[i]) as unknown as number[][], + hovertemplate: + '<b>%{customdata[0]}</b><br>%{customdata[1]}<br><i>%{customdata[2]}</i><extra></extra>', + hoverinfo: opacity < 0.3 ? 'skip' : undefined, + showlegend: false + }; + } + if (!hasSelection) { + const all = s.xs3.map((_: number, i: number) => i); + const t = buildTrace('all', all, 0.85, false); + if (t) traces.push(t); + } else { + const selIdx: number[] = []; + const unselIdx: number[] = []; + for (let i = 0; i < s.xs3.length; i++) { + if (partition(i) === 'sel') selIdx.push(i); + else unselIdx.push(i); + } + // Draw unselected first so selected layer renders on top. + const tUnsel = buildTrace('unselected', unselIdx, 0.05, false); + if (tUnsel) traces.push(tUnsel); + const tSel = buildTrace('selected', selIdx, 1, false); + if (tSel) traces.push(tSel); + } + // Focused-abstract halo: oversized open marker drawn last (on top). + if (focusedIdx3 >= 0) { + traces.push({ + type: 'scatter3d' as const, + mode: 'markers' as const, + name: 'focused', + x: [s.xs3[focusedIdx3]], + y: [s.ys3[focusedIdx3]], + z: [s.zs3[focusedIdx3]], + marker: { + size: 9, + color: t === 'dark' ? '#FFFFFF' : '#000000', + symbol: 'circle-open', + line: { color: t === 'dark' ? '#FFFFFF' : '#000000', width: 3 } + }, + hovertemplate: + '<b>FOCUSED · %{customdata[0]}</b><br>%{customdata[1]}<extra></extra>', + customdata: [[s.posters[focusedIdx3], s.titles[focusedIdx3]]] as unknown as number[][], + showlegend: false + }); + } + const c = themedColors(t); + const axisCfg = { visible: false, showbackground: false }; + // Explicitly carry the current camera forward into each react() so + // Plotly doesn't reset to its default. (`uirevision` should do this + // automatically per the docs, but in this bundle it doesn't.) We + // trust `currentEye3D` (updated by the rotation step + by + // `plotly_relayout` user interactions) over `_fullLayout`, which + // occasionally lags the user's last gesture in this bundle. + let cameraEye: { x: number; y: number; z: number } = { x: 1.6, y: 1.6, z: 0.9 }; + if (chart3dInitialized && currentEye3D) { + cameraEye = currentEye3D; + } + const scene: Record<string, unknown> = { + xaxis: axisCfg, + yaxis: axisCfg, + zaxis: axisCfg, + bgcolor: c.plot, + camera: { eye: cameraEye } + }; + const layout = { + margin: { l: 0, r: 0, t: 0, b: 0 }, + showlegend: false, + paper_bgcolor: c.paper, + plot_bgcolor: c.plot, + font: { color: c.font }, + scene, + // uirevision pinned constant so camera (rotation, zoom) survives + // re-renders triggered by selection / theme / cell switches. + // Per US2 acceptance scenario 2, the lasso selection is by + // abstract_id and should persist across cell switches anyway, so + // keeping the camera too feels right. + uirevision: 'umap-3d' + }; + const config = { + responsive: true, + displaylogo: false + }; + (api as unknown as { react: (...args: unknown[]) => Promise<unknown> }) + .react(el, traces, layout, config) + .then(() => { + chart3dInitialized = true; + if (!handlers3dAttached) { + handlers3dAttached = true; + const node = el as unknown as { on: (e: string, h: (e: unknown) => void) => void }; + node.on('plotly_click', (e: unknown) => { + const ev = e as { + points?: Array<{ customdata?: [string, string, string] }>; + } | null; + const pt = ev?.points?.[0]; + // 3D may have 1 or 2 traces depending on selection — read + // the poster_id straight off customdata so trace + // partitioning doesn't affect the click target. + const posterId = pt?.customdata?.[0]; + if (posterId) $focusedAbstract = posterId; + }); + // Capture every camera change (user orbit / zoom / pan, OR our + // own rotation `relayout`) into `currentEye3D`. The event + // payload arrives in two shapes depending on the cause: + // - mouse interaction: { 'scene.camera': { eye, center, ...} } + // - our relayout({ 'scene.camera.eye': ... }): + // { 'scene.camera.eye': { x, y, z } } + node.on('plotly_relayout', (e: unknown) => { + const ev = e as Record<string, unknown> | null; + if (!ev) return; + let eye: { x?: number; y?: number; z?: number } | undefined; + const flat = ev['scene.camera.eye'] as { x?: number; y?: number; z?: number } | undefined; + const nested = (ev['scene.camera'] as { eye?: { x?: number; y?: number; z?: number } } | undefined) + ?.eye; + eye = flat ?? nested; + if (eye && typeof eye.x === 'number' && typeof eye.y === 'number' && typeof eye.z === 'number') { + currentEye3D = { x: eye.x, y: eye.y, z: eye.z }; + } + }); + } + ensureRotate(); + }) + .catch((err: Error) => { + plotlyError = err.message; + }); + } + + function ensureRotate() { + stopRotate(); + if (!autoRotate || !plotly || !chart3dEl) return; + // Seed the orbit from `currentEye3D` (kept in sync via the + // `plotly_relayout` listener) so pause → user zoom/orbit → unpause + // continues from where the user left off instead of snapping back to + // the factory default. + let r = 2.2; + let z = 0.9; + if (currentEye3D) { + const r0 = Math.hypot(currentEye3D.x, currentEye3D.y); + if (r0 > 1e-6) { + r = r0; + z = currentEye3D.z; + rotateAngle = Math.atan2(currentEye3D.y, currentEye3D.x); + } + } + const step = () => { + if (!autoRotate || !plotly || !chart3dEl) return; + rotateAngle += 0.004; + const eye = { + x: r * Math.cos(rotateAngle), + y: r * Math.sin(rotateAngle), + z + }; + currentEye3D = eye; + try { + (plotly as unknown as { relayout: (el: HTMLDivElement, p: unknown) => Promise<unknown> }).relayout( + chart3dEl, + { 'scene.camera.eye': eye } + ); + } catch { + /* no-op */ + } + rotateFrame = requestAnimationFrame(step); + }; + rotateFrame = requestAnimationFrame(step); + } + + function stopRotate() { + if (rotateFrame !== null) { + cancelAnimationFrame(rotateFrame); + rotateFrame = null; + } + } + + function toggleRotate() { + autoRotate = !autoRotate; + if (autoRotate) ensureRotate(); + else stopRotate(); + } +</script> + +<section class="umap-panel" data-testid="umap-panel"> + <header class="umap-header"> + <div class="title-block"> + <h3>UMAP — cell <code>{cellKey}</code></h3> + <p class="hint"> + Points are coloured + shaped by <em>cluster</em> (community detected for this + cell, Tol-bright palette × 5 symbols, colour-vision-friendly). Lasso on 2D + filters the result list; click any point to open its detail panel. + </p> + </div> + <div class="header-actions"> + {#if $lassoSelection} + <button + type="button" + class="clear-lasso" + on:click={() => ($lassoSelection = null)} + data-testid="umap-clear-lasso" + > + Clear selection ({$lassoSelection.size}) + </button> + {/if} + </div> + </header> + + <div class="charts" data-testid="umap-chart-wrap"> + <figure class="chart-card"> + <figcaption>2D <span class="caption-aside">{mobile ? 'tap to filter by community' : 'lasso to filter'}</span></figcaption> + <div bind:this={chart2dEl} class="chart" data-testid="umap-chart-2d"></div> + </figure> + <figure class="chart-card"> + <figcaption> + 3D + <span class="caption-aside"> + <button + type="button" + on:click={toggleRotate} + class="rotate-btn" + aria-pressed={autoRotate} + data-testid="umap-rotate-toggle" + > + {autoRotate ? '⏸ pause' : '▶ rotate'} + </button> + </span> + </figcaption> + <div bind:this={chart3dEl} class="chart chart-3d" data-testid="umap-chart-3d"></div> + </figure> + </div> + + {#if plotlyError || cellError} + <p class="error">Map unavailable: {plotlyError || cellError}</p> + {:else if !plotly || cellLoading} + <p class="status">Loading map…</p> + {/if} + + <!-- Back-compat testid: existing e2e looks for `umap-chart`. --> + <div data-testid="umap-chart" hidden></div> +</section> + +<style> + .umap-panel { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 0; + } + .umap-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.75rem; + flex-wrap: wrap; + } + .title-block h3 { + margin: 0; + font-size: 0.95rem; + color: var(--text); + font-weight: 600; + } + .title-block code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; + color: var(--accent); + } + .hint { + margin: 0.15rem 0 0; + font-size: 0.78rem; + color: var(--text-muted); + } + .header-actions { + display: flex; + gap: 0.5rem; + } + .clear-lasso { + all: unset; + cursor: pointer; + padding: 0.3rem 0.7rem; + border-radius: 4px; + font-size: 0.8rem; + background: var(--warning-bg); + color: var(--warning-text); + border: 1px solid var(--warning-border); + } + .charts { + display: grid; + gap: 0.75rem; + grid-template-columns: 1fr; + } + @media (min-width: 880px) { + .charts { + grid-template-columns: 1fr 1fr; + } + } + .chart-card { + margin: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--chart-paper); + overflow: hidden; + } + .chart-card figcaption { + font-size: 0.78rem; + color: var(--text-muted); + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + } + .caption-aside { + color: var(--text-faint); + font-size: 0.75rem; + } + .rotate-btn { + all: unset; + cursor: pointer; + font-size: 0.75rem; + color: var(--accent); + } + .rotate-btn:hover { + text-decoration: underline; + } + .chart { + width: 100%; + max-width: 100%; + height: clamp(220px, 50vh, 480px); + overflow: hidden; + } + @media (max-height: 480px) and (orientation: landscape) { + /* Phone-landscape: 480 px tall or less. Don't let one chart eat the + entire viewport — keep ~60% of it for the chart, leave room for + the panel header + the surrounding result list. */ + .chart { + height: 60vh; + } + } + .chart-3d { + height: clamp(280px, 45vh, 480px); + } + .status, + .error { + margin: 0; + font-size: 0.8rem; + color: var(--text-muted); + font-style: italic; + } + .error { + color: var(--danger); + } +</style> diff --git a/site/src/lib/data_package.ts b/site/src/lib/data_package.ts new file mode 100644 index 00000000..f9e6bfdd --- /dev/null +++ b/site/src/lib/data_package.ts @@ -0,0 +1,110 @@ +/** + * Runtime data-package fetcher. + * + * The deployed site no longer bundles any data. Instead the client fetches + * a single tarball from `VITE_DATA_PACKAGE_URL` (a Dropbox / CDN URL), uses + * the browser's native `DecompressionStream('gzip')` to ungzip, parses the + * tar entries in memory, and returns a `Map<path, JsonValue>` keyed by the + * tarball-relative path (e.g. `data/manifest.json`, + * `data/cells/voyage_abstract.json`). + * + * No tar/gzip npm dependency — `DecompressionStream` is available in all + * modern browsers, and the tar format's a 512-byte-aligned stream that + * fits in ~50 lines of straightforward TypeScript. + */ + +let packageCache: Promise<Map<string, unknown> | null> | null = null; + +export function getDataPackageUrl(): string | null { + const url = import.meta.env.VITE_DATA_PACKAGE_URL; + if (!url) return null; + // Dropbox shared links served via `www.dropbox.com` redirect (HTTP 302) + // to `*.dl.dropboxusercontent.com`, but the redirect step itself lacks + // CORS headers — browsers refuse the cross-origin fetch. Rewriting the + // host to `dl.dropboxusercontent.com` skips the redirect and lands + // directly on the content endpoint, which DOES send + // `Access-Control-Allow-Origin: *` and serves raw bytes. + return url + .replace(/^https:\/\/www\.dropbox\.com\//, 'https://dl.dropboxusercontent.com/') + .replace(/[?&]dl=0(\b|$)/, (m) => m.replace('dl=0', '')); +} + +export function loadDataPackage( + fetcher: typeof fetch = fetch +): Promise<Map<string, unknown> | null> { + if (packageCache !== null) return packageCache; + const url = getDataPackageUrl(); + if (!url) { + packageCache = Promise.resolve(null); + return packageCache; + } + packageCache = (async (): Promise<Map<string, unknown> | null> => { + try { + const response = await fetcher(url); + if (!response.ok || !response.body) return null; + const decompressed = response.body.pipeThrough(new DecompressionStream('gzip')); + const buffer = await new Response(decompressed).arrayBuffer(); + return parseTar(new Uint8Array(buffer)); + } catch (err) { + console.error('[ohbm2026] failed to load data package:', err); + return null; + } + })(); + return packageCache; +} + +export function resetDataPackageCacheForTests(): void { + packageCache = null; +} + +function parseTar(bytes: Uint8Array): Map<string, unknown> { + const out = new Map<string, unknown>(); + const decoder = new TextDecoder(); + let offset = 0; + while (offset + 512 <= bytes.length) { + // Two consecutive 512-byte zero blocks = end of archive. + if (isZeroBlock(bytes, offset)) { + offset += 512; + if (offset + 512 <= bytes.length && isZeroBlock(bytes, offset)) break; + continue; + } + const name = decoder.decode(bytes.subarray(offset, offset + 100)).split('\0')[0]; + const prefix = decoder.decode(bytes.subarray(offset + 345, offset + 345 + 155)).split('\0')[0]; + const fullName = prefix ? `${prefix}/${name}` : name; + const sizeStr = decoder + .decode(bytes.subarray(offset + 124, offset + 124 + 12)) + .split('\0')[0] + .trim(); + const size = sizeStr ? parseInt(sizeStr, 8) : 0; + const typeFlag = decoder.decode(bytes.subarray(offset + 156, offset + 157)); + offset += 512; + if (size > 0 && (typeFlag === '' || typeFlag === '0' || typeFlag === '\0')) { + if (fullName && fullName.endsWith('.json')) { + const content = decoder.decode(bytes.subarray(offset, offset + size)); + try { + out.set(fullName, JSON.parse(content)); + } catch { + // skip malformed json + } + } else if (fullName && fullName.endsWith('.bin')) { + // Copy out the raw bytes so the underlying ArrayBuffer can be + // GC'd once the parser returns; sharing the original buffer + // would pin the whole 50 MB decompressed package in memory. + const slice = bytes.subarray(offset, offset + size); + const copy = new Uint8Array(size); + copy.set(slice); + out.set(fullName, copy); + } + // Skip past content padded to 512-byte boundary. + offset += Math.ceil(size / 512) * 512; + } + } + return out; +} + +function isZeroBlock(bytes: Uint8Array, offset: number): boolean { + for (let i = offset; i < offset + 512; i++) { + if (bytes[i] !== 0) return false; + } + return true; +} diff --git a/site/src/lib/facets.ts b/site/src/lib/facets.ts new file mode 100644 index 00000000..e752bede --- /dev/null +++ b/site/src/lib/facets.ts @@ -0,0 +1,215 @@ +import type { AbstractRecord } from '$lib/shards'; + +/** + * Facet recomputation per US4 / FR-013. + * + * For each facet (Topics + Methods + Study type + Population + Field strength + * + Processing packages + Species + Recording technology + Brain regions + + * Brain networks + Keywords + Accepted-for), build `{option → count}` over + * the abstracts that pass the current intersection of (search ∩ lasso ∩ + * other-facet-filters). The "other-facet-filters" detail is the FR-013 nuance: + * within a given facet we want to show what's reachable IF the user also + * selected another option from that facet — so the count for facet F uses the + * intersection EXCLUDING F's current selections (otherwise once you select + * Species=Human you'd see counts of 0 for every other Species). + */ + +export type ActiveFilters = Map<string, Set<string>>; + +const FACETS_FROM_BLOCK = [ + 'keywords', + 'methods', + 'study_type', + 'population', + 'field_strength', + 'processing_packages', + 'species', + 'recording_technology', + 'brain_regions', + 'brain_networks' +] as const; + +export type FacetKey = + | 'accepted_for' + | 'cluster' + | 'topic' + | 'subcategory' + | (typeof FACETS_FROM_BLOCK)[number]; + +export const FACET_KEYS_ORDERED: FacetKey[] = [ + 'cluster', + 'topic', + 'subcategory', + 'methods', + 'study_type', + 'population', + 'field_strength', + 'processing_packages', + 'species', + 'recording_technology', + 'brain_regions', + 'brain_networks', + 'keywords', + 'accepted_for' +]; + +export const FACET_LABELS: Record<FacetKey, string> = { + accepted_for: 'Accepted for', + cluster: 'Cluster (current map)', + topic: 'Topic', + subcategory: 'Subcategory', + keywords: 'Keywords', + methods: 'Methods', + study_type: 'Study type', + population: 'Population', + field_strength: 'Field strength', + processing_packages: 'Processing packages', + species: 'Species', + recording_technology: 'Recording technology', + brain_regions: 'Brain regions', + brain_networks: 'Brain networks' +}; + +/** + * Per-(model, input) cell-specific context for facets that derive their + * options from the active UMAP layout rather than the abstract record alone. + * Currently just the cluster facet — `clusterLabelByAbstractId` maps each + * abstract to the community label that the UMAP color-codes it under. + */ +export interface FacetCellContext { + clusterLabelByAbstractId: Map<number, string>; +} + +const EMPTY_CTX: FacetCellContext = { clusterLabelByAbstractId: new Map() }; + +function dedupe(values: string[]): string[] { + const out: string[] = []; + const seen = new Set<string>(); + for (const v of values) { + if (!v || seen.has(v)) continue; + seen.add(v); + out.push(v); + } + return out; +} + +function valuesFor(record: AbstractRecord, key: FacetKey, ctx: FacetCellContext): string[] { + if (key === 'accepted_for') return record.accepted_for ? [record.accepted_for] : []; + if (key === 'cluster') { + const label = ctx.clusterLabelByAbstractId.get(record.abstract_id); + return label ? [label] : []; + } + // The Topic facet is the UNION of primary + secondary topic values per + // abstract (deduped). A selected Topic option matches if EITHER position + // equals it — the previous split into primary_topic / secondary_topic + // made users pick the same term twice for the same conceptual filter. + if (key === 'topic') return dedupe([record.topics.primary, record.topics.secondary]); + if (key === 'subcategory') + return dedupe([record.topics.primary_subcategory, record.topics.secondary_subcategory]); + const v = record.facets[key]; + if (Array.isArray(v)) return v.map((x) => String(x)).filter(Boolean); + if (typeof v === 'string' && v) return [v]; + return []; +} + +/** Does *record* pass the active filters in `filters`, optionally ignoring facet *exceptKey*? */ +function passesFilters( + record: AbstractRecord, + filters: ActiveFilters, + ctx: FacetCellContext, + exceptKey: FacetKey | null = null +): boolean { + for (const [key, options] of filters) { + if (!options.size) continue; + if (key === exceptKey) continue; + const recordValues = valuesFor(record, key as FacetKey, ctx); + let hit = false; + for (const v of recordValues) { + if (options.has(v)) { + hit = true; + break; + } + } + if (!hit) return false; + } + return true; +} + +export function filterByFacets( + abstracts: AbstractRecord[], + filters: ActiveFilters, + ctx: FacetCellContext = EMPTY_CTX +): Set<number> | null { + let active = false; + for (const set of filters.values()) { + if (set.size) { + active = true; + break; + } + } + if (!active) return null; + const out = new Set<number>(); + for (const a of abstracts) { + if (passesFilters(a, filters, ctx)) out.add(a.abstract_id); + } + return out; +} + +export interface FacetOption { + value: string; + count: number; +} + +export type FacetCounts = Map<FacetKey, FacetOption[]>; + +/** + * Compute the per-option counts for every facet, restricted to the abstracts + * that pass `(searchIds ∩ lassoIds ∩ facetsExceptSelf)`. The per-facet + * exception lets the sidebar show "what would happen if I added another + * option from this facet" — selecting Methods=fMRI doesn't zero out every + * other Method count. + */ +export function recomputeFacets( + abstracts: AbstractRecord[], + filters: ActiveFilters, + preFilteredIds: Set<number> | null, + ctx: FacetCellContext = EMPTY_CTX +): FacetCounts { + const out: FacetCounts = new Map(); + for (const key of FACET_KEYS_ORDERED) { + const counts = new Map<string, number>(); + for (const record of abstracts) { + if (preFilteredIds && !preFilteredIds.has(record.abstract_id)) continue; + if (!passesFilters(record, filters, ctx, key)) continue; + for (const v of valuesFor(record, key, ctx)) { + counts.set(v, (counts.get(v) ?? 0) + 1); + } + } + const sorted: FacetOption[] = [...counts.entries()] + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)); + out.set(key, sorted); + } + return out; +} + +/** Helper for the sidebar: toggle a single (facet, option) selection. */ +export function toggleFilter( + filters: ActiveFilters, + key: FacetKey, + option: string +): ActiveFilters { + const next = new Map<string, Set<string>>( + [...filters].map(([k, v]) => [k, new Set(v)]) + ); + const set = next.get(key) ?? new Set(); + if (set.has(option)) set.delete(option); + else set.add(option); + if (set.size) next.set(key, set); + else next.delete(key); + return next; +} + +export function clearAllFilters(): ActiveFilters { + return new Map(); +} diff --git a/site/src/lib/filter.ts b/site/src/lib/filter.ts new file mode 100644 index 00000000..2fcf317c --- /dev/null +++ b/site/src/lib/filter.ts @@ -0,0 +1,262 @@ +import type { AbstractRecord, AuthorRecord } from '$lib/shards'; + +/** Lower-case + accent-fold for case/diacritic-insensitive substring search. */ +export function normalize(value: string): string { + return value + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase(); +} + +/** + * Damerau-Levenshtein distance with early-exit when the running minimum + * exceeds `maxDist`. Returns `maxDist + 1` instead of the full distance in + * that case (cheap signal that's > threshold). + */ +export function damerauLevenshtein(a: string, b: string, maxDist = 2): number { + if (a === b) return 0; + const la = a.length; + const lb = b.length; + if (Math.abs(la - lb) > maxDist) return maxDist + 1; + if (la === 0) return lb; + if (lb === 0) return la; + let prev2: number[] = []; + let prev: number[] = new Array(lb + 1); + let curr: number[] = new Array(lb + 1); + for (let j = 0; j <= lb; j++) prev[j] = j; + for (let i = 1; i <= la; i++) { + curr[0] = i; + let rowMin = i; + for (let j = 1; j <= lb; j++) { + const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min( + curr[j - 1] + 1, // insert + prev[j] + 1, // delete + prev[j - 1] + cost // substitute + ); + // transposition + if ( + i > 1 && + j > 1 && + a.charCodeAt(i - 1) === b.charCodeAt(j - 2) && + a.charCodeAt(i - 2) === b.charCodeAt(j - 1) + ) { + curr[j] = Math.min(curr[j], prev2[j - 2] + 1); + } + if (curr[j] < rowMin) rowMin = curr[j]; + } + if (rowMin > maxDist) return maxDist + 1; + prev2 = prev; + prev = curr; + curr = new Array(lb + 1); + } + return prev[lb]; +} + +/** Tokenize for the inverted index: keep alnum runs ≥ 2 chars after normalization. */ +export function tokenizeForIndex(text: string): string[] { + return normalize(text) + .split(/[^a-z0-9]+/) + .filter((t) => t.length >= 2); +} + +interface InvertedIndex { + tokens: string[]; + postings: Map<string, Set<number>>; +} + +const invertedIndexCache = new WeakMap<AbstractRecord[], InvertedIndex>(); + +function buildInvertedIndex( + abstracts: AbstractRecord[], + authorsById: Map<number, AuthorRecord> +): InvertedIndex { + const cached = invertedIndexCache.get(abstracts); + if (cached) return cached; + const postings = new Map<string, Set<number>>(); + for (const a of abstracts) { + const authorNames = a.author_ids + .map((id) => authorsById.get(id)?.name ?? '') + .filter(Boolean) + .join(' '); + const facetBlob = Object.values(a.facets) + .map((v) => (Array.isArray(v) ? v.join(' ') : (v as string))) + .filter(Boolean) + .join(' '); + // IMPORTANT: section bodies are part of the haystack now. The earlier + // build only indexed metadata (title, topics, methods checklist, + // authors, facets), which meant a query like "pydra" missed the one + // abstract that mentions the tool in its Methods section. + const corpus = [ + a.title, + a.poster_id, + a.topics.primary, + a.topics.primary_subcategory, + a.topics.secondary, + a.topics.secondary_subcategory, + a.methods_checklist.join(' '), + a.sections.introduction, + a.sections.methods, + a.sections.results, + a.sections.conclusion, + authorNames, + facetBlob + ].join(' '); + const seen = new Set<string>(); + for (const tok of tokenizeForIndex(corpus)) { + if (seen.has(tok)) continue; + seen.add(tok); + let postingList = postings.get(tok); + if (!postingList) { + postingList = new Set(); + postings.set(tok, postingList); + } + postingList.add(a.abstract_id); + } + } + const index = { tokens: [...postings.keys()], postings }; + invertedIndexCache.set(abstracts, index); + return index; +} + +/** + * Distance threshold per token length. Tighter than FR-008's "≤2 for ≥4" + * because at 3K abstracts × ~50K unique corpus tokens, a 5-char query like + * "pydra" admits dozens of proximal matches (hydra, pyra, pydry, …) most + * of which aren't what the user wants. Stricter scheme: + * < 4 chars → exact only (DL = 0); too noisy otherwise + * 4–6 chars → DL ≤ 1 ; catches single-typo / transposition (Smtih→Smith) + * ≥ 7 chars → DL ≤ 2 ; matches the FR-008 spec for longer words + */ +function thresholdFor(token: string): number { + const n = token.length; + if (n < 4) return 0; + if (n < 7) return 1; + return 2; +} + +/** + * Typo-tolerant lexical search across the per-abstract inverted index. + * For multi-word queries every query token must match at least one corpus + * token within its Damerau-Levenshtein threshold; the abstract sets are + * intersected so all words contribute. + * + * Returns `null` for an empty query, or a `{ ids, exactness }` pair where + * `exactness` is a per-abstract count of how many query tokens matched + * EXACTLY (not just within DL distance). The UI uses `exactness` to rank + * exact-match abstracts first. + */ +export interface LexicalResult { + ids: Set<number>; + exactness: Map<number, number>; // abstract_id → number of EXACT query-token matches +} + +export function lexicalSearch( + abstracts: AbstractRecord[], + authorsById: Map<number, AuthorRecord>, + query: string +): LexicalResult | null { + const q = normalize(query).trim(); + if (!q) return null; + const queryTokens = tokenizeForIndex(q); + if (queryTokens.length === 0) return { ids: new Set(), exactness: new Map() }; + const index = buildInvertedIndex(abstracts, authorsById); + + // Per-token match sets, paired with per-abstract exact-hit flags. + type PerTokenMatch = { all: Set<number>; exact: Set<number> }; + const perTokenMatches: PerTokenMatch[] = []; + for (const qt of queryTokens) { + const threshold = thresholdFor(qt); + const all = new Set<number>(); + const exact = new Set<number>(); + for (const corpusToken of index.tokens) { + if (Math.abs(corpusToken.length - qt.length) > threshold) continue; + const isExact = corpusToken === qt; + if (isExact || damerauLevenshtein(qt, corpusToken, threshold) <= threshold) { + const posting = index.postings.get(corpusToken); + if (!posting) continue; + for (const id of posting) { + all.add(id); + if (isExact) exact.add(id); + } + } + } + perTokenMatches.push({ all, exact }); + } + if (perTokenMatches.length === 0) return { ids: new Set(), exactness: new Map() }; + + // AND-intersect across per-token sets. + let finalIds = perTokenMatches[0].all; + for (let i = 1; i < perTokenMatches.length; i++) { + const next = new Set<number>(); + const probe = perTokenMatches[i].all; + for (const id of finalIds) if (probe.has(id)) next.add(id); + finalIds = next; + if (finalIds.size === 0) break; + } + + // For each surviving abstract, count how many query tokens matched EXACTLY. + const exactness = new Map<number, number>(); + for (const id of finalIds) { + let n = 0; + for (const m of perTokenMatches) if (m.exact.has(id)) n++; + exactness.set(id, n); + } + return { ids: finalIds, exactness }; +} + +interface SearchHaystack { + abstract_id: number; + haystack: string; +} + +const haystackCache = new WeakMap<AbstractRecord[], SearchHaystack[]>(); + +export function buildHaystacks( + abstracts: AbstractRecord[], + authorsById: Map<number, AuthorRecord> +): SearchHaystack[] { + const cached = haystackCache.get(abstracts); + if (cached) return cached; + const out: SearchHaystack[] = abstracts.map((a) => { + const authorNames = a.author_ids + .map((id) => authorsById.get(id)?.name ?? '') + .filter(Boolean) + .join(' '); + const facetBlob = Object.values(a.facets) + .map((v) => (Array.isArray(v) ? v.join(' ') : (v as string))) + .join(' '); + const haystack = normalize( + [ + a.title, + a.poster_id, + a.topics.primary, + a.topics.primary_subcategory, + a.topics.secondary, + a.topics.secondary_subcategory, + a.methods_checklist.join(' '), + authorNames, + facetBlob + ].join('\n') + ); + return { abstract_id: a.abstract_id, haystack }; + }); + haystackCache.set(abstracts, out); + return out; +} + +/** Substring search across title/poster_id/topics/methods/authors/facets. */ +export function searchAbstracts( + abstracts: AbstractRecord[], + authorsById: Map<number, AuthorRecord>, + query: string +): Set<number> | null { + const q = normalize(query).trim(); + if (!q) return null; + const haystacks = buildHaystacks(abstracts, authorsById); + const out = new Set<number>(); + for (const { abstract_id, haystack } of haystacks) { + if (haystack.includes(q)) out.add(abstract_id); + } + return out; +} diff --git a/site/src/lib/search/semantic.ts b/site/src/lib/search/semantic.ts new file mode 100644 index 00000000..b558693d --- /dev/null +++ b/site/src/lib/search/semantic.ts @@ -0,0 +1,109 @@ +import { loadMinilmVectors } from '$lib/shards'; +import { writable, type Readable } from 'svelte/store'; + +/** + * Main-thread facade for the semantic-search worker. Lazy-initializes the + * worker + transferring the int8 vector buffer + waiting for the + * `Xenova/all-MiniLM-L6-v2` model to finish loading (one-time per browser + * session; the model + its tokenizer are cached by the browser thereafter). + */ + +type SemanticStatus = + | { state: 'idle' } + | { state: 'loading-vectors' } + | { state: 'loading-model' } + | { state: 'ready' } + | { state: 'error'; message: string }; + +const _status = writable<SemanticStatus>({ state: 'idle' }); +export const semanticStatus: Readable<SemanticStatus> = { subscribe: _status.subscribe }; + +let worker: Worker | null = null; +let initPromise: Promise<Worker> | null = null; +let queryCounter = 0; + +export interface SemanticHit { + index: number; + score: number; +} + +async function initWorker(): Promise<Worker> { + if (worker) return worker; + if (initPromise) return initPromise; + initPromise = (async () => { + _status.set({ state: 'loading-vectors' }); + const vectors = await loadMinilmVectors(); + if (!vectors) { + _status.set({ state: 'error', message: 'minilm vectors not available' }); + throw new Error('minilm vectors not available'); + } + const w = new Worker(new URL('../workers/semantic.worker.ts', import.meta.url), { + type: 'module' + }); + const ready = new Promise<void>((resolve, reject) => { + const handler = (e: MessageEvent) => { + const m = e.data; + if (m?.type === 'progress' && m.stage === 'model') { + _status.set({ state: 'loading-model' }); + } else if (m?.type === 'ready') { + w.removeEventListener('message', handler); + resolve(); + } else if (m?.type === 'error') { + w.removeEventListener('message', handler); + reject(new Error(m.message)); + } + }; + w.addEventListener('message', handler); + }); + const buffer = vectors.bytes.buffer.slice( + vectors.bytes.byteOffset, + vectors.bytes.byteOffset + vectors.bytes.byteLength + ); + w.postMessage( + { + type: 'init', + vectors: buffer, + dim: vectors.sidecar.shape[1], + scale: vectors.sidecar.scale + }, + [buffer] + ); + await ready; + worker = w; + _status.set({ state: 'ready' }); + return w; + })().catch((err) => { + _status.set({ state: 'error', message: (err as Error).message }); + initPromise = null; + throw err; + }); + return initPromise; +} + +/** Ensure the worker is initialized; resolves once ready. */ +export async function warmSemantic(): Promise<void> { + await initWorker(); +} + +export async function semanticSearch(query: string, topK = 50): Promise<SemanticHit[]> { + const w = await initWorker(); + const id = `q-${++queryCounter}`; + return new Promise((resolve, reject) => { + const handler = (e: MessageEvent) => { + const m = e.data; + if (m?.type === 'results' && m.id === id) { + w.removeEventListener('message', handler); + const hits: SemanticHit[] = m.indices.map((idx: number, k: number) => ({ + index: idx, + score: m.scores[k] + })); + resolve(hits); + } else if (m?.type === 'error') { + w.removeEventListener('message', handler); + reject(new Error(m.message)); + } + }; + w.addEventListener('message', handler); + w.postMessage({ type: 'query', query, topK, id }); + }); +} diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts new file mode 100644 index 00000000..61bc2aec --- /dev/null +++ b/site/src/lib/shards.ts @@ -0,0 +1,299 @@ +import { base } from '$app/paths'; +import { loadDataPackage } from './data_package'; + +export interface BuildInfo { + corpus_state_key: string; + code_revision: string; + code_revision_short: string; + stage4_rollup_state_key: string; + built_at: string; +} + +/** + * Build-time fallback when the data-package builder hasn't run (e.g. the + * placeholder deploy where Stage 1–4 inputs aren't materialized in CI). The + * Vite env vars `VITE_BUILD_SHA` / `VITE_BUILD_SHA_SHORT` / `VITE_BUILD_AT` + * are populated by the deploy workflow before `pnpm build`. Local dev (no + * env vars set) returns null so the UI doesn't display stale data. + */ +export function buildInfoFromEnv(): BuildInfo | null { + const sha = import.meta.env.VITE_BUILD_SHA; + const short = import.meta.env.VITE_BUILD_SHA_SHORT; + const at = import.meta.env.VITE_BUILD_AT; + if (!sha || !short) return null; + return { + corpus_state_key: 'placeholder', + code_revision: sha, + code_revision_short: short, + stage4_rollup_state_key: 'placeholder', + built_at: at || '' + }; +} + +export interface Manifest { + schema_version: string; + build_info: BuildInfo; + corpus_count: number; + default_cell: { model: string; input: string }; + models: string[]; + inputs: string[]; + cells: Array<{ + cell_key: string; + model: string; + input: string; + shard_url: string; + topic_shards: Record<string, string>; + }>; + facets: Array<{ key: string; label: string; options: string[] }>; + search: { + lexical_index: string; + minilm_vectors: string; + minilm_vectors_build_info_url: string; + minilm_dim: number; + minilm_dtype: string; + }; +} + +export interface AbstractRecord { + abstract_id: number; + poster_id: string; + title: string; + accepted_for: string; + sections: { + introduction: string; + methods: string; + results: string; + conclusion: string; + references: string; + }; + topics: { + primary: string; + primary_subcategory: string; + secondary: string; + secondary_subcategory: string; + }; + methods_checklist: string[]; + facets: Record<string, string | string[]>; + author_ids: number[]; + reference_dois: string[]; + reference_urls: string[]; + reference_titles?: string[]; +} + +export interface AuthorRecord { + author_id: number; + name: string; + affiliations: string[]; + abstract_ids: number[]; +} + +export interface AbstractsShard { + schema_version: string; + build_info: BuildInfo; + abstracts: AbstractRecord[]; +} + +export interface AuthorsShard { + schema_version: string; + build_info: BuildInfo; + authors: AuthorRecord[]; +} + +export interface CellRow { + abstract_id: number; + umap2d: [number, number]; + umap3d: [number, number, number]; + community_id: number; + topic_cluster_id: number; + neuroscape_cluster_id?: number; + neuroscape_cluster_distance?: number; +} + +export interface CellShard { + schema_version: string; + build_info: BuildInfo; + cell_key: string; + rows: CellRow[]; +} + +export interface TopicRecord { + cluster_id: number; + keywords: string[]; + title: string; + description: string; + focus: string; +} + +export interface TopicShard { + schema_version: string; + build_info: BuildInfo; + cell_key: string; + kind: string; + topics: TopicRecord[]; +} + +export interface ClaimRecord { + claim: string; + claim_type?: string; + evidence?: string; + evidence_eco_codes?: string[]; + source?: string; + source_quote_verified?: boolean; +} + +export interface FigureRecord { + interpretation: string; + keywords?: string[]; + ocr_text?: string; + question_name?: string; + model_quality_estimate?: string; +} + +export interface EnrichmentRecord { + claims: ClaimRecord[]; + figures: FigureRecord[]; +} + +export interface EnrichmentShard { + schema_version: string; + build_info: BuildInfo; + ai_provenance: { claims_model_id: string | null; figures_model_id: string | null }; + // key = string(abstract_id) + records: Record<string, EnrichmentRecord>; +} + +export interface NeighborsShard { + schema_version: string; + build_info: BuildInfo; + cell_key: string; + k: number; + abstract_ids: number[]; + nearest_ids: number[][]; + nearest_distances: number[][]; + farthest_ids: number[][]; + farthest_distances: number[][]; +} + +/** + * Per-(shard kind) lookups now read from a single in-memory `Map<path, json>` + * built by `loadDataPackage()` on first paint. The path keys are tar-relative, + * e.g. `data/manifest.json`, `data/cells/voyage_abstract.json`. When the + * data package isn't reachable (no `VITE_DATA_PACKAGE_URL`, CORS failure, + * network drop) every loader returns null — callers fall back to the + * "data unavailable" placeholder. + * + * The `base` import + per-shard fetch URL machinery from prior versions is + * gone: nothing is hosted on the same origin as the app anymore. + */ + +void base; // base no longer used directly; keep import warm for any future relative asset + +async function getFromPackage<T>(path: string): Promise<T | null> { + const pkg = await loadDataPackage(); + if (!pkg) return null; + const v = pkg.get(path); + return (v as T | undefined) ?? null; +} + +export function loadManifest(): Promise<Manifest | null> { + return getFromPackage<Manifest>('data/manifest.json'); +} + +export function loadAbstracts(): Promise<AbstractsShard | null> { + return getFromPackage<AbstractsShard>('data/abstracts.json'); +} + +export function loadAuthors(): Promise<AuthorsShard | null> { + return getFromPackage<AuthorsShard>('data/authors.json'); +} + +export function loadCell(cellKey: string): Promise<CellShard | null> { + return getFromPackage<CellShard>(`data/cells/${cellKey}.json`); +} + +export function loadTopics(cellKey: string, kind: string): Promise<TopicShard | null> { + return getFromPackage<TopicShard>(`data/topics/${cellKey}_${kind}.json`); +} + +export function loadNeighbors(cellKey: string): Promise<NeighborsShard | null> { + return getFromPackage<NeighborsShard>(`data/neighbors/${cellKey}.json`); +} + +/** + * Load every per-cell `data/neighbors/*.json` shard currently in the data + * package, keyed by cell_key. Cheap — the data package is already a Map + * resident in memory after first paint. Used by the detail panel to + * surface a corpus-wide view of related abstracts rather than one biased + * by the active (model, input) cell. + */ +export async function loadAllNeighbors(): Promise<Map<string, NeighborsShard>> { + const pkg = await loadDataPackage(); + const out = new Map<string, NeighborsShard>(); + if (!pkg) return out; + const prefix = 'data/neighbors/'; + for (const [path, v] of pkg) { + if (!path.startsWith(prefix) || !path.endsWith('.json')) continue; + const shard = v as NeighborsShard; + out.set(shard.cell_key, shard); + } + return out; +} + +/** + * Load every per-cell shard + its matching `communities` topics shard so the + * detail panel can render this abstract's cluster membership across all 15 + * (model, input) approaches. Returns a map of cell_key → { cell, topics }. + */ +export async function loadAllCellsWithTopics(): Promise< + Map<string, { cell: CellShard; topics: TopicShard | null }> +> { + const pkg = await loadDataPackage(); + const out = new Map<string, { cell: CellShard; topics: TopicShard | null }>(); + if (!pkg) return out; + const cellPrefix = 'data/cells/'; + for (const [path, v] of pkg) { + if (!path.startsWith(cellPrefix) || !path.endsWith('.json')) continue; + const cell = v as CellShard; + const topicsPath = `data/topics/${cell.cell_key}_communities.json`; + const topics = (pkg.get(topicsPath) as TopicShard | undefined) ?? null; + out.set(cell.cell_key, { cell, topics }); + } + return out; +} + +export function loadEnrichment(): Promise<EnrichmentShard | null> { + return getFromPackage<EnrichmentShard>('data/enrichment.json'); +} + +export interface MinilmVectorsSidecar { + schema_version: string; + build_info: BuildInfo; + shape: [number, number]; + dtype: string; + scale: number; + max_abs_original: number; + components: string[]; + component_state_keys: string[]; + missing_abstract_ids: number[]; + cosine_recovery_mae: number; + byte_offset_url: string; + note?: string; +} + +export async function loadMinilmVectors(): Promise<{ + sidecar: MinilmVectorsSidecar; + bytes: Uint8Array; +} | null> { + const sidecar = await getFromPackage<MinilmVectorsSidecar>( + 'data/search/minilm_vectors.build_info.json' + ); + if (!sidecar) return null; + const pkg = await loadDataPackage(); + const bytes = pkg?.get('data/search/minilm_vectors.bin') as Uint8Array | undefined; + if (!bytes) return null; + return { sidecar, bytes }; +} + +export function resetCachesForTests(): void { + // Caches now live on the data_package module; reset there. +} diff --git a/site/src/lib/stores/cart.ts b/site/src/lib/stores/cart.ts new file mode 100644 index 00000000..6e7ec86e --- /dev/null +++ b/site/src/lib/stores/cart.ts @@ -0,0 +1,83 @@ +import { writable, get } from 'svelte/store'; + +const STORAGE_KEY = 'ohbm2026.ui.cart.v1'; + +function _isBrowser(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function loadInitial(): Set<string> { + if (!_isBrowser()) return new Set(); + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((v) => typeof v === 'string')); + } catch { + return new Set(); + } +} + +function persist(items: Set<string>): void { + if (!_isBrowser()) return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...items])); + } catch { + // localStorage may be unavailable (e.g. private-browsing quota) — silent + // degrade. The store still works in-memory for the session. + } +} + +const _store = writable<Set<string>>(loadInitial()); + +function add(posterId: string): void { + const next = new Set(get(_store)); + next.add(posterId); + _store.set(next); + persist(next); +} + +function remove(posterId: string): void { + const next = new Set(get(_store)); + next.delete(posterId); + _store.set(next); + persist(next); +} + +function addMany(posterIds: Iterable<string>): void { + const next = new Set(get(_store)); + for (const id of posterIds) if (id) next.add(id); + _store.set(next); + persist(next); +} + +function removeMany(posterIds: Iterable<string>): void { + const next = new Set(get(_store)); + for (const id of posterIds) next.delete(id); + _store.set(next); + persist(next); +} + +function clear(): void { + _store.set(new Set()); + persist(new Set()); +} + +function reset(items: Iterable<string> = []): void { + const next = new Set(items); + _store.set(next); + persist(next); +} + +export const cartStore = { + subscribe: _store.subscribe, + add, + remove, + addMany, + removeMany, + clear, + reset +}; + +export const CART_STORAGE_KEY = STORAGE_KEY; diff --git a/site/src/lib/stores/searchMode.ts b/site/src/lib/stores/searchMode.ts new file mode 100644 index 00000000..f48927bf --- /dev/null +++ b/site/src/lib/stores/searchMode.ts @@ -0,0 +1,39 @@ +import { writable, get } from 'svelte/store'; + +const STORAGE_KEY = 'ohbm2026.ui.searchMode.v1'; + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function loadInitial(): boolean { + if (!isBrowser()) return true; + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw === '0') return false; + return true; // default on +} + +const _enabled = writable<boolean>(loadInitial()); + +function setEnabled(value: boolean): void { + _enabled.set(value); + if (isBrowser()) { + try { + window.localStorage.setItem(STORAGE_KEY, value ? '1' : '0'); + } catch { + // silent degrade + } + } +} + +function toggle(): void { + setEnabled(!get(_enabled)); +} + +export const semanticEnabled = { + subscribe: _enabled.subscribe, + set: setEnabled, + toggle +}; + +export const SEMANTIC_STORAGE_KEY = STORAGE_KEY; diff --git a/site/src/lib/stores/selection.ts b/site/src/lib/stores/selection.ts new file mode 100644 index 00000000..9c092c4d --- /dev/null +++ b/site/src/lib/stores/selection.ts @@ -0,0 +1,62 @@ +import { writable } from 'svelte/store'; + +export interface CellSelection { + model: string; + input: string; +} + +export const selectedCell = writable<CellSelection>({ model: 'neuroscape', input: 'abstract' }); + +export const searchQuery = writable<string>(''); + +export const activeFilters = writable<Map<string, Set<string>>>(new Map()); + +export const lassoSelection = writable<Set<number> | null>(null); + +export const focusedAbstract = writable<string | null>(null); + +/** "Show only saved" — restricts the result list to items currently in the + * cart. Pairs with the bulk-add affordance: save a set, flip this on, + * refine. Default off. Not persisted; resets per session. */ +export const cartOnly = writable<boolean>(false); + +/** Active author-name chips. Clicking an author name in any detail view + * adds the name to this set; the result list intersects with abstracts + * whose `author_ids` include any of these names. Render as removable + * chips next to the search bar; clearing them all returns to the + * unfiltered (by author) state. Non-destructive — coexists with the + * search query / facets / lasso. */ +export const authorChips = writable<Set<string>>(new Set()); + +/** Persisted "Show map" panel toggle. Restored from localStorage on + * startup so a browser reload keeps the user's chosen view (the + * Semantic toggle already had this; the map toggle was a plain + * component variable that reset to false on every reload). */ +const SHOW_MAP_STORAGE_KEY = 'ohbm2026.ui.showMap.v1'; + +function loadShowMap(): boolean { + // Default ON — new users land with the UMAP open so the corpus is + // visible at a glance. Stored value wins once the user has toggled, + // so a chosen-OFF state survives reloads. + if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') return true; + try { + const raw = window.localStorage.getItem(SHOW_MAP_STORAGE_KEY); + if (raw === '0') return false; + if (raw === '1') return true; + return true; // no prior choice → default ON + } catch { + return true; + } +} + +const _showMap = writable<boolean>(loadShowMap()); +_showMap.subscribe((v) => { + if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') return; + try { + window.localStorage.setItem(SHOW_MAP_STORAGE_KEY, v ? '1' : '0'); + } catch { + /* private mode / quota — best effort */ + } +}); + +export const showMap = _showMap; diff --git a/site/src/lib/stores/theme.ts b/site/src/lib/stores/theme.ts new file mode 100644 index 00000000..ae79990d --- /dev/null +++ b/site/src/lib/stores/theme.ts @@ -0,0 +1,88 @@ +import { writable, get } from 'svelte/store'; + +export type ThemeChoice = 'light' | 'dark' | 'auto'; + +const STORAGE_KEY = 'ohbm2026.ui.theme.v1'; + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined'; +} + +function loadInitial(): ThemeChoice { + if (!isBrowser()) return 'auto'; + const raw = window.localStorage?.getItem(STORAGE_KEY); + if (raw === 'light' || raw === 'dark' || raw === 'auto') return raw; + return 'auto'; +} + +function systemPref(): 'light' | 'dark' { + if (!isBrowser()) return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +/** + * Compute the effective theme — what `data-theme` should be set to on + * `<html>`. `auto` resolves to the system preference at this moment. + */ +function resolve(choice: ThemeChoice): 'light' | 'dark' { + return choice === 'auto' ? systemPref() : choice; +} + +function applyToDocument(effective: 'light' | 'dark') { + if (!isBrowser()) return; + document.documentElement.setAttribute('data-theme', effective); + document.documentElement.style.colorScheme = effective; +} + +const choiceStore = writable<ThemeChoice>(loadInitial()); +const effectiveStore = writable<'light' | 'dark'>(resolve(get(choiceStore))); + +if (isBrowser()) { + // Apply on first import so SSR-hydration matches. + applyToDocument(get(effectiveStore)); + + // Watch the system preference; only re-render when current choice is auto. + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onSystemChange = () => { + if (get(choiceStore) !== 'auto') return; + const eff = resolve('auto'); + effectiveStore.set(eff); + applyToDocument(eff); + }; + // Modern + legacy listener support. + if (typeof mq.addEventListener === 'function') mq.addEventListener('change', onSystemChange); + else if (typeof mq.addListener === 'function') mq.addListener(onSystemChange); +} + +function setChoice(choice: ThemeChoice): void { + choiceStore.set(choice); + const eff = resolve(choice); + effectiveStore.set(eff); + applyToDocument(eff); + if (isBrowser()) { + try { + window.localStorage.setItem(STORAGE_KEY, choice); + } catch { + // localStorage may be unavailable; silently degrade. + } + } +} + +function cycle(): void { + const order: ThemeChoice[] = ['light', 'dark', 'auto']; + const current = get(choiceStore); + const next = order[(order.indexOf(current) + 1) % order.length]; + setChoice(next); +} + +export const themeChoice = { + subscribe: choiceStore.subscribe, + set: setChoice, + cycle +}; + +export const effectiveTheme = { + subscribe: effectiveStore.subscribe +}; + +export const THEME_STORAGE_KEY = STORAGE_KEY; diff --git a/site/src/lib/stores/tour.ts b/site/src/lib/stores/tour.ts new file mode 100644 index 00000000..34f77fc4 --- /dev/null +++ b/site/src/lib/stores/tour.ts @@ -0,0 +1,126 @@ +import { writable, get } from 'svelte/store'; + +/** + * Guided-tour state machine (US6 / shepherd.js wrapper). + * + * idle → never started in this session + * running → currently walking through tour steps + * dismissed → user closed / completed at least once + * + * Two persistent flags in localStorage at `ohbm2026.ui.tour.v1`: + * cta_dismissed: true once the first-visit banner has been shown + closed + * completed_or_skipped: true once the tour itself was run end-to-end OR + * the user pressed "skip"; controls whether the + * banner is offered on future visits. + */ + +const STORAGE_KEY = 'ohbm2026.ui.tour.v1'; + +export type TourPhase = 'idle' | 'running' | 'dismissed'; + +export interface TourFlags { + ctaDismissed: boolean; + completedOrSkipped: boolean; +} + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function loadFlags(): TourFlags { + if (!isBrowser()) return { ctaDismissed: false, completedOrSkipped: false }; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { ctaDismissed: false, completedOrSkipped: false }; + const parsed = JSON.parse(raw); + return { + ctaDismissed: !!parsed?.ctaDismissed, + completedOrSkipped: !!parsed?.completedOrSkipped + }; + } catch { + return { ctaDismissed: false, completedOrSkipped: false }; + } +} + +function persist(flags: TourFlags): void { + if (!isBrowser()) return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(flags)); + } catch { + /* private mode / quota — best effort */ + } +} + +const _phase = writable<TourPhase>('idle'); +const _step = writable<number>(0); +const _flags = writable<TourFlags>(loadFlags()); + +function start(): void { + _phase.set('running'); + _step.set(0); +} + +function next(): void { + _step.update((s) => s + 1); +} + +function prev(): void { + _step.update((s) => Math.max(0, s - 1)); +} + +function complete(): void { + _phase.set('dismissed'); + _step.set(0); + const next = { ...get(_flags), completedOrSkipped: true }; + _flags.set(next); + persist(next); +} + +function skip(): void { + complete(); +} + +function dismissCta(): void { + const next = { ...get(_flags), ctaDismissed: true }; + _flags.set(next); + persist(next); +} + +/** + * Reset everything — primarily for tests. Production callers should use + * the normal start/skip/complete flow. + */ +function reset(): void { + _phase.set('idle'); + _step.set(0); + const blank = { ctaDismissed: false, completedOrSkipped: false }; + _flags.set(blank); + if (isBrowser()) { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + /* no-op */ + } + } +} + +// Three separately-subscribable stores so `$tourPhase`, `$tourStep`, +// `$tourFlags` work via Svelte's `$store` auto-subscribe shorthand. +export const tourPhase = { subscribe: _phase.subscribe }; +export const tourStep = { subscribe: _step.subscribe }; +export const tourFlags = { subscribe: _flags.subscribe }; + +export const tourStore = { + start, + next, + prev, + complete, + skip, + dismissCta, + reset, + phase: tourPhase, + step: tourStep, + flags: tourFlags +}; + +export const TOUR_STORAGE_KEY = STORAGE_KEY; diff --git a/site/src/lib/workers/semantic.worker.ts b/site/src/lib/workers/semantic.worker.ts new file mode 100644 index 00000000..94d7c497 --- /dev/null +++ b/site/src/lib/workers/semantic.worker.ts @@ -0,0 +1,86 @@ +/// <reference lib="webworker" /> + +/** + * Semantic-search worker. + * + * Boots transformers.js inside a Web Worker (off the main thread), pulls the + * `Xenova/all-MiniLM-L6-v2` ONNX model from the Hugging Face CDN (~23 MB, + * one-time download cached by the browser), and answers query messages by: + * 1. Mean-pooled + L2-normalized embedding of the query text → 384-d float32 + * 2. Cosine similarity against every row of the int8-quantized corpus + * matrix (`[N, 384]`) transferred from the main thread on init. + * 3. Top-K indices (positional → maps to `abstracts.json:abstracts[i]`). + * + * Wire protocol: + * main → worker: { type: 'init', vectors: Uint8Array, dim, scale } + * { type: 'query', query: string, topK: number, id: string } + * worker → main: { type: 'ready' } | { type: 'error', message } + * { type: 'results', id, indices: number[], scores: number[] } + * { type: 'progress', stage: 'model', detail: string } + */ + +import { pipeline, env, type FeatureExtractionPipeline } from '@xenova/transformers'; + +// Disable local-model lookups; we always pull from the Hugging Face CDN. +env.allowLocalModels = false; +env.useBrowserCache = true; + +let extractor: FeatureExtractionPipeline | null = null; +let corpus: Int8Array | null = null; +let dim = 384; +let invScale = 1; // 1 / quantization-scale → multiplied into each dot-product result + +type InitMsg = { type: 'init'; vectors: ArrayBuffer; dim: number; scale: number }; +type QueryMsg = { type: 'query'; query: string; topK: number; id: string }; +type InMsg = InitMsg | QueryMsg; + +const post = (msg: unknown) => (self as unknown as Worker).postMessage(msg); + +self.addEventListener('message', async (e: MessageEvent<InMsg>) => { + const msg = e.data; + try { + if (msg.type === 'init') { + corpus = new Int8Array(msg.vectors); + dim = msg.dim; + invScale = msg.scale > 0 ? 1 / msg.scale : 1; + post({ type: 'progress', stage: 'model', detail: 'loading' }); + extractor = (await pipeline( + 'feature-extraction', + 'Xenova/all-MiniLM-L6-v2' + )) as FeatureExtractionPipeline; + post({ type: 'ready' }); + return; + } + if (msg.type === 'query') { + if (!extractor || !corpus) { + post({ type: 'error', message: 'worker not initialized' }); + return; + } + const output = await extractor(msg.query, { pooling: 'mean', normalize: true }); + const query = output.data as Float32Array; + const n = Math.floor(corpus.length / dim); + const scores = new Float32Array(n); + for (let i = 0; i < n; i++) { + let s = 0; + const off = i * dim; + for (let j = 0; j < dim; j++) s += query[j] * corpus[off + j]; + // Dequantize once per row (faster than per-element). Then clamp + // to [-1, 1] — int8 quantization can perturb the dequantized + // corpus norm slightly above 1, which can push cosine sim above + // 1 (and thus negative cosine distance). Clamp keeps the + // downstream `d = 1 - sim` in [0, 2]. + const raw = s * invScale; + scores[i] = raw > 1 ? 1 : raw < -1 ? -1 : raw; + } + const topK = Math.min(msg.topK || 50, n); + // Heap-pick via partial sort: collect indices, sort by descending score, slice. + const indices = Array.from({ length: n }, (_, i) => i); + indices.sort((a, b) => scores[b] - scores[a]); + const top = indices.slice(0, topK); + const topScores = top.map((i) => scores[i]); + post({ type: 'results', id: msg.id, indices: top, scores: topScores }); + } + } catch (err) { + post({ type: 'error', message: (err as Error).message }); + } +}); diff --git a/site/src/routes/+layout.svelte b/site/src/routes/+layout.svelte new file mode 100644 index 00000000..37cc7328 --- /dev/null +++ b/site/src/routes/+layout.svelte @@ -0,0 +1,251 @@ +<script lang="ts"> + import '../app.css'; + import { onMount } from 'svelte'; + import { base } from '$app/paths'; + import { goto } from '$app/navigation'; + import { buildInfoFromEnv, loadManifest, type BuildInfo, type Manifest } from '$lib/shards'; + import BuildInfoFooter from '$lib/components/BuildInfo.svelte'; + import ThemeToggle from '$lib/components/ThemeToggle.svelte'; + import Tour from '$lib/components/Tour.svelte'; + import { tourStore, tourFlags } from '$lib/stores/tour'; + + let manifest: Manifest | null = null; + const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); + $: dataBuildInfo = manifest?.build_info ?? null; + + const SPA_REDIRECT_KEY = 'ohbm2026.spa.redirect'; + + onMount(async () => { + // Importing the theme store side-effect-initialises the data-theme + // attribute + the system-pref watcher. + await import('$lib/stores/theme'); + + // Deep-link restore. When a user direct-loads e.g. + // `/pr-9/abstract/M-AM-101/`, gh-pages serves the root `404.html` + // (the hand-written SPA-redirect shim on gh-pages). That shim + // stashes the original path in BOTH sessionStorage AND a `?spa=…` + // query param, then redirects to the SPA shell. Query-param wins — + // sessionStorage is unreliable across cold incognito loads on some + // browsers, the query param survives any same-origin redirect. + try { + let stash: string | null = null; + const params = new URLSearchParams(window.location.search); + const fromQuery = params.get('spa'); + if (fromQuery) { + stash = fromQuery; + // Strip the param so it doesn't show up in the address bar + // after goto. replaceState avoids a back-button entry. + params.delete('spa'); + const cleanedSearch = params.toString(); + const cleanedUrl = + window.location.pathname + + (cleanedSearch ? '?' + cleanedSearch : '') + + window.location.hash; + window.history.replaceState({}, '', cleanedUrl); + } + if (!stash) { + stash = sessionStorage.getItem(SPA_REDIRECT_KEY); + } + sessionStorage.removeItem(SPA_REDIRECT_KEY); + if (stash && stash.startsWith('/') && !stash.startsWith('//')) { + // IMPORTANT: SvelteKit's `goto('/foo')` treats a leading + // slash as ORIGIN-absolute (relative to document.baseURI), + // NOT base-aware. If we strip the base path before calling + // goto, the navigation lands outside the SPA's scope — + // in PR-preview mode that means escaping `/pr-N/` and + // loading the production root (the placeholder home page). + // So pass the FULL stash (with base) to goto: SvelteKit + // will recognise it as in-scope and route accordingly. + const currentFull = + window.location.pathname + window.location.search + window.location.hash; + if (stash !== currentFull) { + void goto(stash, { replaceState: true }); + } + } + } catch { + /* sessionStorage / location may be blocked; falling through is fine */ + } + + manifest = await loadManifest(); + }); +</script> + +<svelte:head> + {#if envBuildInfo} + <title>OHBM 2026 Atlas · {envBuildInfo.code_revision_short} + {:else if dataBuildInfo} + OHBM 2026 Atlas · {dataBuildInfo.code_revision_short} + {:else} + OHBM 2026 Atlas + {/if} + + +
+
+
+
+

OHBM 2026 Atlas

+

+ Browse, search, and explore the 2026 accepted abstracts +

+
+
+ + + About + + +
+
+
+ + {#if !$tourFlags.ctaDismissed && !$tourFlags.completedOrSkipped} +
+ + New here? Take a 60-second tour of the search, map, and saved-list features. + + + +
+ {/if} + +
+ +
+ + + + +
+ + diff --git a/site/src/routes/+layout.ts b/site/src/routes/+layout.ts new file mode 100644 index 00000000..2c45d238 --- /dev/null +++ b/site/src/routes/+layout.ts @@ -0,0 +1,5 @@ +// Prerender every route at build time so the page title (and the build_info +// footer) are baked into the static HTML — that lets reviewers verify the +// committish via `view-source` or curl, not only after JS hydration. +export const prerender = true; +export const trailingSlash = 'always'; diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte new file mode 100644 index 00000000..93696b91 --- /dev/null +++ b/site/src/routes/+page.svelte @@ -0,0 +1,677 @@ + + +
+
+
+ + {#if $authorChips.size > 0} +
+ authors: + {#each [...$authorChips] as name (name)} + + {name} + + + {/each} + {#if $authorChips.size > 1} + + {/if} +
+ {/if} +
+ {#if loaded && !dataMissing} +
+ + + + + + +
+ {/if} +
+ + + + {#if $showMap && loaded && !dataMissing} + + {/if} + + {#if !loaded} +

Loading…

+ {:else if dataMissing} +
+

Data package not deployed yet

+ {#if envBuildInfo} +

+ This preview is built from + {envBuildInfo.code_revision_short} + but no data/abstracts.json was found. +

+ {/if} +

+ The deploy workflow runs against the source code; the Stage 1–4 inputs aren't yet + wired into CI via scripts/fetch_ui_inputs.sh. Build locally per + specs/008-ui-rewrite/quickstart.md to exercise the full UI. +

+
+ {:else} +
+
+ +
+
+ +
+
+ {#if focused} + + {:else} + + {/if} +
+
+ {/if} +
+ + diff --git a/site/src/routes/about/+page.svelte b/site/src/routes/about/+page.svelte new file mode 100644 index 00000000..25a67936 --- /dev/null +++ b/site/src/routes/about/+page.svelte @@ -0,0 +1,791 @@ + + + + About · OHBM 2026 Atlas + + +
+ + +
+

About the OHBM 2026 Atlas

+

+ A search-and-browse interface for every accepted OHBM 2026 abstract. Each abstract + is the submitter's own text; everything else on the site — clusters, related-abstract + suggestions, figure interpretations, claim extractions — is computed from those + abstracts by an automated pipeline. The pipeline is open-source and reproducible. +

+
+ +
+

+ Reading 3,000+ abstracts to find the ones you care about isn't realistic for most + people. This atlas tries to make that browsable: a free-text + faceted search, a + 2D + 3D map of the corpus coloured by topic cluster, AI-extracted highlights of each + abstract's claims and figures, and a lightweight saved-list export. +

+

+ The pipeline runs in five stages, listed below. Click each one to see how it works. + Surfaces that were authored or interpreted by an LLM (figure interpretations, + extracted claims, LLM-grouped topic-cluster titles) carry an + ✨ AI pill in the detail panel so the + provenance is always visible. +

+
+ + {#each [ + { key: 'fetch', label: 'Stage 1 — Fetch & normalise (Oxford Abstracts → JSON)' }, + { key: 'enrich', label: 'Stage 2 — AI enrichment (figures + claims + references)' }, + { key: 'embed', label: 'Stage 3 — Embeddings (5 models × per-section)' }, + { key: 'analyse', label: 'Stage 4 — Communities + clusters + UMAP' }, + { key: 'ui', label: 'Stage 6 — This site' } + ] as stage (stage.key)} +
+ + {#if openStages[stage.key]} +
+ {#if stage.key === 'fetch'} +

+ We pull the accepted-abstract corpus from the + + Oxford Abstracts GraphQL API, paginating through every accepted submission. Each record carries + its program-assigned poster id, authors + affiliations, + submitter-typed abstract sections (introduction / methods / results / + conclusion), and the answers to the submission-form "extra questions" + that drive our facets (methods, study type, population, etc.). Withdrawn + submissions never reach this site — they're filtered out at this stage. +

+ + {#if openTldrs[stage.key]} + + {/if} + {:else if stage.key === 'enrich'} +

+ Each abstract is passed to an LLM (currently gpt-5.4-mini) twice: + once to extract structured claims with the + + Evidence and Conclusion Ontology annotating each piece of evidence, and once per figure to produce a + written interpretation. Both outputs are cached by content hash so + re-runs only pay for changed records. References are split out of the + submitter's text via the same LLM, then resolved to canonical DOIs via + + OpenAlex. +

+

+ These two surfaces — figure interpretations and claims — are the only + pieces of the site that are LLM-written. They're always tagged + ✨ AI with the model identifier in the + tooltip so readers can decide how much to trust them. Verbatim + submitter content (abstract sections, topic dropdowns, methods + checklists, authors) carries no such pill — that text is theirs. +

+ + {#if openTldrs[stage.key]} + + {/if} + {:else if stage.key === 'embed'} +

+ We compute sentence-level embeddings for every abstract using five + different encoder families: a public general-purpose model + ( + MiniLM-L6), a domain-specific biomedical model (PubMedBERT), two commercial APIs + (OpenAI, Voyage), and our project-specific NeuroScape model + (Aperture Neuro paper, + + code). Embeddings are computed per section (title / introduction / + methods / results / conclusion / claims) and composed into bundles at + read time, so the UI can show the same corpus through different "lenses". +

+ + {#if openTldrs[stage.key]} + + {/if} + {:else if stage.key === 'analyse'} +

+ For each (model, input) combination we build a UMAP layout + ( + McInnes 2018) in 2D and 3D, run Leiden community detection + ( + Traag 2019) on the nearest-neighbour graph to find topic clusters, and HDBSCAN + ( + McInnes 2017) for a density-based view. An LLM names each community by reading a + representative sample of titles from inside it; those names are what + you see in the "Cluster (current map)" facet and in the UMAP hover + tooltips. +

+

+ The same per-(model, input) bundle drives the "Most similar" and "Most + different" lists in the detail panel — we precompute the 10 nearest + + 10 farthest abstracts per record per cell. The detail panel then + aggregates across all 15 cells so the similar-list reflects every + "lens" rather than just the currently-selected one. +

+ + {#if openTldrs[stage.key]} + + {/if} + {:else if stage.key === 'ui'} +

+ This site is a static SvelteKit app deployed to GitHub Pages. The data + package is a single gzipped tarball fetched from a stable CDN URL at + page load — no server, no database, no per-query backend round-trip. + Lexical typo-tolerant search runs in the main thread; semantic search + runs in a Web Worker using + + MiniLM-L6 ONNX through transformers.js, against an int8-quantised vector matrix + also shipped in the tarball. +

+

+ Source: github.com/sensein/ohbm2026. Build provenance is in the footer of every page. +

+ + {#if openTldrs[stage.key]} + + {/if} + {/if} +
+ {/if} +
+ {/each} +
+ + diff --git a/site/src/routes/abstract/[poster_id]/+page.svelte b/site/src/routes/abstract/[poster_id]/+page.svelte new file mode 100644 index 00000000..7cde4015 --- /dev/null +++ b/site/src/routes/abstract/[poster_id]/+page.svelte @@ -0,0 +1,101 @@ + + + + {#if abstractRecord} + {abstractRecord.poster_id} — {abstractRecord.title} + {:else} + Abstract not found + {/if} + + + + + diff --git a/site/src/routes/abstract/[poster_id]/+page.ts b/site/src/routes/abstract/[poster_id]/+page.ts new file mode 100644 index 00000000..4e9e436d --- /dev/null +++ b/site/src/routes/abstract/[poster_id]/+page.ts @@ -0,0 +1,6 @@ +// Dynamic route — served via the adapter-static SPA fallback (404.html), not +// prerendered. The list of poster_ids is read from the data package at +// runtime, so prerendering would either need to crawl the data package at +// build time (out of scope for the first US1 PR) or enumerate ~3,244 routes. +export const prerender = false; +export const ssr = false; diff --git a/site/src/tests/e2e/accepted-only.spec.ts b/site/src/tests/e2e/accepted-only.spec.ts new file mode 100644 index 00000000..cb110825 --- /dev/null +++ b/site/src/tests/e2e/accepted-only.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; + +const DATA_AVAILABLE = process.env.UI_DATA_AVAILABLE !== '0'; + +test.describe('FR-001 + SC-005: accepted-only invariant', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('no abstract record in the loaded shard has accepted_for == "Withdrawn"', async ({ + page + }) => { + await page.goto('/'); + await expect(page.getByTestId('result-count')).toBeVisible({ timeout: 5000 }); + // After hydration, the home page exposes window.__abstracts for this guard. + const leak = await page.evaluate(() => { + const records = (window as unknown as { __abstracts?: Array<{ accepted_for: string }> }) + .__abstracts; + if (!records) return -1; + return records.filter((r) => r.accepted_for === 'Withdrawn').length; + }); + expect(leak).toBe(0); + }); +}); diff --git a/site/src/tests/e2e/browse.spec.ts b/site/src/tests/e2e/browse.spec.ts new file mode 100644 index 00000000..b923d646 --- /dev/null +++ b/site/src/tests/e2e/browse.spec.ts @@ -0,0 +1,84 @@ +import { test, expect, devices } from '@playwright/test'; + +const DATA_AVAILABLE = process.env.UI_DATA_AVAILABLE !== '0'; + +test.describe('US1: browse + search + detail (desktop)', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('search bar visible within 3s, result cards render, detail opens', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('search-input')).toBeVisible({ timeout: 3000 }); + + // Wait for the result list to hydrate (any non-zero card count). + await expect(page.getByTestId('result-count')).toBeVisible(); + const initialText = (await page.getByTestId('result-count').textContent())?.trim(); + expect(initialText).toMatch(/^\d+$/); + expect(Number(initialText)).toBeGreaterThan(100); + + // Type a known-good query. + await page.getByTestId('search-input').fill('connectivity'); + await expect(async () => { + const t = (await page.getByTestId('result-count').textContent())?.trim(); + expect(Number(t)).toBeGreaterThan(0); + }).toPass({ timeout: 2000 }); + + // Click the first result. + await page.getByTestId('result-card').first().click(); + + // Detail panel renders the poster_id (NOT the submission_id) as the header. + await expect(page.getByTestId('detail-panel')).toBeVisible(); + const headerPosterId = (await page.getByTestId('detail-poster-id').textContent())?.trim(); + expect(headerPosterId).toBeTruthy(); + + // The clicked card's poster_id (the program-assigned id) must equal what + // the detail header renders. Submission ids in this corpus are 7-digit + // integers (e.g. 1176971); poster_ids are program tags like M-AM-101 or + // 0503. Catch the regression where the panel accidentally falls back to + // the submission id by checking the card→panel pairing matches. + const card = page.getByTestId('result-card').first(); + const cardPosterId = await card.getAttribute('data-poster-id'); + expect(cardPosterId).toBe(headerPosterId); + // Submission ids in this corpus are >= 1,000,000 — assert the displayed + // poster_id isn't a raw submission id. + expect(headerPosterId!.length).toBeLessThan(7); + }); +}); + +test.describe('US1: mobile layout (360 × 640 — SC-004 minimum)', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('home page has no horizontal scroll on the SC-004 viewport', async ({ browser }) => { + const context = await browser.newContext({ + viewport: { width: 360, height: 640 }, + userAgent: devices['Pixel 5'].userAgent + }); + const page = await context.newPage(); + try { + await page.goto('/'); + await expect(page.getByTestId('search-input')).toBeVisible({ timeout: 3000 }); + const scroll = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth + })); + expect(scroll.scrollWidth).toBeLessThanOrEqual(scroll.clientWidth + 1); + } finally { + await context.close(); + } + }); +}); + +test.describe('US1: build provenance (FR-022 / SC-011)', () => { + test('footer carries the build_info short SHA on every route', async ({ page }) => { + await page.goto('/'); + const footer = page.getByTestId('build-info-footer'); + await expect(footer).toBeVisible(); + const shortSha = await page.getByTestId('build-info-short-sha').textContent(); + expect(shortSha).toMatch(/^[0-9a-f]{7}$/); + }); + + test('placeholder route also surfaces the build short SHA in the title', async ({ page }) => { + await page.goto('/'); + const title = await page.title(); + expect(title).toMatch(/OHBM 2026 Atlas · [0-9a-f]{7}/); + }); +}); diff --git a/site/src/tests/e2e/detail-extra-fields.spec.ts b/site/src/tests/e2e/detail-extra-fields.spec.ts new file mode 100644 index 00000000..ce570d3a --- /dev/null +++ b/site/src/tests/e2e/detail-extra-fields.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; + +const DATA_AVAILABLE = process.env.UI_DATA_AVAILABLE !== '0'; + +/** + * T036a / FR-011 — the detail panel surfaces **only** the Topics + Methods + * extra-question fields. Other submission-form extras (study_type, + * population, field_strength, processing_packages, species, etc.) live in + * `facets` for filtering but MUST NOT render in the detail panel. + * + * Negative test: scan the panel's DOM for any leakage of the other facet + * keys. The fixture-style assertion here scans the live corpus instead of a + * synthetic abstract — that catches both component bugs and accidental + * shard-schema regressions. + */ +test.describe('FR-011 — detail panel renders only Topics + Methods extras', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('opening any abstract shows topics + methods + no other facet keys as headings', async ({ + page + }) => { + await page.goto('/'); + await expect(page.getByTestId('result-card').first()).toBeVisible({ timeout: 5000 }); + await page.getByTestId('result-card').first().click(); + await expect(page.getByTestId('detail-panel')).toBeVisible(); + + // Allowed extra-question sections — at least one must be visible. + const topicsCount = await page.getByTestId('extra-topics').count(); + const methodsCount = await page.getByTestId('extra-methods').count(); + expect(topicsCount + methodsCount).toBeGreaterThan(0); + + // Forbidden extra-question keys must NOT appear as testid'd blocks. + const forbidden = [ + 'extra-study_type', + 'extra-population', + 'extra-field_strength', + 'extra-processing_packages', + 'extra-species', + 'extra-recording_technology', + 'extra-brain_regions', + 'extra-brain_networks', + 'extra-keywords' + ]; + for (const key of forbidden) { + await expect(page.getByTestId(key)).toHaveCount(0); + } + + // Defensive: the rendered

headings inside the detail panel are + // limited to Authors / Introduction / Methods / Results / Conclusion / + // Topics / Methods (the checklist) / References. + const headings = await page.getByTestId('detail-panel').locator('h2').allTextContents(); + const allowed = new Set([ + 'Authors', + 'Introduction', + 'Methods', + 'Results', + 'Conclusion', + 'Topics', + 'References' + ]); + for (const h of headings) { + expect(allowed.has(h.trim())).toBe(true); + } + }); +}); diff --git a/site/src/tests/e2e/mobile-check.spec.ts b/site/src/tests/e2e/mobile-check.spec.ts new file mode 100644 index 00000000..b2cf4a8c --- /dev/null +++ b/site/src/tests/e2e/mobile-check.spec.ts @@ -0,0 +1,81 @@ +import { test, chromium, devices } from '@playwright/test'; + +test.setTimeout(120_000); + +const probes = [ + { label: 'iSE-1st-portrait', viewport: { width: 320, height: 568 }, mobile: true }, + { label: 'iSE-2nd-portrait', viewport: { width: 375, height: 667 }, mobile: true }, + { label: 'iSE-1st-landscape', viewport: { width: 568, height: 320 }, mobile: true }, + { label: 'iSE-2nd-landscape', viewport: { width: 667, height: 375 }, mobile: true }, + { label: 'tablet-portrait', viewport: { width: 768, height: 1024 }, mobile: true }, + { label: 'desktop-landscape', viewport: { width: 1440, height: 900 }, mobile: false } +]; + +const BASE = 'https://abstractatlas.brainkb.org/pr-9'; + +async function waitForRecentDeploy(page: any, expectedSha: string) { + for (let i = 0; i < 30; i++) { + const sha = await page.locator('[data-testid="build-info-short-sha"]').first().textContent().catch(() => null); + if (sha && sha.trim() === expectedSha) return true; + await page.waitForTimeout(2000); + await page.reload({ waitUntil: 'load' }); + } + return false; +} + +test('multi-viewport overflow probe', async () => { + const browser = await chromium.launch(); + const expectedSha = process.env.EXPECTED_SHA || 'c408504'; + for (const probe of probes) { + const ctx = await browser.newContext({ viewport: probe.viewport, isMobile: probe.mobile, deviceScaleFactor: probe.mobile ? 2 : 1 }); + const page = await ctx.newPage(); + await page.goto(`${BASE}/`, { waitUntil: 'load' }); + await page.waitForSelector('[data-testid="search-input"]', { timeout: 30000 }); + // Wait until deploy SHA matches (or timeout silently) + await waitForRecentDeploy(page, expectedSha); + await page.waitForSelector('[data-testid="result-card"]', { timeout: 30000 }); + await page.waitForTimeout(2000); + + const scroll = await page.evaluate(() => ({ + docW: document.documentElement.scrollWidth, + viewW: document.documentElement.clientWidth, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth + })); + console.log(`${probe.label} (${probe.viewport.width}x${probe.viewport.height}): docW=${scroll.docW} viewW=${scroll.viewW} overflow=${scroll.overflow}`); + + if (scroll.overflow > 0) { + const widest = await page.evaluate(() => { + const viewW = document.documentElement.clientWidth; + const out: any[] = []; + for (const el of document.querySelectorAll('*')) { + const r = (el as HTMLElement).getBoundingClientRect(); + if (r.right > viewW + 1 && r.width < 2000) { + out.push({ tag: el.tagName, tid: (el as HTMLElement).getAttribute('data-testid'), w: Math.round(r.width), right: Math.round(r.right) }); + } + } + return out.sort((a,b)=>b.right-a.right).slice(0, 8); + }); + console.log(` overflowing on ${probe.label}:`); + for (const e of widest) console.log(` <${e.tag}> tid=${e.tid} w=${e.w} right=${e.right}`); + } + await page.screenshot({ path: `/tmp/probe-${probe.label}.png`, fullPage: false }); + + // Also tap a card to see the detail layout + const card = page.locator('[data-testid="result-card"]').first(); + const posterId = await card.getAttribute('data-poster-id'); + await card.click(); + await page.waitForSelector('[data-testid="detail-panel"]', { timeout: 8000 }).catch(() => null); + await page.waitForTimeout(800); + await page.screenshot({ path: `/tmp/probe-${probe.label}-detail.png`, fullPage: false }); + + if (posterId && !probe.mobile) { + const dp = await ctx.newPage(); + await dp.goto(`${BASE}/abstract/${encodeURIComponent(posterId)}/`, { waitUntil: 'load' }); + await dp.waitForSelector('[data-testid="detail-panel"]', { timeout: 30000 }); + await dp.waitForTimeout(1500); + await dp.screenshot({ path: `/tmp/probe-${probe.label}-permalink.png`, fullPage: false }); + } + await ctx.close(); + } + await browser.close(); +}); diff --git a/site/src/tests/e2e/umap.spec.ts b/site/src/tests/e2e/umap.spec.ts new file mode 100644 index 00000000..d6020df6 --- /dev/null +++ b/site/src/tests/e2e/umap.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from '@playwright/test'; + +const DATA_AVAILABLE = process.env.UI_DATA_AVAILABLE !== '0'; + +test.describe('US2: UMAP panel + lasso + model selector', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('opens map; lazy-loads Plotly; both 2D + 3D charts render side-by-side', async ({ + page + }) => { + await page.goto('/'); + await expect(page.getByTestId('result-count')).toBeVisible({ timeout: 5000 }); + await expect(page.getByTestId('umap-panel')).toHaveCount(0); + + await page.getByTestId('toggle-map').click(); + await expect(page.getByTestId('umap-panel')).toBeVisible(); + await expect(page.getByTestId('umap-chart-2d')).toBeVisible(); + await expect(page.getByTestId('umap-chart-3d')).toBeVisible(); + + // Plotly lazy-loads — wait for the 2D pane to render an SVG/canvas. + await expect + .poll( + async () => + page + .locator('[data-testid="umap-chart-2d"] svg, [data-testid="umap-chart-2d"] canvas') + .count(), + { timeout: 15000 } + ) + .toBeGreaterThan(0); + // 3D pane renders a WebGL canvas inside `.gl-container`. + await expect + .poll( + async () => + page.locator('[data-testid="umap-chart-3d"] canvas').count(), + { timeout: 15000 } + ) + .toBeGreaterThan(0); + }); + + test('rotate toggle pauses / resumes the 3D animation', async ({ page }) => { + await page.goto('/'); + await page.getByTestId('toggle-map').click(); + await expect(page.getByTestId('umap-chart-3d')).toBeVisible(); + const btn = page.getByTestId('umap-rotate-toggle'); + await expect(btn).toBeVisible(); + // Initial state: rotating (aria-pressed=true, label "⏸ pause"). + await expect(btn).toHaveAttribute('aria-pressed', 'true'); + await btn.click(); + await expect(btn).toHaveAttribute('aria-pressed', 'false'); + await btn.click(); + await expect(btn).toHaveAttribute('aria-pressed', 'true'); + }); + + test('lasso selection (simulated) updates the result list count', async ({ page }) => { + await page.goto('/'); + await page.getByTestId('toggle-map').click(); + await expect(page.getByTestId('umap-chart-2d')).toBeVisible(); + await expect + .poll( + async () => + page + .locator('[data-testid="umap-chart-2d"] svg, [data-testid="umap-chart-2d"] canvas') + .count(), + { timeout: 15000 } + ) + .toBeGreaterThan(0); + + const initialCount = Number( + (await page.getByTestId('result-count').textContent())?.trim() + ); + expect(initialCount).toBeGreaterThan(100); + + const ok = await page.evaluate(() => { + const el = document.querySelector('[data-testid="umap-chart-2d"]') as unknown as { + emit?: (event: string, payload: unknown) => void; + } | null; + if (!el?.emit) return false; + el.emit('plotly_selected', { + points: [{ pointIndex: 0 }, { pointIndex: 1 }, { pointIndex: 2 }] + }); + return true; + }); + expect(ok).toBe(true); + + const clear = page.getByTestId('umap-clear-lasso'); + await expect(clear).toBeVisible({ timeout: 3000 }); + // 3-point lasso → 3 abstracts selected → result-count drops to 3. + await expect(page.getByTestId('result-count')).toHaveText('3', { timeout: 3000 }); + await clear.click(); + await expect(clear).toHaveCount(0); + }); +}); diff --git a/site/src/tests/setup.ts b/site/src/tests/setup.ts new file mode 100644 index 00000000..32654bba --- /dev/null +++ b/site/src/tests/setup.ts @@ -0,0 +1,48 @@ +/** + * Vitest setup — Node 25 added a built-in `localStorage` global that lacks + * `removeItem` / `clear`. When jsdom is also enabled, Node's incomplete shim + * still wins. This polyfill replaces both with a Map-backed implementation + * that's reset between test runs. + */ +class MemoryStorage implements Storage { + private store = new Map(); + get length(): number { + return this.store.size; + } + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + getItem(key: string): string | null { + return this.store.has(key) ? this.store.get(key)! : null; + } + setItem(key: string, value: string): void { + this.store.set(key, String(value)); + } + removeItem(key: string): void { + this.store.delete(key); + } + clear(): void { + this.store.clear(); + } +} + +Object.defineProperty(globalThis, 'localStorage', { + value: new MemoryStorage(), + writable: true, + configurable: true +}); +Object.defineProperty(globalThis, 'sessionStorage', { + value: new MemoryStorage(), + writable: true, + configurable: true +}); +Object.defineProperty(window, 'localStorage', { + value: globalThis.localStorage, + writable: true, + configurable: true +}); +Object.defineProperty(window, 'sessionStorage', { + value: globalThis.sessionStorage, + writable: true, + configurable: true +}); diff --git a/site/src/tests/unit/cart.test.ts b/site/src/tests/unit/cart.test.ts new file mode 100644 index 00000000..a2acdc18 --- /dev/null +++ b/site/src/tests/unit/cart.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { get } from 'svelte/store'; +import { cartStore, CART_STORAGE_KEY } from '$lib/stores/cart'; + +describe('cartStore', () => { + afterEach(() => { + cartStore.reset(); + window.localStorage.removeItem(CART_STORAGE_KEY); + }); + + it('starts empty', () => { + expect(get(cartStore).size).toBe(0); + }); + + it('add → contains the poster id', () => { + cartStore.add('M-AM-101'); + expect(get(cartStore).has('M-AM-101')).toBe(true); + }); + + it('add is idempotent (set semantics)', () => { + cartStore.add('M-AM-101'); + cartStore.add('M-AM-101'); + expect(get(cartStore).size).toBe(1); + }); + + it('remove drops the poster id', () => { + cartStore.add('M-AM-101'); + cartStore.add('M-AM-102'); + cartStore.remove('M-AM-101'); + expect(get(cartStore).has('M-AM-101')).toBe(false); + expect(get(cartStore).has('M-AM-102')).toBe(true); + }); + + it('clear empties the cart', () => { + cartStore.add('M-AM-101'); + cartStore.add('M-AM-102'); + cartStore.clear(); + expect(get(cartStore).size).toBe(0); + }); + + it('persists to localStorage', () => { + cartStore.add('M-AM-101'); + cartStore.add('M-AM-102'); + const raw = window.localStorage.getItem(CART_STORAGE_KEY); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(new Set(parsed)).toEqual(new Set(['M-AM-101', 'M-AM-102'])); + }); + + it('clear wipes the persisted payload', () => { + cartStore.add('M-AM-101'); + cartStore.clear(); + const raw = window.localStorage.getItem(CART_STORAGE_KEY); + expect(JSON.parse(raw!)).toEqual([]); + }); + + it('addMany unions multiple poster ids in one update', () => { + cartStore.add('M-AM-100'); + cartStore.addMany(['M-AM-101', 'M-AM-102', 'M-AM-100']); // 100 already present + expect(get(cartStore)).toEqual(new Set(['M-AM-100', 'M-AM-101', 'M-AM-102'])); + }); + + it('removeMany deletes only the listed poster ids', () => { + cartStore.addMany(['a', 'b', 'c', 'd']); + cartStore.removeMany(['b', 'd', 'zzz']); // unknown id is a no-op + expect(get(cartStore)).toEqual(new Set(['a', 'c'])); + }); +}); diff --git a/site/src/tests/unit/cart_email.test.ts b/site/src/tests/unit/cart_email.test.ts new file mode 100644 index 00000000..3ff4e1ab --- /dev/null +++ b/site/src/tests/unit/cart_email.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { + buildMailtoLink, + buildPlainTextList, + MAX_MAILTO_LENGTH +} from '$lib/cart_email'; +import type { AbstractRecord } from '$lib/shards'; + +function rec(id: number, poster: string, title: string): AbstractRecord { + return { + abstract_id: id, + poster_id: poster, + title, + accepted_for: 'Poster', + sections: { introduction: '', methods: '', results: '', conclusion: '', references: '' }, + topics: { primary: '', primary_subcategory: '', secondary: '', secondary_subcategory: '' }, + methods_checklist: [], + facets: {}, + author_ids: [], + reference_dois: [], + reference_urls: [], + reference_titles: [] + }; +} + +describe('buildMailtoLink', () => { + it('produces a mailto: URL with the standard subject', () => { + const url = buildMailtoLink([rec(1, 'M-AM-101', 'Memory in aging')], new Map(), { + siteUrl: 'https://example.org/atlas' + }); + expect(url.startsWith('mailto:?subject=')).toBe(true); + expect(decodeURIComponent(url)).toContain('My OHBM 2026 abstract list'); + }); + + it('embeds each abstract as poster_id + title + permalink', () => { + const items = [rec(1, 'M-AM-101', 'A'), rec(2, 'M-AM-102', 'B')]; + const url = buildMailtoLink(items, new Map(), { siteUrl: 'https://example.org/atlas' }); + const body = decodeURIComponent(url.split('&body=')[1]); + expect(body).toContain('M-AM-101'); + expect(body).toContain('M-AM-102'); + expect(body).toContain('https://example.org/atlas/abstract/M-AM-101/'); + expect(body).toContain('https://example.org/atlas/abstract/M-AM-102/'); + }); + + it('includes lead author when provided', () => { + const items = [rec(1, 'M-AM-101', 'Memory in aging')]; + const leads = new Map([[1, 'José García']]); + const url = buildMailtoLink(items, leads, { siteUrl: 'https://example.org' }); + const body = decodeURIComponent(url.split('&body=')[1]); + expect(body).toContain('— José García'); + }); + + it('caps the URL length at the mailto budget and inserts a truncation marker', () => { + // Manufacture 500 fake abstracts; the cap kicks in long before the end. + const many = Array.from({ length: 500 }, (_, i) => + rec(i, `P${i.toString().padStart(4, '0')}`, `Abstract title number ${i} — a longish placeholder so each line eats bytes`) + ); + const url = buildMailtoLink(many, new Map(), { siteUrl: 'https://example.org/atlas' }); + expect(url.length).toBeLessThanOrEqual(MAX_MAILTO_LENGTH); + const body = decodeURIComponent(url.split('&body=')[1]); + expect(body).toContain('more items not shown'); + }); + + it('handles an empty cart gracefully', () => { + const url = buildMailtoLink([], new Map(), { siteUrl: 'https://example.org' }); + expect(url.startsWith('mailto:?')).toBe(true); + const body = decodeURIComponent(url.split('&body=')[1]); + expect(body).toContain('(0 items)'); + }); + + it('puts each item on its own numbered block with a labelled Open link', () => { + const items = [rec(1, 'M-AM-101', 'Memory in aging'), rec(2, 'M-AM-102', 'Vision')]; + const url = buildMailtoLink(items, new Map(), { siteUrl: 'https://example.org/atlas' }); + const body = decodeURIComponent(url.split('&body=')[1]); + expect(body).toContain('1. [M-AM-101] Memory in aging'); + expect(body).toContain('2. [M-AM-102] Vision'); + expect(body).toContain('→ Open: https://example.org/atlas/abstract/M-AM-101/'); + expect(body).toContain('→ Open: https://example.org/atlas/abstract/M-AM-102/'); + expect(body).toContain('Browse the rest at https://example.org/atlas/'); + }); + + it('respects custom subject', () => { + const url = buildMailtoLink([], new Map(), { + siteUrl: 'https://example.org', + subject: 'Hand-picked for you' + }); + expect(url).toContain('subject=Hand-picked%20for%20you'); + }); +}); + +describe('buildPlainTextList', () => { + it('produces a clipboard-friendly plain-text rendering', () => { + const items = [rec(1, 'M-AM-101', 'Memory in aging')]; + const txt = buildPlainTextList(items, new Map(), 'https://example.org'); + expect(txt).toContain('M-AM-101'); + expect(txt).toContain('Memory in aging'); + expect(txt).toContain('https://example.org/abstract/M-AM-101/'); + }); +}); diff --git a/site/src/tests/unit/filter.test.ts b/site/src/tests/unit/filter.test.ts new file mode 100644 index 00000000..07148fd0 --- /dev/null +++ b/site/src/tests/unit/filter.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { searchAbstracts, normalize } from '$lib/filter'; +import type { AbstractRecord, AuthorRecord } from '$lib/shards'; + +const author: AuthorRecord = { + author_id: 0, + name: 'José García', + affiliations: ['UAM Madrid'], + abstract_ids: [1001] +}; + +const abstracts: AbstractRecord[] = [ + { + abstract_id: 1001, + poster_id: 'M-AM-101', + title: 'Memory fMRI in aging', + accepted_for: 'Poster', + sections: { + introduction: '', + methods: '', + results: '', + conclusion: '', + references: '' + }, + topics: { + primary: 'Lifespan Development', + primary_subcategory: 'Aging', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: { keywords: ['Aging', 'MRI'], methods: ['Functional MRI'] }, + author_ids: [0], + reference_dois: [], + reference_urls: [] + }, + { + abstract_id: 1003, + poster_id: 'M-AM-103', + title: 'Default mode network in fMRI', + accepted_for: 'Oral', + sections: { + introduction: '', + methods: '', + results: '', + conclusion: '', + references: '' + }, + topics: { + primary: 'Cognition', + primary_subcategory: 'Memory', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: { keywords: ['DMN', 'resting-state'] }, + author_ids: [], + reference_dois: [], + reference_urls: [] + } +]; + +const authorsById = new Map([[0, author]]); + +describe('searchAbstracts', () => { + it('returns null for empty query', () => { + expect(searchAbstracts(abstracts, authorsById, '')).toBeNull(); + expect(searchAbstracts(abstracts, authorsById, ' ')).toBeNull(); + }); + + it('matches title substring', () => { + // "memory" appears in the title of 1001 ("Memory fMRI in aging") and in the + // secondary topic of 1003 ("Memory") — both are reachable from the haystack. + const ids = searchAbstracts(abstracts, authorsById, 'memory fmri'); + expect(ids).toEqual(new Set([1001])); + }); + + it('matches poster_id', () => { + const ids = searchAbstracts(abstracts, authorsById, 'AM-103'); + expect(ids).toEqual(new Set([1003])); + }); + + it('matches author name', () => { + const ids = searchAbstracts(abstracts, authorsById, 'García'); + expect(ids).toEqual(new Set([1001])); + }); + + it('is diacritic-insensitive (FR-010)', () => { + const ids = searchAbstracts(abstracts, authorsById, 'Garcia'); + expect(ids).toEqual(new Set([1001])); + }); + + it('matches facet values', () => { + const ids = searchAbstracts(abstracts, authorsById, 'DMN'); + expect(ids).toEqual(new Set([1003])); + }); + + it('empty result set when no match', () => { + const ids = searchAbstracts(abstracts, authorsById, 'xyzpdq'); + expect(ids).toEqual(new Set()); + }); +}); + +describe('normalize', () => { + it('lowercases + strips diacritics', () => { + expect(normalize('García')).toBe('garcia'); + expect(normalize('JOSÉ')).toBe('jose'); + }); +}); diff --git a/site/src/tests/unit/lexical.test.ts b/site/src/tests/unit/lexical.test.ts new file mode 100644 index 00000000..c8fdaca0 --- /dev/null +++ b/site/src/tests/unit/lexical.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { damerauLevenshtein, lexicalSearch, tokenizeForIndex } from '$lib/filter'; +import type { AbstractRecord, AuthorRecord } from '$lib/shards'; + +describe('damerauLevenshtein', () => { + it('returns 0 for identical strings', () => { + expect(damerauLevenshtein('memory', 'memory')).toBe(0); + }); + + it('counts a single substitution', () => { + expect(damerauLevenshtein('memory', 'memora')).toBe(1); + }); + + it('counts a single deletion', () => { + expect(damerauLevenshtein('memory', 'memry')).toBe(1); + }); + + it('counts a single transposition (Damerau)', () => { + // "smtih" → "smith" is one adjacent transposition + expect(damerauLevenshtein('smtih', 'smith')).toBe(1); + }); + + it('early-exits past the threshold', () => { + // Garbage query vs corpus token — must be > 2 + expect(damerauLevenshtein('xyzpdq', 'memory', 2)).toBe(3); + }); + + it('rejects pairs whose length difference exceeds the threshold', () => { + expect(damerauLevenshtein('mem', 'memorial', 2)).toBe(3); + }); +}); + +describe('tokenizeForIndex', () => { + it('lowercases + accent-folds + drops 1-char tokens', () => { + expect(tokenizeForIndex('Memory fMRI in García')).toEqual(['memory', 'fmri', 'in', 'garcia']); + }); + + it('handles punctuation as delimiters', () => { + expect(tokenizeForIndex('default-mode network!')).toEqual(['default', 'mode', 'network']); + }); +}); + +const author: AuthorRecord = { + author_id: 0, + name: 'José García', + affiliations: ['UAM'], + abstract_ids: [1001] +}; + +const abstracts: AbstractRecord[] = [ + { + abstract_id: 1001, + poster_id: 'M-AM-101', + title: 'Memory fMRI in aging', + accepted_for: 'Poster', + sections: { introduction: '', methods: '', results: '', conclusion: '', references: '' }, + topics: { + primary: 'Lifespan Development', + primary_subcategory: 'Aging', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: { keywords: ['Aging', 'MRI'], methods: ['Functional MRI'] }, + author_ids: [0], + reference_dois: [], + reference_urls: [] + }, + { + abstract_id: 1003, + poster_id: 'M-AM-103', + title: 'Default mode network in fMRI', + accepted_for: 'Oral', + sections: { introduction: '', methods: '', results: '', conclusion: '', references: '' }, + topics: { + primary: 'Cognition', + primary_subcategory: 'Memory', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: { keywords: ['DMN', 'resting-state'] }, + author_ids: [], + reference_dois: [], + reference_urls: [] + } +]; + +const authorsById = new Map([[0, author]]); + +describe('lexicalSearch (FR-008 typo tolerance)', () => { + it('returns null for empty query', () => { + expect(lexicalSearch(abstracts, authorsById, '')).toBeNull(); + }); + + it('matches an exact token', () => { + const result = lexicalSearch(abstracts, authorsById, 'aging'); + expect(result?.ids).toEqual(new Set([1001])); + // "aging" is an exact match in abstract 1001 → exactness count = 1. + expect(result?.exactness.get(1001)).toBe(1); + }); + + it('matches a single-substitution typo on a long word', () => { + // "aginq" → "aging" (1 substitution; threshold = 1 for length 5 under + // the tightened scheme). + const result = lexicalSearch(abstracts, authorsById, 'aginq'); + expect(result?.ids).toEqual(new Set([1001])); + // Fuzzy hit, NOT exact → exactness should be 0. + expect(result?.exactness.get(1001) ?? 0).toBe(0); + }); + + it('matches the FR-008 example: 2-typo query "defautl mode netwrk" → "default mode network"', () => { + // All three query tokens must hit the abstract (AND across tokens). + // Lengths: defautl=7 (thr 2), mode=4 (thr 1), netwrk=6 (thr 1). + // "defautl"→"default" = 1 transposition (DL 1 ≤ 2) ✓ + // "mode" exact ✓; "netwrk"→"network" = 1 deletion (DL 1 ≤ 1) ✓ + const result = lexicalSearch(abstracts, authorsById, 'defautl mode netwrk'); + expect(result?.ids).toEqual(new Set([1003])); + // "mode" hits exactly; the other two are typo-corrected → exactness = 1. + expect(result?.exactness.get(1003)).toBe(1); + }); + + it('matches the FR-010 example: diacritic-folded surname', () => { + // "Garcia" tokenizes to ["garcia"]; corpus has the NFD-folded "garcia" + // from "García". Length 6 → threshold 1; exact hit after folding. + const result = lexicalSearch(abstracts, authorsById, 'Garcia'); + expect(result?.ids).toEqual(new Set([1001])); + }); + + it('intersects across multiple query tokens', () => { + const result = lexicalSearch(abstracts, authorsById, 'memory mri'); + expect((result?.ids.size ?? 0)).toBeGreaterThan(0); + }); + + it('returns an empty set for queries that match nothing', () => { + const result = lexicalSearch(abstracts, authorsById, 'xyzpdqzzz'); + expect(result?.ids).toEqual(new Set()); + expect(result?.exactness.size).toBe(0); + }); + + it('ranks the exact-match abstract above fuzzy proximal matches', () => { + // Regression guard for the "pydra" issue: an exact-token hit must + // produce a strictly higher exactness count than any fuzzy hit so the + // UI can sort it to the top. + const result = lexicalSearch(abstracts, authorsById, 'aging'); + expect(result).not.toBeNull(); + // Only the abstract with the literal word should have exactness ≥ 1. + const counts = [...result!.exactness.values()]; + expect(Math.max(0, ...counts)).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/site/src/tests/unit/shards.test.ts b/site/src/tests/unit/shards.test.ts new file mode 100644 index 00000000..40b797bc --- /dev/null +++ b/site/src/tests/unit/shards.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + loadAbstracts, + loadAuthors, + loadCell, + loadManifest, + loadTopics, + type AbstractsShard, + type AuthorsShard, + type CellShard, + type Manifest, + type TopicShard +} from '$lib/shards'; +import * as dataPackage from '$lib/data_package'; + +const BUILD_INFO = { + corpus_state_key: 'test12345678', + code_revision: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + code_revision_short: 'a1b2c3d', + stage4_rollup_state_key: 'test12345678', + built_at: '2026-05-17T00:00:00+00:00' +}; + +const MANIFEST: Manifest = { + schema_version: 'ui.v1', + build_info: BUILD_INFO, + corpus_count: 1, + default_cell: { model: 'neuroscape', input: 'abstract' }, + models: ['neuroscape'], + inputs: ['abstract'], + cells: [], + facets: [], + search: { + lexical_index: 'data/search/lexical_index.json', + minilm_vectors: 'data/search/minilm_vectors.bin', + minilm_vectors_build_info_url: 'data/search/minilm_vectors.build_info.json', + minilm_dim: 384, + minilm_dtype: 'int8' + } +}; + +const ABSTRACTS: AbstractsShard = { + schema_version: 'abstracts.v1', + build_info: BUILD_INFO, + abstracts: [ + { + abstract_id: 1001, + poster_id: 'M-AM-101', + title: 'Memory fMRI in aging', + accepted_for: 'Poster', + sections: { introduction: '', methods: '', results: '', conclusion: '', references: '' }, + topics: { + primary: 'Lifespan Development', + primary_subcategory: 'Aging', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: {}, + author_ids: [0], + reference_dois: [], + reference_urls: [] + } + ] +}; + +const AUTHORS: AuthorsShard = { + schema_version: 'authors.v1', + build_info: BUILD_INFO, + authors: [{ author_id: 0, name: 'Jane Smith', affiliations: ['Stanford'], abstract_ids: [1001] }] +}; + +const CELL: CellShard = { + schema_version: 'cell.v1', + build_info: BUILD_INFO, + cell_key: 'neuroscape_abstract', + rows: [ + { + abstract_id: 1001, + umap2d: [0.1, 0.2], + umap3d: [0.1, 0.2, 0.3], + community_id: 7, + topic_cluster_id: 100, + neuroscape_cluster_id: 42, + neuroscape_cluster_distance: 0.5 + } + ] +}; + +const TOPICS: TopicShard = { + schema_version: 'topics.v1', + build_info: BUILD_INFO, + cell_key: 'neuroscape_abstract', + kind: 'communities', + topics: [ + { cluster_id: 7, keywords: ['memory'], title: 'Memory cluster', description: '', focus: '' } + ] +}; + +function mockPackage(entries: Record) { + const map = new Map(Object.entries(entries)); + vi.spyOn(dataPackage, 'loadDataPackage').mockResolvedValue(map); +} + +describe('shard loaders (in-memory data-package map)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loadManifest reads `data/manifest.json` from the map', async () => { + mockPackage({ 'data/manifest.json': MANIFEST }); + const m = await loadManifest(); + expect(m).not.toBeNull(); + expect(m?.schema_version).toBe('ui.v1'); + expect(m?.build_info.code_revision_short).toBe('a1b2c3d'); + expect(m?.corpus_count).toBe(1); + }); + + it('loadAbstracts reads `data/abstracts.json` from the map', async () => { + mockPackage({ 'data/abstracts.json': ABSTRACTS }); + const a = await loadAbstracts(); + expect(a?.abstracts).toHaveLength(1); + expect(a?.abstracts[0].poster_id).toBe('M-AM-101'); + }); + + it('loadAuthors reads `data/authors.json` from the map', async () => { + mockPackage({ 'data/authors.json': AUTHORS }); + const au = await loadAuthors(); + expect(au?.authors[0].name).toBe('Jane Smith'); + }); + + it('loadCell reads `data/cells/.json` from the map', async () => { + mockPackage({ 'data/cells/neuroscape_abstract.json': CELL }); + const c = await loadCell('neuroscape_abstract'); + expect(c?.cell_key).toBe('neuroscape_abstract'); + expect(c?.rows[0].neuroscape_cluster_id).toBe(42); + }); + + it('loadTopics reads `data/topics/_.json` from the map', async () => { + mockPackage({ 'data/topics/neuroscape_abstract_communities.json': TOPICS }); + const t = await loadTopics('neuroscape_abstract', 'communities'); + expect(t?.topics[0].title).toBe('Memory cluster'); + }); + + it('returns null when the package map is unavailable (no URL set / CORS failure)', async () => { + vi.spyOn(dataPackage, 'loadDataPackage').mockResolvedValue(null); + expect(await loadManifest()).toBeNull(); + expect(await loadAbstracts()).toBeNull(); + expect(await loadCell('whatever')).toBeNull(); + }); + + it('returns null when the path is missing from the map', async () => { + mockPackage({ 'data/manifest.json': MANIFEST }); + expect(await loadCell('not_a_cell')).toBeNull(); + }); +}); diff --git a/site/src/tests/unit/tour.test.ts b/site/src/tests/unit/tour.test.ts new file mode 100644 index 00000000..dce16933 --- /dev/null +++ b/site/src/tests/unit/tour.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { get } from 'svelte/store'; +import { tourStore, TOUR_STORAGE_KEY } from '$lib/stores/tour'; + +describe('tourStore', () => { + afterEach(() => { + tourStore.reset(); + window.localStorage.removeItem(TOUR_STORAGE_KEY); + }); + + it('starts in the idle phase with step 0', () => { + expect(get(tourStore.phase)).toBe('idle'); + expect(get(tourStore.step)).toBe(0); + }); + + it('start() sets phase to running and step to 0', () => { + tourStore.start(); + expect(get(tourStore.phase)).toBe('running'); + expect(get(tourStore.step)).toBe(0); + }); + + it('next() advances the step counter', () => { + tourStore.start(); + tourStore.next(); + tourStore.next(); + expect(get(tourStore.step)).toBe(2); + }); + + it('prev() decrements but clamps at 0', () => { + tourStore.start(); + tourStore.next(); + tourStore.prev(); + tourStore.prev(); + expect(get(tourStore.step)).toBe(0); + }); + + it('complete() flips to dismissed + marks completedOrSkipped', () => { + tourStore.start(); + tourStore.next(); + tourStore.complete(); + expect(get(tourStore.phase)).toBe('dismissed'); + expect(get(tourStore.step)).toBe(0); + expect(get(tourStore.flags).completedOrSkipped).toBe(true); + }); + + it('skip() is equivalent to complete()', () => { + tourStore.start(); + tourStore.skip(); + expect(get(tourStore.phase)).toBe('dismissed'); + expect(get(tourStore.flags).completedOrSkipped).toBe(true); + }); + + it('dismissCta() sets ctaDismissed without changing phase', () => { + tourStore.dismissCta(); + expect(get(tourStore.flags).ctaDismissed).toBe(true); + expect(get(tourStore.phase)).toBe('idle'); + }); + + it('persists flags across reload', () => { + tourStore.dismissCta(); + tourStore.complete(); + const raw = window.localStorage.getItem(TOUR_STORAGE_KEY); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.ctaDismissed).toBe(true); + expect(parsed.completedOrSkipped).toBe(true); + }); +}); diff --git a/site/src/vite-env.d.ts b/site/src/vite-env.d.ts new file mode 100644 index 00000000..6174d664 --- /dev/null +++ b/site/src/vite-env.d.ts @@ -0,0 +1,17 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BUILD_SHA?: string; + readonly VITE_BUILD_SHA_SHORT?: string; + readonly VITE_BUILD_AT?: string; + /** + * Public URL of the data-package tarball (Dropbox / S3 / etc.). + * The client fetches + decompresses + untars in-browser on first paint. + * Set at build time from the OHBM2026_UI_DATA_PACKAGE_URL repo variable. + */ + readonly VITE_DATA_PACKAGE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/site/svelte.config.js b/site/svelte.config.js new file mode 100644 index 00000000..26d15492 --- /dev/null +++ b/site/svelte.config.js @@ -0,0 +1,22 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +const basePath = process.env.BASE_PATH ?? ''; + +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: '404.html', + precompress: false, + strict: true + }), + paths: { + base: basePath + } + } +}; + +export default config; diff --git a/site/tsconfig.json b/site/tsconfig.json new file mode 100644 index 00000000..a8f10c8e --- /dev/null +++ b/site/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/site/vite.config.ts b/site/vite.config.ts new file mode 100644 index 00000000..b4a04ad3 --- /dev/null +++ b/site/vite.config.ts @@ -0,0 +1,17 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + include: ['src/tests/unit/**/*.{test,spec}.{js,ts}'], + environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://localhost/' + } + }, + setupFiles: ['./src/tests/setup.ts'], + globals: false + } +}); diff --git a/specs/008-ui-rewrite/checklists/requirements.md b/specs/008-ui-rewrite/checklists/requirements.md new file mode 100644 index 00000000..c257a80f --- /dev/null +++ b/specs/008-ui-rewrite/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: UI Rewrite — Static Search Site + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-17 +**Feature**: [Link to spec.md](../spec.md) + +## Content Quality + +- [X] No implementation details (languages, frameworks, APIs) — the spec names *categories* of tech (modern JS framework, in-browser ML runtime, GitHub Action) without mandating a specific choice. The Assumptions section calls out the open choices. +- [X] Focused on user value and business needs — every FR / SC ties back to a user-facing capability (search, browse, save, share, learn). +- [X] Written for non-technical stakeholders — the User Stories speak in user terms; the FR section is precise but uses plain language ("typo tolerance", "open in new tab"). The wireframe-prompt appendix is for designers, not engineers. +- [X] All mandatory sections completed — User Scenarios, Edge Cases, Functional Requirements, Key Entities, Constitution Alignment, Success Criteria, Assumptions, plus the wireframe prompt. + +## Requirement Completeness + +- [X] No [NEEDS CLARIFICATION] markers remain — all ambiguities resolved with informed defaults documented in Assumptions (in-browser ML runtime choice, cart email mechanism = mailto, mobile lasso replacement = tap-by-community, PR preview cleanup = pull_request.closed event). +- [X] Requirements are testable and unambiguous — every FR cites a concrete behavior; the Independent Test on each user story names a specific verification path. +- [X] Success criteria are measurable — every SC carries a numeric threshold (3 s first paint, 500 ms search, 5 MB / 25 MB gzipped data, 90 % typo recall, 10 min preview latency). +- [X] Success criteria are technology-agnostic — SCs measure user-facing latency, file sizes, and recall %; they do not mandate React, Vite, ONNX Runtime, etc. +- [X] All acceptance scenarios are defined — 8 user stories × ~3 Given/When/Then each = 24+ acceptance scenarios. +- [X] Edge cases are identified — 11 edge cases enumerated (empty search, single result, mobile lasso conflict, empty cart email, no mail handler, short queries, low-end 3D, dead reference links, PR collisions, mobile walkthrough, plus the typo-tolerance threshold). +- [X] Scope is clearly bounded — explicit "out of v1" calls (3D lasso, runtime dead-link handling); the Assumptions section enumerates what the spec doesn't try to solve (no SMTP relay, no server-side cart, no auth, no analytics). +- [X] Dependencies and assumptions identified — 11 assumption bullets covering framework choice, ML runtime, poster id source, references data, cart storage, etc. + +## Feature Readiness + +- [X] All functional requirements have clear acceptance criteria — every FR maps to ≥ 1 user-story acceptance scenario or success criterion. +- [X] User scenarios cover primary flows — 8 user stories cover MVP browsing (US1), exploration (US2 + US3), filtering (US4), sharing (US5), onboarding (US6 + US7), and operational deploy (US8). +- [X] Feature meets measurable outcomes defined in Success Criteria — SC-001..SC-010 collectively bound performance, correctness, deployment, and usability. +- [X] No implementation details leak into specification — implementation choices (framework, ML runtime, preview cleanup mechanism) are noted as assumptions, not requirements. + +## Notes + +- The user offered to ask Claude Design for a wireframe. The wireframe-prompt block at the end of the spec is ready to paste verbatim. Reviewing the wireframe before `/speckit-plan` is recommended but not mandatory; the plan can also drive the wireframe. +- The 3D lasso is explicitly out of scope for v1 (FR-006 + US2 acceptance scenario 3). If the user later wants it, that's a follow-up stage. +- The build-time link checker for the About page (FR-017 + SC-007) requires the deploy action to run with network access; this is the default for `ubuntu-latest` GitHub-hosted runners. +- The current corpus has 3,244 accepted abstracts (verified live at spec-time). If the count shifts before the deploy, the spec's user-facing copy ("3,244 accepted abstracts") needs to update — but the FR/SC contracts are size-agnostic. +- Data-package size budgets (SC-006) assume aggressive JSON minification + per-cell lazy loading. If they're missed, the plan phase needs to consider switching to a binary format (Parquet via parquetjs / Arrow) for the per-model coordinates. diff --git a/specs/008-ui-rewrite/contracts/data-package.md b/specs/008-ui-rewrite/contracts/data-package.md new file mode 100644 index 00000000..c22685cf --- /dev/null +++ b/specs/008-ui-rewrite/contracts/data-package.md @@ -0,0 +1,69 @@ +# Contract: UI Data Package (post-build) + +The data package is the **public file-system contract** between the Python builders and the Svelte site. Anything else (storage engines, query layers, ML runtimes) is implementation detail; this contract is what the site fetches. + +## Files + +| Path | Format | Lazy? | Max gz size | Schema reference | +|---|---|---|---|---| +| `data/manifest.json` | JSON object | no | 5 KB | data-model.md §1 | +| `data/abstracts.json` | JSON array | no | 6 MB | data-model.md §2 | +| `data/authors.json` | JSON array | no | 1.5 MB | data-model.md §3 | +| `data/cells/_.json` | JSON array | yes (non-default cells) | 100 KB each | data-model.md §4 | +| `data/topics/__.json` | JSON array | yes (non-default cells/kinds) | 30 KB each | data-model.md §5 | +| `data/search/lexical_index.json` | JSON object | no | 500 KB | data-model.md §6 | +| `data/search/minilm_vectors.bin` | binary `Int8Array` | yes (first semantic query) | 1.5 MB | data-model.md §7 | + +## Stable URL conventions + +- All paths are **relative to the site root**. With GitHub Pages serving from `https://.github.io//`, the manifest lives at `https://.github.io//data/manifest.json`. +- PR previews mount the **same** relative layout under `https://.github.io//pr-/data/manifest.json`. The site never hardcodes the base; it uses SvelteKit's `base` config. +- File names use only `[a-z0-9_]+` plus `.json` / `.bin` extensions — no spaces, no special characters. + +## Loading order (browser) + +1. **Manifest** — fetched first; everything else's URL is in here. +2. **Parallel fan-out** after manifest resolves: + - `abstracts.json` + - `authors.json` + - `cells/.json` (the default `neuroscape_abstract` cell) + - `topics/_communities.json`, `..._neuroscape_clusters.json`, `..._topic_clusters.json` + - `search/lexical_index.json` +3. **On-demand**: + - `cells/.json` when the user switches the (model, input) selector. + - `topics/_.json` lazy-loaded with its cell. + - `search/minilm_vectors.bin` on the first semantic query. + +## Invariants + +- **Accepted-only.** `accepted_for != "Withdrawn"` for every record in every shard. +- **Positional join.** `cells/.json[i].abstract_id == abstracts.json[i].abstract_id` for all `i ∈ [0, 3244)`. +- **Referential integrity.** Every `author_id` in `abstracts.json` exists in `authors.json`; every `*_cluster_id` exists in the matching topics file. +- **Provenance block.** Every shard's `build_info` block is byte-identical (same corpus + same rollup + same code revision). +- **Determinism.** The build is reproducible: same inputs → same shard SHA-256 hashes (modulo the `built_at` timestamp). + +## Versioning + +The manifest's `schema_version` field is the contract version. Bumping it triggers a deliberate site-side migration; the site refuses to load an unknown schema_version (with a user-facing error). + +## Build command + +```bash +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --references data/primary/reference_metadata.json \ + --rollup data/outputs/analysis/annotations__.sqlite \ + --analysis-root data/outputs/analysis \ + --minilm-bundle data/outputs/embeddings/minilm/title__ \ + --references-yaml specs/008-ui-rewrite/contracts/references.yaml \ + --output site/static/data/ +``` + +Exit codes: +- `0` — all shards written; all invariants satisfied; link checker passed. +- `2` — corpus mismatch or referential-integrity failure (Principle VI: fail loudly). +- `3` — link checker found a non-2xx URL in `references.yaml`. +- `5` — output directory not writable or partial-write detected; nothing committed. diff --git a/specs/008-ui-rewrite/contracts/github-action.md b/specs/008-ui-rewrite/contracts/github-action.md new file mode 100644 index 00000000..07c2baa5 --- /dev/null +++ b/specs/008-ui-rewrite/contracts/github-action.md @@ -0,0 +1,198 @@ +# Contract: GitHub Action Workflows + +Three workflows govern the site lifecycle. Each has a single trigger and a single responsibility (per research.md R8). + +## 1. `.github/workflows/deploy-ui.yml` + +**Trigger**: `push` to `main` (paths: `src/ohbm2026/**`, `scripts/build_ui_data.py`, `site/**`, `specs/008-ui-rewrite/contracts/references.yaml`). + +**Job: `deploy-production`** + +```yaml +runs-on: ubuntu-latest +concurrency: { group: deploy-ui-production, cancel-in-progress: false } +permissions: + contents: write + pages: write +steps: + - name: Checkout + uses: actions/checkout@v4 + with: { fetch-depth: 0 } # full history for code_revision + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: { python-version: "3.14" } + + - name: Install uv + repo deps + run: | + pip install uv + uv venv --python 3.14 .venv + uv pip install --python .venv/bin/python -e ".[ui,enrich]" + + - name: Set up Node 20 + pnpm + uses: actions/setup-node@v4 + with: { node-version: "20" } + - run: corepack enable && corepack prepare pnpm@9 --activate + + - name: Install site deps + working-directory: site + run: pnpm install --frozen-lockfile + + - name: Restore corpus + Stage 4 artifacts + run: | + # The corpus + rollup + embeddings live outside the repo (gitignored). + # Production deploy fetches them from a release artifact or a tracked DVC store. + # Implementation detail handled by `scripts/fetch_ui_inputs.sh`. + ./scripts/fetch_ui_inputs.sh + + - name: Build data package (state-keys discovered at runtime per CA-007) + run: | + PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --references data/primary/reference_metadata.json \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --minilm-bundle "$(PYTHONPATH=src .venv/bin/python -m ohbm2026.ui_data.state_key minilm data/outputs/embeddings/minilm title)" \ + --references-yaml specs/008-ui-rewrite/contracts/references.yaml \ + --output site/static/data/ + + - name: Run Python unit tests + run: PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -p "test_ui_data*" + + - name: Run site unit tests (Vitest) + working-directory: site + run: pnpm test:unit -- --run + + - name: Build site (SvelteKit static-adapter) + working-directory: site + run: pnpm build + + - name: Run Playwright smoke + working-directory: site + run: pnpm test:e2e --reporter=line + + - name: Publish to gh-pages root + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site/build + publish_branch: gh-pages + destination_dir: . + keep_files: true # Preserve /pr-/ subdirectories from preview workflow + enable_jekyll: false + commit_message: | + deploy(ui): ${{ github.sha }} +``` + +## 2. `.github/workflows/pr-preview.yml` + +**Trigger**: `pull_request` (events: `opened`, `synchronize`, `reopened`). + +**Surface**: the preview URL surfaces in the **PR's Deployments box** (top-of-PR, populated by the workflow's `environment:` declaration). No bot comment is posted in the conversation. GitHub auto-creates the `pr-preview-` environment on first use; subsequent pushes update the same environment URL in place. + +**Job: `deploy-preview`** + +```yaml +runs-on: ubuntu-latest +concurrency: { group: deploy-ui-preview-pr-${{ github.event.pull_request.number }}, cancel-in-progress: true } +permissions: + contents: write # gh-pages branch push + deployments: write # create/update the deployment entry +if: github.event.pull_request.head.repo.full_name == github.repository # skip forks +environment: + name: pr-preview-${{ github.event.pull_request.number }} + # NOTE: this URL surfaces in the PR's Deployments box at the top of the PR. + # GitHub auto-creates the environment on first deploy. The URL stays attached + # to the environment across pushes so the same deployment entry is updated + # in place — no comment churn. + url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/pr-${{ github.event.pull_request.number }}/ +steps: + # ...Steps 1-7 identical to deploy-production EXCEPT: + - name: Publish to gh-pages subdirectory + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site/build + publish_branch: gh-pages + destination_dir: pr-${{ github.event.pull_request.number }} + keep_files: true + enable_jekyll: false + commit_message: | + preview(ui): pr-${{ github.event.pull_request.number }} ${{ github.sha }} +``` + +**Note**: no `peter-evans/find-comment` or `create-or-update-comment` step. The GitHub Deployments API (auto-populated from `environment:`) handles the PR-surface affordance natively. Reviewers see "View deployment" → `pr-preview-` at the top of the PR, above the file diff and below the description. + +## 3. `.github/workflows/pr-preview-cleanup.yml` + +**Trigger**: `pull_request` (event: `closed`). + +**Job: `cleanup-preview`** — removes the `/pr-/` directory from `gh-pages` AND marks the `pr-preview-` deployment as **inactive** via the GitHub Deployments API. After cleanup, the PR's Deployments box shows the deployment in the inactive state with no live link. + +```yaml +runs-on: ubuntu-latest +permissions: + contents: write # gh-pages branch push + deployments: write # mark deployment inactive +if: github.event.pull_request.head.repo.full_name == github.repository +steps: + - uses: actions/checkout@v4 + with: { ref: gh-pages, fetch-depth: 1 } + + - name: Remove preview directory + run: | + rm -rf pr-${{ github.event.pull_request.number }} + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --staged --quiet; then + echo "No preview directory to remove." + else + git commit -m "cleanup(ui): remove pr-${{ github.event.pull_request.number }} preview" + git push + fi + + - name: Mark pr-preview- deployment inactive + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const envName = `pr-preview-${process.env.PR_NUMBER}`; + // List active deployments for this environment and set each to inactive. + const deployments = await github.rest.repos.listDeployments({ + owner: context.repo.owner, + repo: context.repo.repo, + environment: envName, + per_page: 100, + }); + for (const dep of deployments.data) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: dep.id, + state: "inactive", + description: "PR closed; preview cleaned up.", + }); + } +``` + +**Note**: no PR-conversation comment is posted on cleanup either — the Deployments box state ("Inactive") is the surface that signals the preview is gone. + +## Required Pages settings (one-time, manual) + +- GitHub Pages: **source = `gh-pages` branch / root** (the default after the first publish). +- Custom domain (optional): set in repo settings → Pages. +- Enforce HTTPS: ON. + +## Secrets + +- Only `GITHUB_TOKEN` (auto-provisioned per workflow). **No** OpenAI / Anthropic / Voyage / other API keys touch the deploy path — the data package is built from pre-computed Stage 1–4 artifacts. (CA-004.) + +## Time + cost budget + +- Per-workflow run: ≤ 10 min p90 (SC-008). Dominated by the data-package build (~3 min for 3,244 abstracts + the link checker). +- Free-tier `ubuntu-latest` minutes are sufficient at expected PR cadence. diff --git a/specs/008-ui-rewrite/contracts/references.yaml b/specs/008-ui-rewrite/contracts/references.yaml new file mode 100644 index 00000000..3cab6fc4 --- /dev/null +++ b/specs/008-ui-rewrite/contracts/references.yaml @@ -0,0 +1,121 @@ +# References registry for the About page + Stage-6 build-time link check. +# +# Each entry is HEAD-checked by `src/ohbm2026/ui_data/link_check.py` during +# the deploy build (T086 + T088). 4xx / 5xx / timeout → non-zero exit → +# CI fails the deploy. Update entries here when a paper moves / a model +# card gets a new URL; do not let drift accumulate. +# +# `section` groups references by pipeline stage so the about page can +# render them under the matching deep-dive without hard-coding. + +references: + # ---- Stage 1 — fetch ---- + - section: stage1 + title: Oxford Abstracts platform + authors: Oxford Abstracts Ltd. + year: 2025 + url: https://app.oxfordabstracts.com/ + + # ---- Stage 2 — enrichment ---- + - section: stage2 + title: Evidence and Conclusion Ontology (ECO) + authors: Giglio et al. + year: 2019 + url: https://evidenceontology.org/ + doi: 10.1093/nar/gky1036 + - section: stage2 + title: OpenAlex — Open scholarly catalog + authors: OurResearch + year: 2024 + url: https://openalex.org/ + - section: stage2 + title: GPT-5.4-mini Vision model card + authors: OpenAI + year: 2026 + url: https://platform.openai.com/docs/models/gpt-5-4 + + # ---- Stage 3 — embeddings ---- + - section: stage3 + title: 'MiniLM: Deep Self-Attention Distillation for Task-Agnostic Compression' + authors: Wang et al. + year: 2020 + url: https://arxiv.org/abs/2002.10957 + doi: 10.48550/arXiv.2002.10957 + - section: stage3 + title: 'sentence-transformers/all-MiniLM-L6-v2 (HF model card)' + authors: Reimers, Sentence-Transformers + year: 2022 + url: https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2 + - section: stage3 + title: 'BiomedNLP-PubMedBERT-base-uncased-abstract (HF model card)' + authors: Microsoft Research + year: 2021 + url: https://huggingface.co/microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract + - section: stage3 + title: 'OpenAI text-embedding-3-small documentation' + authors: OpenAI + year: 2024 + url: https://platform.openai.com/docs/guides/embeddings + - section: stage3 + title: 'Voyage AI — embeddings documentation' + authors: Voyage AI + year: 2024 + url: https://docs.voyageai.com/docs/embeddings + - section: stage3 + title: 'NeuroScape — code repository' + authors: sensein + year: 2024 + url: https://github.com/sensein/neuroscape + - section: stage3 + title: 'NeuroScape: a domain-specific embedding for neuroscience abstracts' + authors: Vali Tehrani et al. + year: 2024 + url: https://apertureneuro.org/article/124574-neuroscape-a-domain-specific-embedding-for-neuroscience-abstracts + + # ---- Stage 4 — analysis ---- + - section: stage4 + title: 'UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction' + authors: McInnes, Healy & Melville + year: 2018 + url: https://arxiv.org/abs/1802.03426 + doi: 10.48550/arXiv.1802.03426 + - section: stage4 + title: 'From Louvain to Leiden: guaranteeing well-connected communities' + authors: Traag, Waltman & van Eck + year: 2019 + url: https://www.nature.com/articles/s41598-019-41695-z + doi: 10.1038/s41598-019-41695-z + - section: stage4 + title: 'HDBSCAN: hierarchical density-based clustering' + authors: McInnes & Healy + year: 2017 + url: https://joss.theoj.org/papers/10.21105/joss.00205 + doi: 10.21105/joss.00205 + - section: stage4 + title: 'FAISS — Billion-scale similarity search' + authors: Johnson, Douze & Jégou + year: 2019 + url: https://arxiv.org/abs/1702.08734 + doi: 10.48550/arXiv.1702.08734 + - section: stage4 + title: 'spaCy — Industrial-strength NLP' + authors: Explosion AI + year: 2024 + url: https://spacy.io/ + + # ---- Stage 6 — UI ---- + - section: stage6 + title: 'OHBM 2026 Atlas — source repository' + authors: sensein + year: 2026 + url: https://github.com/sensein/ohbm2026 + - section: stage6 + title: 'transformers.js — In-browser ML for the web' + authors: Xenova / Hugging Face + year: 2024 + url: https://huggingface.co/docs/transformers.js + - section: stage6 + title: 'LinkML — Linked Data Modeling Language' + authors: LinkML community + year: 2024 + url: https://linkml.io/linkml/ diff --git a/specs/008-ui-rewrite/contracts/routes.md b/specs/008-ui-rewrite/contracts/routes.md new file mode 100644 index 00000000..86a1aa09 --- /dev/null +++ b/specs/008-ui-rewrite/contracts/routes.md @@ -0,0 +1,41 @@ +# Contract: Site Routes + +The site is a single-page-ish application with three top-level routes. SvelteKit serves them as static HTML + per-route lazy-loaded JS chunks. + +## Routes + +| Path | Component | Description | Hard-link-ability | +|---|---|---|---| +| `/` | `+page.svelte` | Home: search + UMAP + result list + detail panel. State (search query, filters, lasso, selected abstract) is reflected in URL query params so the page is shareable. | Yes — `?q=...&model=neuroscape&input=abstract&filters=...&abstract=M-AM-101` | +| `/about` | `about/+page.svelte` | About page with overview + collapsible deep-dives. | Yes — `/about#embedding-models` etc. for deep-dive anchors | +| `/abstract/` | `abstract/[poster_id]/+page.svelte` | Permalink to a single abstract's detail panel; opens the same detail UI as `/` but without the surrounding search context. Used by cart-email permalinks. | Yes — direct link | + +## Query-param contract on `/` + +The home route serializes its state into URL query params (debounced ~500 ms during typing) so users can share their view: + +| Param | Type | Default | Notes | +|---|---|---|---| +| `q` | string | empty | Search query (semantic + lexical) | +| `mode` | enum | `both` | Search mode: `semantic` \| `lexical` \| `both` | +| `model` | enum | `neuroscape` | Selected model | +| `input` | enum | `abstract` | Selected input source | +| `filters` | comma-sep | empty | Active facet filters, encoded as `facet_key:option_value` pairs (e.g. `methods:fMRI,species:Human`) | +| `lasso` | base64 | empty | Lasso-selected abstract ids, packed as a base64 varint list to keep URLs short | +| `abstract` | string | empty | Currently-focused poster_id (for the detail panel) | +| `tour` | bool | false | `?tour=1` re-launches the walkthrough on page load | + +Empty params are dropped from the URL. + +## Permalinks for cart-email + +When the user clicks "email my list" with a non-empty cart, each cart item's permalink is `/abstract/` — NOT `/?abstract=`. Rationale: +- Shareable: even if the recipient doesn't have the search context, the direct link works. +- Crawler-friendly: search engines can index per-abstract pages. +- Resilient: a future search/UMAP UX change doesn't break archived cart-email permalinks. + +## 404 + edge cases + +- `/abstract/` — renders a "Abstract not found" page; offers a search link back to `/`. +- `/` with `?abstract=` — opens the home view; toast says "Abstract not found"; the detail panel stays empty. +- `/` — SvelteKit's catch-all renders a 404 page; SPA fallback to `/` is configured so deep links work on GitHub Pages. diff --git a/specs/008-ui-rewrite/contracts/ui_data.linkml.yaml b/specs/008-ui-rewrite/contracts/ui_data.linkml.yaml new file mode 100644 index 00000000..9d478546 --- /dev/null +++ b/specs/008-ui-rewrite/contracts/ui_data.linkml.yaml @@ -0,0 +1,633 @@ +id: https://github.com/sensein/ohbm2026/specs/008-ui-rewrite/contracts/ui_data +name: ohbm2026_ui_data +title: OHBM 2026 Atlas — UI data package schema +description: >- + LinkML schema describing every JSON shard the OHBM 2026 Atlas SPA loads at + runtime from `data/`. Authoritative contract: a re-built data package whose + shards validate against this schema can be loaded by the site without code + changes. The wire format is JSON; this schema does not cover the binary + `data/search/minilm_vectors.bin` (only its companion sidecar). + + Anchor invariants (data-model.md §8) are enforced by the Python builder + rather than by the schema itself — LinkML can't express e.g. "the cell + shard's `rows[i].abstract_id` equals `abstracts.abstracts[i].abstract_id`" + cleanly. The cross-shard build_info equality (`invariant 6`) is checked at + build time too. Validation here covers PER-SHARD shape only. +license: MIT +prefixes: + linkml: https://w3id.org/linkml/ + ohbm: https://github.com/sensein/ohbm2026/ui_data# +default_prefix: ohbm +imports: + - linkml:types + +# ---------------------------------------------------------------------------- +# Common envelope: every emitted JSON shard is an object that starts with +# `schema_version` + `build_info`. The Python builder enforces this in +# `_validate_invariants` (§8 invariant 6: every shard carries a byte-identical +# build_info). The schema below adopts the same envelope as a shared mixin via +# inheritance: each concrete shard is a `Shard` subtype. +# ---------------------------------------------------------------------------- + +classes: + Any: + description: >- + Pass-through for arbitrary JSON values (string, number, boolean, null, + array, object). LinkML wires this to the well-known `linkml:Any` class + so the JSON-schema generator emits no constraint at all. + class_uri: linkml:Any + + BuildInfo: + description: >- + Identity of the build that produced a shard. Six required fields; the + Python builder pins them at top-of-build so every shard's `build_info` + is byte-identical (data-model.md §8 invariant 6). `built_at` is an + ISO-8601 timestamp in UTC. + attributes: + code_revision: + description: Full git SHA of the repo at build time. + range: string + required: true + code_revision_short: + description: 7-char short SHA derived from `code_revision`. + range: string + required: true + pattern: "^[0-9a-fA-F]{7,12}$" + built_at: + description: ISO-8601 UTC timestamp at which the build started. + range: datetime + required: true + corpus_state_key: + description: >- + Stage 1 state-key of the abstracts corpus (e.g. `f0c51e80dc0e`). + Identifies which checkpoint of the GraphQL fetch the build consumed. + range: string + required: true + stage4_rollup_state_key: + description: >- + Stage 4 analysis rollup state-key (e.g. `48b...`). Identifies the + per-cell embedding-bundle revision the build used to populate + cells/topics/neighbors. + range: string + required: true + builder_version: + description: Stage-6 builder version string (currently `ui_data.v1`); not always emitted. + range: string + required: false + + Shard: + abstract: true + description: Common envelope shared by every JSON shard the site loads. + attributes: + schema_version: + description: Schema version tag (e.g. `abstracts.v1`). + range: string + required: true + pattern: "^[a-z_][a-z0-9_]*\\.v[0-9]+$" + build_info: + description: Identity of the build that produced this shard. + range: BuildInfo + required: true + + # -------- manifest.json --------------------------------------------------- + ManifestCell: + description: One (model, input) cell entry in the manifest. + attributes: + cell_key: + description: '`_` — the canonical cell key.' + range: string + required: true + model: + description: Embedding-model identifier (e.g. `neuroscape`, `voyage`, `minilm`). + range: string + required: true + input: + description: Input-component identifier (e.g. `abstract`, `claims`, `methods`). + range: string + required: true + shard_url: + description: Relative URL to this cell's `cells/.json` shard. + range: string + required: true + topic_shards: + description: >- + Inline JSON object: `{: }` mapping. LinkML doesn't model + arbitrary string-keyed dicts cleanly without inventing a wrapper + class, so this is typed as a permissive `AnyValue`. + range: Any + required: true + + ManifestFacet: + description: One facet definition in the manifest. + attributes: + key: + description: Internal facet key (e.g. `cluster`, `methods`). + range: string + required: true + label: + description: Human-facing label. + range: string + required: true + options: + description: Enumerated option strings (may be empty for free-form facets). + range: string + multivalued: true + inlined_as_list: true + + ManifestSearch: + description: Search-bundle pointers + dimensions. + attributes: + lexical_index: + description: Relative URL of the lexical-index shard (or placeholder string). + range: string + required: true + minilm_vectors: + description: Relative URL of the int8 vector buffer. + range: string + required: true + minilm_vectors_build_info_url: + description: Relative URL of the sidecar JSON. + range: string + required: true + minilm_dim: + description: Vector dimensionality. + range: integer + required: true + minimum_value: 1 + minilm_dtype: + description: Vector buffer dtype (currently `int8`). + range: string + required: true + + ManifestDefaultCell: + description: '`{model, input}` used as the SPA''s startup cell.' + attributes: + model: + range: string + required: true + input: + range: string + required: true + + Manifest: + is_a: Shard + description: '`data/manifest.json` — the SPA reads this first.' + attributes: + corpus_count: + description: Number of accepted abstracts in the corpus. + range: integer + required: true + minimum_value: 0 + default_cell: + description: '`{model, input}` the SPA opens with by default.' + range: ManifestDefaultCell + required: false + models: + description: Embedding models in this build. + range: string + multivalued: true + inlined_as_list: true + inputs: + description: Input components in this build. + range: string + multivalued: true + inlined_as_list: true + cells: + description: Every (model, input) cell available. + range: ManifestCell + multivalued: true + inlined_as_list: true + facets: + description: Facet definitions consumed by the filter sidebar. + range: ManifestFacet + multivalued: true + inlined_as_list: true + search: + description: Search-bundle pointers. + range: ManifestSearch + required: true + + # -------- abstracts.json -------------------------------------------------- + AbstractSections: + description: The four submitter-typed body sections + references. + attributes: + introduction: + range: string + required: true + methods: + range: string + required: true + results: + range: string + required: true + conclusion: + range: string + required: true + references: + range: string + required: true + + AbstractTopics: + description: Primary + secondary topic dropdowns from the submission form. + attributes: + primary: + range: string + required: true + primary_subcategory: + range: string + required: true + secondary: + range: string + required: true + secondary_subcategory: + range: string + required: true + + AbstractRecord: + description: One accepted abstract. + attributes: + abstract_id: + description: Internal numeric ID (from the GraphQL feed). + range: integer + required: true + poster_id: + description: Program-assigned poster identifier (FR-002). + range: string + required: true + title: + range: string + required: true + accepted_for: + description: Submission category (e.g. `Poster`, `Oral`). + range: string + required: true + sections: + range: AbstractSections + required: true + topics: + range: AbstractTopics + required: true + methods_checklist: + description: Verbatim items from the "methods" checklist question. + range: string + multivalued: true + inlined_as_list: true + facets: + description: >- + Per-record facet values (the 11 keyword/method/etc. lists). Stored + as a `facet_key → list-or-string` JSON object; LinkML can't tighten + this further so it stays `Any`. + range: Any + required: true + author_ids: + description: Synthetic author IDs referencing `authors.json`. + range: integer + multivalued: true + inlined_as_list: true + reference_dois: + range: string + multivalued: true + inlined_as_list: true + reference_urls: + range: string + multivalued: true + inlined_as_list: true + reference_titles: + description: Optional; parallel array to dois/urls. + range: string + multivalued: true + inlined_as_list: true + required: false + + AbstractsShard: + is_a: Shard + description: '`data/abstracts.json` — all accepted abstracts.' + attributes: + abstracts: + range: AbstractRecord + multivalued: true + inlined_as_list: true + required: true + + # -------- authors.json ---------------------------------------------------- + AuthorRecord: + attributes: + author_id: + description: Synthetic id (R6 dedup output). + range: integer + required: true + name: + description: Display name; NFC-normalised before dedup. + range: string + required: true + affiliations: + description: Ordered list of affiliations as the submitter entered them. + range: string + multivalued: true + inlined_as_list: true + abstract_ids: + description: Back-references into `abstracts.json`. + range: integer + multivalued: true + inlined_as_list: true + + AuthorsShard: + is_a: Shard + description: '`data/authors.json` — the unique-author registry.' + attributes: + authors: + range: AuthorRecord + multivalued: true + inlined_as_list: true + required: true + + # -------- cells/.json ------------------------------------------ + CellRow: + description: One abstract's positional row in a cell shard. + attributes: + abstract_id: + range: integer + required: true + umap2d: + description: 2D UMAP coordinates `[x, y]`. Empty when `umap_missing` is true. + range: float + multivalued: true + inlined_as_list: true + umap3d: + description: 3D UMAP coordinates `[x, y, z]`. Empty when `umap_missing` is true. + range: float + multivalued: true + inlined_as_list: true + umap_missing: + description: True if this abstract didn't get UMAP coordinates this run. + range: boolean + required: false + community_id: + description: Leiden community id (cluster). -1 = unclustered. + range: integer + topic_cluster_id: + description: HDBSCAN topic-cluster id. -1 = noise. + range: integer + neuroscape_cluster_id: + description: NeuroScape Stage-2 cluster id (neuroscape models only). + range: integer + required: false + neuroscape_cluster_distance: + description: Distance to assigned NeuroScape cluster centroid. + range: float + required: false + + CellShard: + is_a: Shard + description: '`data/cells/.json` — per-cell UMAP + cluster table.' + attributes: + cell_key: + range: string + required: true + rows: + range: CellRow + multivalued: true + inlined_as_list: true + required: true + + # -------- topics/_.json ---------------------------------- + TopicRecord: + attributes: + cluster_id: + range: integer + required: true + keywords: + range: string + multivalued: true + inlined_as_list: true + title: + description: LLM-grouped cluster title (may be empty). + range: string + description: + description: LLM-grouped cluster description (may be empty). + range: string + focus: + description: LLM-grouped cluster focus blurb (may be empty). + range: string + + TopicShard: + is_a: Shard + description: '`data/topics/_.json` — cluster metadata.' + attributes: + cell_key: + range: string + required: true + kind: + description: One of `communities`, `topic_clusters`, `neuroscape_clusters`. + range: TopicKind + required: true + topics: + range: TopicRecord + multivalued: true + inlined_as_list: true + required: true + + # -------- neighbors/.json -------------------------------------- + NeighborsShard: + is_a: Shard + description: '`data/neighbors/.json` — precomputed nearest/farthest 10.' + attributes: + cell_key: + range: string + required: true + k: + description: Neighbours per abstract (10). + range: integer + required: true + minimum_value: 1 + abstract_ids: + description: Positional reference list — `nearest_ids[i]` corresponds to `abstract_ids[i]`. + range: integer + multivalued: true + inlined_as_list: true + required: true + nearest_ids: + description: |- + 2D array `[N_abstracts, k]` — `nearest_ids[i]` is the list of k + nearest-neighbour abstract_ids of abstract i. Modelled via LinkML's + NumPy-style `array` slot extension. + range: integer + array: + dimensions: + - alias: row + - alias: col + required: true + nearest_distances: + description: 2D float array parallel to `nearest_ids`. + range: float + array: + dimensions: + - alias: row + - alias: col + required: true + farthest_ids: + description: 2D int array `[N, k]` — farthest-neighbour abstract_ids. + range: integer + array: + dimensions: + - alias: row + - alias: col + required: true + farthest_distances: + description: 2D float array parallel to `farthest_ids`. + range: float + array: + dimensions: + - alias: row + - alias: col + required: true + + # -------- enrichment.json ------------------------------------------------ + ClaimRecord: + description: One AI-extracted claim. + attributes: + claim: + range: string + required: true + claim_type: + range: string + evidence: + range: string + evidence_eco_codes: + range: string + multivalued: true + inlined_as_list: true + source: + range: string + source_quote_verified: + range: boolean + + FigureRecord: + description: One AI-interpreted figure. + attributes: + interpretation: + range: string + required: true + keywords: + range: string + multivalued: true + inlined_as_list: true + ocr_text: + range: string + question_name: + range: string + model_quality_estimate: + range: string + + EnrichmentRecord: + description: Per-abstract claims + figures. + attributes: + claims: + range: ClaimRecord + multivalued: true + inlined_as_list: true + required: true + figures: + range: FigureRecord + multivalued: true + inlined_as_list: true + required: true + + AIProvenance: + description: Model identifiers for the AI-authored surfaces. + attributes: + claims_model_id: + range: string + required: false + figures_model_id: + range: string + required: false + + EnrichmentShard: + is_a: Shard + description: |- + `data/enrichment.json` — keyed by `str(abstract_id)`. `records` is a + free-form JSON object (LinkML can't model string-keyed dicts without a + per-row identifier attribute). The value-side shape is documented by + the `EnrichmentRecord` / `ClaimRecord` / `FigureRecord` classes above + so the schema is still self-documenting even if not directly enforced. + attributes: + ai_provenance: + range: AIProvenance + required: true + records: + description: |- + `{str(abstract_id): EnrichmentRecord}` — LinkML cannot tighten this + further. The value shape per key follows `EnrichmentRecord`. + range: Any + required: true + + # -------- search/minilm_vectors.build_info.json -------------------------- + MinilmVectorsSidecar: + is_a: Shard + description: Sidecar for `data/search/minilm_vectors.bin`. + attributes: + shape: + description: '`[N_abstracts, dim]` — typically `[3243, 384]`.' + range: integer + multivalued: true + inlined_as_list: true + required: true + dtype: + description: Buffer dtype, currently `int8`. + range: string + required: true + scale: + description: Quantization scale; cosine recovery uses `1 / scale`. + range: float + required: false + max_abs_original: + description: Max absolute value before int8 quantization. + range: float + required: false + components: + description: Per-section MiniLM components that were composed. + range: string + multivalued: true + inlined_as_list: true + required: false + component_state_keys: + range: string + multivalued: true + inlined_as_list: true + required: false + missing_abstract_ids: + description: Abstracts that lacked an embedding (zero-vector substitute). + range: integer + multivalued: true + inlined_as_list: true + required: false + cosine_recovery_mae: + description: Mean absolute error of cosine recovery (≤ 0.005 invariant). + range: float + required: false + byte_offset_url: + description: Relative URL of the raw int8 buffer. + range: string + required: false + note: + description: Free-form note (e.g. "vectors not built (MiniLM bundle missing)"). + range: string + required: false + +# ---------------------------------------------------------------------------- +# Enums +# ---------------------------------------------------------------------------- +enums: + TopicKind: + permissible_values: + communities: {} + topic_clusters: {} + neuroscape_clusters: {} + +# ---------------------------------------------------------------------------- +# No custom types beyond `linkml:types` — see the `Any` class above for the +# permissive JSON pass-through we use for `facets` and the neighbour arrays- +# of-arrays. +# ---------------------------------------------------------------------------- +settings: {} diff --git a/specs/008-ui-rewrite/data-model.md b/specs/008-ui-rewrite/data-model.md new file mode 100644 index 00000000..418b49d5 --- /dev/null +++ b/specs/008-ui-rewrite/data-model.md @@ -0,0 +1,305 @@ +# Phase 1 — Data Model (Stage 6 UI Rewrite) + +This document specifies the **per-shard JSON field schemas** that satisfy spec FR-019. It is the canonical reference both for the Python builders under `src/ohbm2026/ui_data/` and for the TypeScript shard-loader under `site/src/lib/shards.ts`. + +Every shard is **accepted-only** — withdrawn abstracts are filtered out at build time and never reach any shard. + +## 0. Shared types + +```text +abstract_id : int32 — Stage 1 corpus id; stable across rebuilds for unchanged corpora. +poster_id : str — program-assigned poster id (e.g. "M-AM-101"); user-facing identifier per FR-002. +author_id : int32 — synthetic id assigned at build time; stable across rebuilds via the dedup key in research.md R6. +model_key : enum — one of {"voyage", "minilm", "openai", "pubmedbert", "neuroscape"}. +input_key : enum — one of {"abstract", "claims", "methods"}. +cell_key : str — `_` (e.g. "neuroscape_abstract"). 15 cells. +clustering_kind: enum — one of {"communities", "neuroscape_clusters", "topic_clusters"}. +build_info : object — {corpus_state_key: str, code_revision: str, code_revision_short: str (first 7 chars of `code_revision`, e.g. "a1b2c3d"), stage4_rollup_state_key: str, built_at: str(ISO 8601 UTC)}. +``` + +## 1. `data/manifest.json` (≤ 5 KB gz) + +```json +{ + "schema_version": "ui.v1", + "build_info": { "...": "see Shared types" }, + "corpus_count": 3244, + "default_cell": { "model": "neuroscape", "input": "abstract" }, + "models": ["voyage", "minilm", "openai", "pubmedbert", "neuroscape"], + "inputs": ["abstract", "claims", "methods"], + "cells": [ + { + "cell_key": "neuroscape_abstract", + "model": "neuroscape", + "input": "abstract", + "shard_url": "data/cells/neuroscape_abstract.json", + "topic_shards": { + "communities": "data/topics/neuroscape_abstract_communities.json", + "neuroscape_clusters": "data/topics/neuroscape_abstract_neuroscape_clusters.json", + "topic_clusters": "data/topics/neuroscape_abstract_topic_clusters.json" + }, + "byte_size_gz": 47512 + } + /* ...14 more cells */ + ], + "facets": [ + {"key": "accepted_for", "label": "Accepted for", "options": ["Poster", "Oral", "Symposium", "..."]}, + {"key": "primary_topic", "label": "Primary topic", "options": ["..."]}, + /* ...11 more facet keys per spec entity list */ + ], + "search": { + "lexical_index": "data/search/lexical_index.json", + "minilm_vectors": "data/search/minilm_vectors.bin", + "minilm_vectors_build_info_url": "data/search/minilm_vectors.build_info.json", + "minilm_dim": 384, + "minilm_dtype": "int8" + } +} +``` + +**Validation rules** + +- `default_cell` MUST appear in `cells`. +- Every entry in `cells` MUST have a discoverable `shard_url` that 200s (build-time check). +- `corpus_count` MUST equal `len(abstracts.json)` (build-time assert). +- `facets[].options` MUST be the union of distinct values across the corpus for that facet key, sorted alphabetically except `accepted_for` which carries program-order. +- `byte_size_gz` is the actual gzipped size on disk, useful for client-side budget telemetry. + +## 2. `data/abstracts.json` (≤ 6 MB gz) + +Shard envelope. `build_info` is byte-identical to the same block in `manifest.json` (FR-019 + CA-008 + §8 invariant 6): + +```json +{ + "schema_version": "abstracts.v1", + "build_info": { "...": "see Shared types" }, + "abstracts": [ /* 3,244 records, schema below */ ] +} +``` + +Each record in `abstracts[]`: + +```json +{ + "abstract_id": 49213, + "poster_id": "M-AM-101", + "title": "Memory fMRI in aging", + "accepted_for": "Poster", + "sections": { + "introduction": "...markdown...", + "methods": "...markdown...", + "results": "...markdown...", + "conclusion": "...markdown...", + "references": "...markdown..." + }, + "topics": { + "primary": "Lifespan Development", + "primary_subcategory": "Aging", + "secondary": "Neuroinformatics and Data Sharing", + "secondary_subcategory": "Informatics Other" + }, + "methods_checklist": ["Functional MRI", "Diffusion MRI"], + "facets": { + "study_type": "task-activation", + "population": "healthy", + "field_strength": "3T", + "processing_packages": ["fmriprep", "FSL"], + "species": ["Human"], + "recording_technology": ["fMRI", "Diffusion MRI"], + "brain_regions": ["Hippocampus", "Default Mode Network"], + "brain_networks": ["Default Mode Network"], + "keywords": ["Aging", "MRI"] + }, + "author_ids": [101, 102, 103], + "reference_dois": ["10.1038/nn.4504", "10.1016/j.neuroimage.2019.116189"], + "reference_urls": [ + "https://doi.org/10.1038/nn.4504", + "https://doi.org/10.1016/j.neuroimage.2019.116189" + ] +} +``` + +**Validation rules** + +- `accepted_for != "Withdrawn"` for **every** record — build-time assert; failure aborts deploy. +- `poster_id` is unique across the corpus (build-time assert). +- `author_ids` references existing ids in `authors.json` (build-time referential check). +- `submission_id` is **never** emitted (FR-002). +- `topics.primary` + `topics.secondary` are extracted from the submission form's `Primary Parent Category & Sub-Category` and `Secondary Parent Category & Sub-Category` responses; missing values → empty string, not `null`. +- `methods_checklist` lists the values from the "Please indicate which methods were used in your research:" submission question, split on `;` and trimmed. +- `facets.brain_regions`, `facets.brain_networks`, `facets.species`, `facets.recording_technology` are extracted via the same regex patterns the current `ui.py:build_domain_facets` already uses (kept in Python so they're language-agnostic). +- `reference_dois` and `reference_urls` are parallel arrays (same index = same reference); empty string fills the slot if either is missing. + +## 3. `data/authors.json` (≤ 1.5 MB gz) + +Shard envelope; `build_info` byte-identical to the manifest's (§8 invariant 6): + +```json +{ + "schema_version": "authors.v1", + "build_info": { "..." }, + "authors": [ + { + "author_id": 101, + "name": "Jane Smith", + "affiliations": ["Department of Psychology, Stanford University"], + "abstract_ids": [49213, 49544] + } + /* ~12,000 total records */ + ] +} +``` + +**Validation rules** + +- `author_id` is unique; assigned at build time via the dedup key in research.md R6. +- `abstract_ids` references existing accepted abstracts only. +- `name` is preserved verbatim (NFC-normalized but not transliterated); diacritics survive. +- `affiliations[0]` is the primary affiliation; subsequent entries are secondary affiliations in the order the submitter listed them. + +## 4. `data/cells/_.json` (15 files, each ≤ 100 KB gz) + +Shard envelope per cell; `build_info` byte-identical to the manifest's: + +```json +{ + "schema_version": "cell.v1", + "build_info": { "..." }, + "cell_key": "neuroscape_abstract", + "rows": [ + { + "abstract_id": 49213, + "umap2d": [3.142, -1.591], + "umap3d": [3.140, -1.589, 0.815], + "community_id": 7, + "topic_cluster_id": 142 + } + /* ...3,244 records, indexed by position to match abstracts.json's order */ + ] +} +``` + +For the **3 `neuroscape_*` cells only**, each record also carries: + +```json +{ + "neuroscape_cluster_id": 89, + "neuroscape_cluster_distance": 1.012 +} +``` + +**Validation rules** + +- Length of array = `manifest.corpus_count` (3,244). +- `abstract_id` order in this file matches the order in `abstracts.json` (positional join). Build-time assert: `for i in range(N): cells[i].abstract_id == abstracts[i].abstract_id`. +- `umap2d` is a 2-element float array; `umap3d` is a 3-element float array. NaNs are not permitted (build-time assert). +- `community_id` and `topic_cluster_id` reference cluster ids that appear in the corresponding `topics/*.json` shard. +- Cells for non-neuroscape models MUST NOT carry `neuroscape_cluster_*` fields (those cells weren't computed per FR-002 in Stage 4). + +## 5. `data/topics/__.json` (≤ 45 files, each ≤ 30 KB gz) + +Shard envelope per (cell, kind); `build_info` byte-identical to the manifest's: + +```json +{ + "schema_version": "topics.v1", + "build_info": { "..." }, + "cell_key": "neuroscape_abstract", + "kind": "communities", + "topics": [ + { + "cluster_id": 7, + "keywords": ["functional connectivity", "default mode network", "fmri"], + "title": "Default Mode Network in fMRI", + "description": "Community 7 spans abstracts on resting-state DMN ...", + "focus": "methodologies" + } + /* ... */ + ] +} +``` + +**Validation rules** + +- `cluster_id` matches the values used in the cells shard's `community_id` / `topic_cluster_id` / `neuroscape_cluster_id` fields. +- `keywords` is non-empty (when `--skip-llm-topics` was set in Stage 4, the keywords come from the local c-TF-IDF pass; `title` + `description` + `focus` may be empty in that case). +- For `=neuroscape_clusters`, the title / description / keywords / focus are sourced from `data/inputs/neuroscape/cluster_table.csv` verbatim (no LLM grouping). +- For `=communities` and `=topic_clusters`, the values come from each Stage 4 bundle's `topics.json` (the hybrid spaCy + c-TF-IDF + LLM grouping pipeline). + +## 6. `data/search/lexical_index.json` (≤ 500 KB gz) + +Schema is internal to the lexical-search engine; documented for traceability: + +```json +{ + "schema_version": "lexical.v1", + "build_info": { "..." }, + "tokens": [ + { + "token_id": 0, + "surface": "memory", + "trigrams": ["mem", "emo", "mor", "ory"], + "postings": [49213, 49544, 49901, /* ...abstract_ids that contain this token */] + }, + /* ~50K tokens */ + ], + "trigram_index": { + "mem": [0, 142, 1057, /* ...token_ids whose surface contains "mem" */], + /* ~12K trigrams */ + } +} +``` + +**Validation rules** + +- Tokens are NFC-normalized, lowercased, accent-folded. +- `postings` lists are sorted ascending and contain only accepted-abstract ids. +- `trigram_index` is the inverse of `tokens[].trigrams` to enable fast query-trigram → candidate-token lookup. +- Stopwords (`the`, `a`, `and`, `of`, `in`, `to`, `for`, `with`, `on`, `at`, `by`, `is`, `was`, `were`, `are`) are dropped at index time. + +## 7. `data/search/minilm_vectors.bin` (≤ 1.5 MB) + `data/search/minilm_vectors.build_info.json` (sidecar) + +The vectors themselves are a raw little-endian buffer of int8 values, shape `[3244, 384]` row-major. Indexed by position to match `abstracts.json` order. The browser decodes via `new Int8Array(buf)`. + +Because a raw binary buffer cannot embed JSON, the build_info travels in a sidecar JSON file at the same URL stem (`minilm_vectors.build_info.json`). The manifest's `search.minilm_vectors_build_info_url` points at the sidecar. + +```json +{ + "schema_version": "minilm_vectors.v1", + "build_info": { "..." }, + "shape": [3244, 384], + "dtype": "int8", + "byte_offset_url": "data/search/minilm_vectors.bin" +} +``` + +**Validation rules** + +- Byte length of the `.bin` = `3244 * 384` = 1,245,696 bytes (≤ 1.5 MB). +- The sidecar `build_info` is byte-identical to the manifest's (§8 invariant 6). +- Quantization preserves cosine similarity: `cos(q, v_int8 / 127) ≈ cos(q, v_float32)` within ε = 0.005 on a held-out subset. Build-time assertion: pick 100 random pairs, verify the int8 → float32 cosine recovers the float32 reference within 0.5 %. + +## 8. Build-time invariants (cross-shard) + +These invariants are asserted by the Python builder (`src/ohbm2026/ui_data/builder.py`) at the end of every build: + +1. `len(abstracts.json.abstracts) == manifest.corpus_count == 3244`. +2. For every cell shard, `len(cell_shard.rows) == 3244` and positional join with `abstracts.json.abstracts` holds. +3. Every `accepted_for != "Withdrawn"` in every shard (the accepted-only invariant — failure means a withdrawn record leaked through; abort deploy). +4. Every `author_id` in `abstracts.json:abstracts[].author_ids` exists in `authors.json:authors[]`. +5. Every `community_id` / `topic_cluster_id` / `neuroscape_cluster_id` in each cell shard exists in the matching `topics/*.json:topics[]`. +6. Every JSON shard MUST carry a top-level `build_info` block (and the `minilm_vectors.bin` MUST have its sidecar `minilm_vectors.build_info.json` co-located); all `build_info` blocks across the build are byte-identical (same corpus + same rollup + same code rev → same build-info). Raw-array JSON shards are forbidden — every shard is an object envelope. +7. All About-page reference URLs in `contracts/references.yaml` return HTTP 2xx (link checker; aborts deploy on any failure per CA-006 + SC-007). +8. Sum of shard `byte_size_gz` ≤ size budget per SC-006. + +## 9. Browser-side derived state (not a shard) + +For completeness — what the client builds in memory after fetching shards: + +- `abstractsByPosterId: Map` — for direct-link routing `/abstract/`. +- `abstractsById: Map` — for cell-shard joins. +- `authorsById: Map` — for detail-panel rendering. +- `cellByKey: Map>` — lazy-populated; cleared via LRU when memory pressure detected. +- `facetCountsByActiveSelection: Map>` — recomputed on every selection change. + +None of these structures are persisted; they live in Svelte stores or component-local state. diff --git a/specs/008-ui-rewrite/plan.md b/specs/008-ui-rewrite/plan.md new file mode 100644 index 00000000..aee91fe9 --- /dev/null +++ b/specs/008-ui-rewrite/plan.md @@ -0,0 +1,220 @@ +# Implementation Plan: Stage 6 — UI Rewrite (Static Search Site) + +**Branch**: `008-ui-rewrite` | **Date**: 2026-05-17 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/008-ui-rewrite/spec.md` + +## Summary + +Rebuild the OHBM 2026 abstract search UI as a **static SvelteKit + Vite site** served from GitHub Pages, built and deployed by a GitHub Action with **per-PR preview management**. The site consumes a **static-JSON-shard data package** (the Session-2026-05-17 clarification ruled out DuckDB-WASM at this scale — 3,244 abstracts × 15 (model, input) cells fit in ~9 MB gzipped, where in-memory JS aggregation runs in < 10 ms). Adds previously-missing capabilities to the prior UI: program-assigned **poster ids** (not submission ids), restored **author + affiliation details**, **3D UMAP**, **typo-tolerant lexical + semantic search** (MiniLM-L6 ONNX via transformers.js, int8-quantized vectors), **interactive facet recomputation**, a **saved-list shopping cart** with `mailto:`-based export, an **optional walkthrough**, an **About page** with build-time verified references, and **mobile-first responsive layout**. Accepted abstracts only; withdrawn submissions never surface anywhere. + +The implementation is **stage-parallel-friendly**: each user story (US1 through US8) is independently shippable as a per-route slice. Per the Session-2026-05-17 sequencing clarification, **US8 (deploy workflows + minimal placeholder) ships first as a small standalone PR** so US1–US7 can be reviewed via live PR previews (surfaced in the PR's Deployments box via `environment:` declaration, not as bot comments). MVP = US1 (search + browse on every device) lands second; US2–US7 then ship in any order, each on top of a working preview pipeline. + +## Technical Context + +**Languages/Versions**: +- **Build-side (data package)**: Python 3.14 in `.venv` (matches the existing pipeline; see CA-001). +- **Site (client)**: TypeScript on Node 20 LTS. The site is fully static — no server runtime. + +**Primary Dependencies**: +- **Site framework**: SvelteKit 2 + Vite 5 in static-adapter mode (`@sveltejs/adapter-static`). Picked over Next.js / Astro for the smallest framework runtime (~5 KB Svelte vs ~50 KB React) and TypeScript-first DX. Decision rationale lives in `research.md` R1. +- **In-browser ML**: `@xenova/transformers` (transformers.js) loading the quantized `Xenova/all-MiniLM-L6-v2` ONNX model from the Hugging Face CDN. Cached by the browser after first visit (~23 MB one-time). +- **Search**: + - Lexical: a **pre-built inverted index** with **n-gram bag tokens** + **Damerau-Levenshtein** distance check on candidate matches. Built at deploy time in Python (`scripts/build_lexical_index.py`); serialized as compact JSON. The browser library is small (~5 KB; a custom edit-distance lookup over the inverted-index postings). + - Semantic: int8-quantized MiniLM vectors (`[3244, 384]` little-endian buffer) loaded on first semantic query. Cosine similarity via typed-array math in a Web Worker. +- **Plotting**: **Plotly.js Basic** (the lite bundle: scatter, gl3d, lasso events; ~700 KB gz) for both 2D and 3D UMAP. Lazy-loaded only when the user opens the projections tab. +- **State**: Svelte stores; no Redux/Pinia/etc. Cart persists via `localStorage`. +- **Walkthrough**: `shepherd.js` (~18 KB gz) or `intro.js` (~14 KB gz). Decision deferred to `research.md` R3. +- **Build-side data scripts**: stdlib + `numpy` + the existing `ohbm2026.*` pipeline (corpus + Stage 4 rollup readers). NO new Python deps. + +**Storage**: +- **Site build output**: a `dist/` directory committed nowhere (gitignored under `export/`) and pushed to the `gh-pages` branch by the GitHub Action. +- **Data package**: static JSON shards + 1 binary file. Detail in `data-model.md`: + - `data/manifest.json`, `data/abstracts.json`, `data/authors.json` + - `data/cells/_.json` × 15 + - `data/topics/__.json` × ≤ 45 + - `data/search/lexical_index.json` + - `data/search/minilm_vectors.bin` (int8) +- **Provenance**: every shard embeds the build-info block (`corpus_state_key`, `code_revision`, `stage4_rollup_state_key`, `built_at`). The site footer's "build info" affordance surfaces this (CA-008). + +**Testing**: +- **Python build-side**: `unittest` for the data-package builders (`scripts/build_ui_data.py`, `scripts/build_lexical_index.py`, link checker). Test-first per CA-002. +- **JS site**: **Vitest** for unit tests (store mutations, facet aggregation, edit-distance lexical match). **Playwright** for end-to-end smoke per US1 (page loads, search returns results, abstract panel opens, accepted-only invariant verified by data scan). Headless Chromium runs in CI. +- **Build-gate tests**: the GitHub Action runs (a) Python data-package builders + tests, (b) link checker against the About page, (c) JS unit suite + Playwright smoke. Any failure blocks deploy (CA-006). + +**Target Platform**: +- **Production**: GitHub Pages (Jekyll-bypassed via `.nojekyll`). +- **Browsers**: evergreen Chromium / Firefox / Safari, last 2 versions; mobile Safari ≥ iOS 15, Chrome on Android ≥ 90. No IE11. + +**Project Type**: client-only static SPA + Python data-package builders. Spec doesn't require a backend. + +**Performance Goals** (from SCs): +- First interactive paint ≤ 3 s on typical broadband (SC-001). +- Query → ranked results ≤ 500 ms median (SC-002). +- Cell-switch UMAP re-render ≤ 1 s on a recent laptop (SC-003). +- Mobile US1 flow without horizontal scroll (SC-004). +- Data package: ≤ 8 MB gz first-paint + ≤ 3 MB gz lazy-expansion + ≤ 1.5 MB MiniLM vectors on first semantic query (SC-006). +- 90 % single-typo recall in top-10 (SC-010). +- PR-preview latency p90 ≤ 10 min (SC-008). + +**Constraints**: +- **Static-only**: no server runtime; no per-request compute. Everything client-side. +- **No new secrets**: deploy uses only `GITHUB_TOKEN` (CA-004). +- **Build-gate determinism**: same corpus + same Stage 4 rollup → byte-identical data shards (build info aside). Test asserts this. +- **Withdrawn-excluded invariant**: build-time filter; every shard is asserted accepted-only before deploy. +- **No DuckDB-WASM**: ruled out by Session-2026-05-17 clarification. + +**Scale/Scope**: +- 3,244 accepted abstracts (live count); ~12,000 author records; 15 active (model, input) cells; ≤ 45 topic files; ≤ 11 MB gz total data package; 8 user stories spanning 105 implementation tasks (T001–T100 + 5 `Tnnna` post-analysis inserts). + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **Principle I (venv-only Python)**: All build-side scripts execute via `.venv/bin/python` or `uv` targeting the venv. CI runs the same `.venv` install. ✓ +- **Principle II (no committed data)**: The site `dist/`, the data-package JSON shards, the lexical index, and the int8 vectors all land under `export/ui-site/` (existing gitignored root) or `data/outputs/exported-sites/ui-site__/` (also gitignored). The `gh-pages` branch is auto-generated by the Action and not part of source review. Nothing tracked. ✓ +- **Principle III (resumable, auditable)**: The data package is rebuildable in one command (`ohbmcli build-ui-data` via `scripts/build_ui_data.py`). The build is deterministic given fixed inputs (corpus + rollup). No external network calls at build time except the About-page link checker. ✓ +- **Principle IV (plan-first, test-first)**: This plan exists. Test-first is honored: Python unit tests for builders + link checker land before their impl; JS Vitest + Playwright smoke land before the corresponding UI features. CA-002 captures this in the spec. ✓ +- **Principle V (secret-safe, commit-early)**: No new secrets. Deploy uses default `GITHUB_TOKEN` only. Per-slice commits per CA-002 (one commit per shipped user-story slice). ✓ +- **Principle VI (fail loudly)**: Build-time link checker is a hard gate; build-time accepted-only invariant check is a hard gate; build-time data-shape contract test is a hard gate. No silent fallbacks. ✓ +- **Principle VII (discover external state)**: The data-package builder discovers the (model, input) cell catalog by reading the Stage 4 rollup's `cluster_topics` table at build time. Adding a 6th model is a zero-UI-code change — just rebuild. ✓ +- **Principle VIII (provenance for organizer-facing artifacts)**: Every shard carries the build-info block. The site footer surfaces it. The `gh-pages` branch's site is the published artifact; provenance is co-located. No absolute or user-home paths. ✓ + +**Gate verdict**: PASS, no waivers. + +## Project Structure + +### Documentation (this feature) + +```text +specs/008-ui-rewrite/ +├── plan.md # This file (/speckit-plan output) +├── research.md # Phase 0 — framework + ML-runtime + walkthrough lib decisions +├── data-model.md # Phase 1 — per-shard JSON field schemas + invariants +├── quickstart.md # Phase 1 — local-dev + build-and-deploy recipes +├── contracts/ +│ ├── data-package.md # Per-shard contract (URLs, schema, size budgets) +│ ├── github-action.md # Workflow contract (triggers, outputs, deploy targets) +│ └── routes.md # Site URL contract (/, /about, /abstract/) +├── checklists/ +│ └── requirements.md # Spec quality checklist (already exists) +└── tasks.md # Phase 2 output (created by /speckit-tasks, not here) +``` + +### Source Code (repository root) + +**New additions in this stage:** + +```text +src/ohbm2026/ui_data/ # NEW Python package — build the data shards +├── __init__.py +├── builder.py # `build_ui_data_package(rollup_path, corpus_path, …)` +├── abstracts.py # Build `abstracts.json` +├── authors.py # Build `authors.json` (dedupe + normalize) +├── cells.py # Build `cells/_.json` × 15 +├── topics.py # Build `topics/__.json` +├── lexical_index.py # Build `search/lexical_index.json` +├── vectors.py # Build `search/minilm_vectors.bin` (int8 from minilm Stage 3 bundle) +├── link_check.py # Verify About-page external URLs at build time +└── manifest.py # Build `manifest.json` + per-shard build-info block + +scripts/ +├── build_ui_data.py # CLI thin wrapper around `ui_data.builder` +└── build_ui_site.sh # End-to-end: `build_ui_data` + `pnpm build` → `dist/` + +site/ # NEW SvelteKit site +├── package.json +├── svelte.config.js # static-adapter + base path for gh-pages +├── vite.config.ts +├── tsconfig.json +├── playwright.config.ts +├── vitest.config.ts +├── src/ +│ ├── app.html +│ ├── lib/ +│ │ ├── shards.ts # Fetch + cache the data shards +│ │ ├── facets.ts # Facet recomputation (in-memory) +│ │ ├── search/ +│ │ │ ├── lexical.ts # Inverted-index lookup + Damerau-Levenshtein +│ │ │ └── semantic.ts # MiniLM ONNX inference + cosine similarity (Web Worker) +│ │ ├── stores/ +│ │ │ ├── selection.ts # Active (model, input) cell + lasso state + filters +│ │ │ ├── cart.ts # Saved-list with localStorage persistence +│ │ │ └── tour.ts # Walkthrough state machine +│ │ ├── components/ +│ │ │ ├── SearchBar.svelte +│ │ │ ├── ResultList.svelte +│ │ │ ├── DetailPanel.svelte +│ │ │ ├── FacetSidebar.svelte +│ │ │ ├── ModelSelector.svelte +│ │ │ ├── UmapPanel.svelte # 2D + 3D tabs; lazy-loads Plotly +│ │ │ ├── Cart.svelte +│ │ │ ├── Tour.svelte +│ │ │ └── BuildInfo.svelte +│ │ └── workers/ +│ │ └── semantic.worker.ts # MiniLM inference off-main-thread +│ ├── routes/ +│ │ ├── +layout.svelte +│ │ ├── +page.svelte # Home: search + UMAP + results + detail +│ │ ├── about/+page.svelte # About + collapsible deep-dives +│ │ └── abstract/[poster_id]/+page.svelte # Direct-link to one abstract +│ └── tests/ +│ ├── unit/ # Vitest +│ │ ├── facets.test.ts +│ │ ├── lexical.test.ts +│ │ └── cart.test.ts +│ └── e2e/ # Playwright +│ ├── browse.spec.ts +│ └── accepted-only.spec.ts +└── static/ + └── data/ # Populated by build_ui_data.py at deploy time + +.github/ +└── workflows/ + ├── deploy-ui.yml # Build + deploy on push to main → gh-pages root + ├── pr-preview.yml # PR open/update → preview under /pr-; surfaces in PR Deployments box via environment: + └── pr-preview-cleanup.yml # PR close → remove /pr-/ + mark deployment inactive via Deployments API +``` + +**Structure Decision**: The site lives under `site/` as a self-contained SvelteKit project (its own `package.json`, `node_modules`, build output). The Python data builders live under `src/ohbm2026/ui_data/` following the same per-stage package pattern as `enrich/`, `embed/`, `analyze/`. The GitHub Action wires them together: data builders run first, write into `site/static/data/`, then the SvelteKit build emits `dist/` which is published. + +## Complexity Tracking + +> Filled only if the Constitution Check produced unjustified violations. None did — the spec is decision-complete and every principle is satisfied. + +## Phasing & sequencing + +Per the Session-2026-05-17 clarification, **US8 ships first** as a small standalone PR so the rest of the work can be reviewed via live PR-previews. Phase numbers below match tasks.md exactly: + +- **Phase 1 (Setup)** — repo layout, deps, gitignore, scaffolding (T001–T007). No UI features yet. +- **Phase 2 (Foundational)** — data-package builder skeleton (manifest + abstracts + authors + cells + topics + state_key discovery + invariant tests) (T008–T020a). Lands the build pipeline that US8 needs to exercise. +- **Phase 3 (US8, P5 promoted to first-shipped) 🥇 first PR** — `deploy-ui.yml`, `pr-preview.yml`, `pr-preview-cleanup.yml` workflows using `environment:` declarations so previews surface in the PR's Deployments box (not as bot comments). A minimal "Stage 6 — under construction" placeholder Svelte page that **renders the build_info footer + short committish in the page title** (FR-022) so reviewers can verify which committish deployed. After this PR merges, every subsequent PR for US1–US7 gets an automatic live preview tagged with its committish. +- **Phase 4 (US1, P1 MVP)** — SvelteKit shell + search bar + result list + detail panel + responsive layout + accepted-only invariant + poster-id + authors + footer build-info on every route. The site goes live for the first time with real content. +- **Phase 5 (US2)** — UmapPanel with 2D (lasso) + 3D tabs; Plotly lazy-load; model selector. Selection-by-id persists across cell switches. +- **Phase 6 (US3)** — semantic + lexical search engines; merged ranking; semantic/lexical/both toggle; author search with typo tolerance; typo-recall eval script. +- **Phase 7 (US4)** — facet sidebar + interactive facet recount over intersection (search ∩ facets ∩ lasso). +- **Phase 8 (US5)** — saved-list cart + email-my-list (`mailto:` + clipboard fallback). +- **Phase 9 (US6)** — walkthrough (shepherd.js per research.md R3). +- **Phase 10 (US7)** — About page + build-time link checker. +- **Phase 11 (Polish)** — accessibility audit, perf budget validation against SC-001..SC-011, docs reconciliation (CLAUDE.md + README + vision doc — most docs micro-updates ride in per-US PRs per Constitution IV), final PR. + +Each phase ends at a green-suite checkpoint (Vitest + Playwright + Python builders + constitution lint). + +## Verification surface + +Per spec FR-001..FR-021 and SC-001..SC-010: + +- **Functional**: `unit/` Vitest suite for stores + search + facet math; `e2e/` Playwright suite per user story. +- **Build-gate**: Python `unittest` against the data-package builders; link checker over the About page; deterministic-build assertion (same inputs → same shard SHAs modulo build info). +- **Performance**: a CI job runs Lighthouse against the production-deploy preview and asserts SC-001 (first interactive paint) + SC-006 (data-package size). Fails the deploy if either regresses by > 20 %. +- **Accepted-only invariant**: `e2e/accepted-only.spec.ts` scans the loaded shards for any `accepted_for == "Withdrawn"` and fails if found. The Python builder also asserts this at build time so the violation can't reach `dist/`. +- **Mobile**: Playwright runs the US1 smoke against a mobile-emulated viewport (360 × 640 — SC-004's stated minimum) and asserts no horizontal scroll on the home + detail screens. + +## Out of scope for this stage (v1) + +- Server-side anything (auth, accounts, persistent carts across devices). +- 3D-UMAP lasso (out per FR-006). +- Runtime dead-link handling on the About page (build-gated only). +- An SMTP/email service — email export is `mailto:` only. +- A user-supplied custom embedding model — only the built-in MiniLM-L6 ONNX. +- Multi-language UI — English only for v1. +- Analytics — no tracker beacons; the site is privacy-respecting. diff --git a/specs/008-ui-rewrite/quickstart.md b/specs/008-ui-rewrite/quickstart.md new file mode 100644 index 00000000..54d1dce0 --- /dev/null +++ b/specs/008-ui-rewrite/quickstart.md @@ -0,0 +1,260 @@ +# Phase 1 — Quickstart (Stage 6 UI Rewrite) + +End-to-end recipes for local development, build verification, and deployment. + +## Prerequisites + +```bash +# Python side (build the data package) +UV_CACHE_DIR=.uv-cache uv venv --python 3.14 .venv +uv pip install --python .venv/bin/python -e ".[ui,enrich]" + +# Node side (build + serve the site) +corepack enable +corepack prepare pnpm@9 --activate +cd site && pnpm install +``` + +Stage 1–4 outputs must exist on disk: + +- `data/primary/abstracts.json` (3,244 accepted records) +- `data/primary/authors.json` +- `data/primary/abstracts_enriched.sqlite` +- `data/primary/reference_metadata.json` +- `data/outputs/analysis/annotations__f0c51e80dc0e.sqlite` (Stage 4 rollup) +- `data/outputs/analysis/` (per-bundle topics.json files) +- `data/outputs/embeddings/minilm/title__f0c51e80dc0e/` (Stage 3 MiniLM bundle for corpus-vector quantization) + +## Local development loop + +```bash +# 1. Build the data package once (data/ inputs → site/static/data/ shards). +# The state-key is discovered at build time via `ohbm2026.ui_data.state_key`; the +# hardcoded value below is only convenient for local one-offs. +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --references data/primary/reference_metadata.json \ + --rollup data/outputs/analysis/annotations__f0c51e80dc0e.sqlite \ + --analysis-root data/outputs/analysis \ + --minilm-bundle data/outputs/embeddings/minilm/title__f0c51e80dc0e \ + --references-yaml specs/008-ui-rewrite/contracts/references.yaml \ + --output site/static/data/ + +# Alternative: let the builder discover the latest rollup state-key automatically +# (this is what CI runs): +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --references data/primary/reference_metadata.json \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --minilm-bundle "$(PYTHONPATH=src .venv/bin/python -m ohbm2026.ui_data.state_key minilm data/outputs/embeddings/minilm title)" \ + --references-yaml specs/008-ui-rewrite/contracts/references.yaml \ + --output site/static/data/ + +# 2. Serve the site with hot-reload (Vite dev server) +cd site && pnpm dev # opens http://localhost:5173 +``` + +Hot-reload picks up edits to `site/src/**`. Editing the data package requires re-running step 1. + +## Build verification + +```bash +# Python unit tests (data-package builders + link checker) +PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -p "test_ui_data*" 2>&1 | tail -3 + +# Site unit tests +cd site && pnpm test:unit -- --run + +# Site end-to-end tests (Playwright headless) +cd site && pnpm test:e2e --reporter=line + +# Production build (static-adapter output) +cd site && pnpm build # → site/build/ + +# Local preview of the production build +cd site && pnpm preview # serves site/build/ at http://localhost:4173 +``` + +## US1 smoke (first-paint verification) + +After running the site locally: + +```bash +# Open http://localhost:5173 in a fresh tab. +# Expected: +# - Search bar visible within 3s (SC-001). +# - Type "connectivity" → results appear within 500ms (SC-002). +# - Click a result → detail panel opens with poster_id as header (NOT submission_id). +# - Author list visible with affiliations (FR-003). +# - Check Network tab: no shard contains a `accepted_for == "Withdrawn"` record. +# - Resize browser to 360x640 — confirm no horizontal scroll on home + detail (SC-004 minimum). +# - Confirm the footer "build info" affordance renders the short SHA (FR-022 / SC-011). +``` + +## US2 smoke (2D UMAP + lasso + model switch) + +```bash +# Click "Map" tab → 2D UMAP loads (Plotly bundle lazy-loaded; check Network). +# Drag a lasso around a cluster of ~100 points. +# Expected: +# - Result list filters to lasso selection within 500ms (US2 acceptance #1). +# - Facet counts update to reflect the intersection. +# - Clear lasso → full corpus restored. +# Switch model dropdown from "neuroscape × abstract" to "voyage × claims". +# - UMAP coordinates change. +# - Lasso selection (by abstract id) persists across the switch (US2 acceptance #2). +``` + +## US3 smoke (search + typo tolerance) + +```bash +# Type "defautl mode netwrk" (2 typos). +# Expected: "default mode network" abstracts surface in the lexical results (US3 acceptance #1). +# Type "Smtih" in the author-search subfield. +# Expected: abstracts by "Smith" appear (US3 acceptance #3). +# Type "how the brain remembers faces" (no verbatim match). +# Expected: face-memory abstracts surface via semantic search (US3 acceptance — semantic-only path). +``` + +## US4 smoke (interactive facets) + +```bash +# Click "Methods = fMRI" in the facet sidebar. +# Expected: +# - Result list shrinks to fMRI abstracts (~1,840 of 3,244). +# - "Species" facet now shows only species that appear in fMRI abstracts. +# - "Brain Regions" + "Brain Networks" + other facets all recount accordingly. +# Lasso a region while the facet is active. +# - Result list = (lasso) ∩ (facet); facet counts reflect the intersection. +``` + +## US5 smoke (cart + email) + +```bash +# Click "add to list" on 3 abstracts from different facet-filtered views. +# Expected: +# - Cart badge shows "3". +# - Reload page → badge still shows "3" (localStorage persistence). +# Click "email my list". +# Expected: OS mail composer opens with: +# Subject: "OHBM 2026 — my abstracts (3)" +# Body: 3 lines, each with poster_id + title + permalink to /abstract/. +# Click "clear cart" → badge resets; localStorage cleared. +``` + +## US6 smoke (walkthrough) + +```bash +# Open the site in an incognito window. +# Expected: "Take the tour" CTA visible in the header; no modal auto-opens. +# Click "Take the tour". +# - Tour runs through 5+ stops: search bar, model selector, UMAP, facets, cart. +# - Each step has next/previous/skip controls. +# - Tour can be re-launched anytime from the "?" affordance. +# Reload the page after dismissing the tour. +# - Tour does NOT auto-launch again. +``` + +## US7 smoke (About page + link health) + +```bash +# Navigate to /about. +# Expected: +# - Top-of-page overview is ≤ 250 words (US7 acceptance #1). +# - Each collapsible deep-dive (Stages 1–4 + topics + UMAP) has clickable references. +# - Click any reference link → opens in a new tab (target=_blank, rel=noopener). +# Run the link checker locally before the build: +PYTHONPATH=src .venv/bin/python -m ohbm2026.ui_data.link_check \ + specs/008-ui-rewrite/contracts/references.yaml +# Expected: exit 0; "All references reachable: N URLs" +# If any URL is dead, the build fails (SC-007). +``` + +## US8 smoke (deploy + PR previews) + +US8 is the **first** Stage 6 PR (per Session-2026-05-17 sequencing). After it lands on `main`, every subsequent PR gets a live preview surfaced in the PR's **Deployments box** at the top of the PR (NOT as a bot comment in the conversation). + +```bash +# === First-PR verification (US8 itself) === +# Open a small PR containing only the workflows + the placeholder route +# (the "Stage 6 — under construction" page). +# Expected within 10 minutes: +# - The PR's "Deployments" box appears AT THE TOP of the PR (above the file +# diff, below the description). NOT in the conversation as a comment. +# - The box shows: "pr-preview- — Active" with a "View deployment" button. +# - Clicking "View deployment" loads https://.github.io//pr-/ +# and the placeholder page renders the manifest build_info. +# Push another commit to the same PR. +# - The SAME Deployments environment ("pr-preview-") updates in place +# (GitHub does NOT create a new environment per push). +# - No bot comment churn anywhere in the conversation. +# Close the PR (merge or reject). +# - Within 30 min: the preview directory is removed from gh-pages AND the +# Deployments box marks "pr-preview-" as "Inactive". +# - Visiting the preview URL returns 404. +# Merge US8 to main. +# - Production deploy runs; canonical GitHub Pages URL updates within 10 min. + +# === Subsequent-PR verification (US1–US7) === +# Open any PR touching site/ or src/ohbm2026/ui_data/. +# Expected within 10 minutes: +# - Same Deployments-box flow: top-of-PR "View deployment" link to the +# per-PR subdirectory; updates in place on subsequent commits; marks +# "Inactive" 30 min after PR close. +# - Preview directories from other open PRs are untouched. +# - Production deploys on merge to main only. +``` + +**Sanity check**: if you ever see a `peter-evans/find-comment` step in `.github/workflows/pr-preview.yml`, that's wrong — the contract uses the Deployments API surface (auto-populated from the workflow's `environment:` declaration), not a bot comment. Restore from `contracts/github-action.md`. + +## Common operations + +### Clean rebuild (force fresh data package) + +```bash +rm -rf site/static/data/ +# ...then re-run the build command above. +``` + +### Verify the accepted-only invariant + +```bash +PYTHONPATH=src .venv/bin/python -c " +import json +with open('site/static/data/abstracts.json') as f: + abstracts = json.load(f) +withdrawn = [a for a in abstracts if a.get('accepted_for') == 'Withdrawn'] +print(f'Withdrawn rows in abstracts.json: {len(withdrawn)}') +assert len(withdrawn) == 0, 'INVARIANT VIOLATION: withdrawn record found' +" +# Expected: 0 (any non-zero aborts the deploy) +``` + +### Update reference YAML before deploy + +```bash +# Edit the references list: +$EDITOR specs/008-ui-rewrite/contracts/references.yaml + +# Validate before push: +PYTHONPATH=src .venv/bin/python -m ohbm2026.ui_data.link_check \ + specs/008-ui-rewrite/contracts/references.yaml +``` + +### Inspect bundle sizes + +```bash +cd site && pnpm build +du -sh site/build/* | sort -h +du -sh site/static/data/* | sort -h +gzip -k site/static/data/abstracts.json && du -h site/static/data/abstracts.json.gz +``` + +Expected: total of `site/static/data/` ≤ 11 MB raw (≤ 8 MB gz first-paint per SC-006). diff --git a/specs/008-ui-rewrite/research.md b/specs/008-ui-rewrite/research.md new file mode 100644 index 00000000..2eacde40 --- /dev/null +++ b/specs/008-ui-rewrite/research.md @@ -0,0 +1,188 @@ +# Phase 0 — Research (Stage 6 UI Rewrite) + +This document captures the technology-choice rationale behind the Technical Context block of `plan.md`. Each open question from the spec or plan is resolved here in `Decision / Rationale / Alternatives considered` form. + +## R1 — Site framework: SvelteKit vs React-Vite vs Astro + +### Decision + +**SvelteKit 2 + Vite 5 in static-adapter mode (`@sveltejs/adapter-static`).** + +### Rationale + +- **Smallest framework runtime.** Svelte compiles components to direct DOM operations; the runtime is ~5 KB gzipped. React shipping with Vite or Next is ~50 KB gzipped (react + react-dom) before any product code. Across SC-001 (≤ 3 s first paint) and SC-006 (≤ 8 MB gz first-paint package), every saved KB matters. +- **Fully static output.** `adapter-static` produces a `dist/` of HTML + JS + assets that GitHub Pages can serve directly. No Node runtime, no edge functions, no server rendering required. +- **Per-route code-split.** SvelteKit's file-based routing lazy-loads each route's JS chunk. Plotly + the MiniLM ONNX runtime only download when the user opens the UMAP tab or runs a semantic query — a clean lazy-load contract that satisfies SC-006's first-paint cap. +- **TypeScript-first DX.** First-class TS support with `svelte-check` instead of two-step tsc + bundler glue. +- **Svelte stores are idiomatic** for the kinds of derived state this app has (search query → filtered ids → facet recounts → result list). + +### Alternatives considered + +- **React + Vite (without Next)** — familiar, large ecosystem, but the runtime cost is 10× Svelte's. Wouldn't fail the SCs, but burns budget that the data package or Plotly could use better. +- **Next.js** — server-rendering features are wasted on a static-only site; the App Router's complexity buys nothing here. Static-export mode works but feels off-label. +- **Astro** — excellent for content-heavy sites with islands of interactivity, but this app is interactivity-heavy throughout (search + UMAP + cart + facets all need reactivity on every page). Forcing every component to be an "island" complicates state sharing. +- **Plain Vanilla + Vite** — minimal runtime cost, but you'd hand-roll routing, state, and templating. Time-to-MVP penalty isn't worth the ~5 KB savings. + +## R2 — In-browser ML runtime + embedding model + +### Decision + +**`@xenova/transformers` (transformers.js) running the quantized `Xenova/all-MiniLM-L6-v2` ONNX checkpoint.** The model loads from the public Hugging Face CDN and is cached by the browser. Embedding inference for a query (single short string) runs in a **Web Worker** to keep the main thread responsive. + +### Rationale + +- **The user explicitly named MiniLM.** transformers.js's `Xenova/all-MiniLM-L6-v2` checkpoint is the canonical browser port; ~23 MB quantized, ~25 ms inference for a single query on a recent MacBook Air via WebGPU + WASM fallback. +- **CDN-hosted model = no repo bloat.** The 23 MB ONNX weights aren't bundled into our data package (which is gated to 11 MB gz total). +- **Pre-computed corpus vectors avoid running MiniLM 3,244× at runtime.** We compute corpus embeddings once at build time (using the Python `sentence-transformers` model already in our Stage 3 minilm bundle) and ship `[3244, 384]` int8-quantized vectors. At query time, the browser computes ONE 384-dim query embedding via transformers.js, then does cosine sim against the corpus matrix in JS — fast typed-array math. + +### Alternatives considered + +- **ONNX Runtime Web directly** — finer control but lots of boilerplate to load tokenizer + handle pooling. transformers.js wraps all of that. +- **TensorFlow.js with USE / similar** — larger runtime, larger model (USE-Lite is ~25 MB raw), and we'd need to rebuild corpus vectors with a different model. +- **Skip the in-browser embedding, send queries to a server** — kills the static-only architecture (SC-006 + privacy). Out. +- **Float16 / float32 corpus vectors** — int8 is 4× smaller (1.2 MB vs 4.8 MB) with negligible recall loss for our use; SC-006 demands the smaller footprint. + +## R3 — Walkthrough library + +### Decision + +**`shepherd.js`** (~18 KB gz). + +### Rationale + +- **Step model fits our needs**: anchored highlight + tooltip + next/prev/skip. Out of the box. +- **No theme dependency.** intro.js bundles a CSS theme; shepherd.js is theme-agnostic and lets us match the rest of the site's CSS. +- **Active maintenance.** Both libs are maintained, but shepherd.js gets more contributors per year and supports Svelte via official examples. + +### Alternatives considered + +- **intro.js** — also fine, ~14 KB gz. The bundled theme conflicts with our minimal palette; we'd override most of it anyway. +- **Custom-rolled with Svelte transitions + Floating UI** — would shave another ~10 KB but adds ~1–2 days of UX work. Not worth it given the rest of the budget already fits. + +## R4 — Plotly bundle for 2D + 3D UMAP + +### Decision + +**Plotly.js "basic" + "gl3d" custom bundle** (`scatter` + `scattergl` + `scatter3d` + `lasso` traces). ~700 KB gz. Lazy-loaded only when the user opens the projections tab. + +### Rationale + +- **Lasso is non-trivial elsewhere.** Plotly's lasso event is well-tested across browsers; rolling our own with D3 + a polygon-in-point predicate is doable but adds risk and dev time. +- **3D is free.** The `gl3d` bundle adds rotate/pan/zoom for `scatter3d` traces with no extra code. +- **Lazy-loaded.** Visiting the home page without opening UMAP never downloads Plotly. SvelteKit's dynamic `import()` handles this. + +### Alternatives considered + +- **deck.gl** — better at million-point scale, but our 3,244-point scatter is well within Plotly's sweet spot, and deck.gl's lasso requires extra plumbing (deck.gl-extensions/lasso). +- **D3 + custom 2D + Three.js for 3D** — finest control, largest dev cost, would lose lasso reliability. + +## R5 — Lexical search: index format + edit-distance strategy + +### Decision + +- **Build time (Python)**: tokenize each abstract's title + sections + keywords + methods + author names into normalized tokens (lowercase, NFC-normalized, accent-folded). For each token, emit **trigrams** (3-character sliding windows). Build an inverted index `{trigram → [token_id], token → {abstract_ids, surface_form}}`. Serialize as a single compact JSON: `lexical_index.json` ≤ 500 KB gz. +- **Query time (browser)**: trigram the user's query into candidate token-id buckets; gather candidates whose trigram overlap with the query ≥ a threshold (≥ 60 %); then run **Damerau-Levenshtein** distance ≤ 2 on those candidates (≤ 1 for words of length < 4). Surface abstracts via posting lists. + +### Rationale + +- **Trigram pre-filter → DL distance** is the canonical approach (used by PostgreSQL `pg_trgm`, Lucene's `FuzzyQuery`, Elasticsearch's `fuzzy` mode). Lets us run real edit-distance on ≤ 100 candidate tokens per query instead of all ~50 K corpus tokens. +- **Pre-built index** means the browser doesn't pay tokenization cost on cold start. +- **500 KB gz budget** is comfortably feasible: 50 K tokens × ~5 trigrams each × 6 bytes per (trigram, token_id) pair ≈ 1.5 MB raw → ~400 KB gz with json + varint-style postings. + +### Alternatives considered + +- **MiniSearch / Fuse.js / Lunr.js** — runtime indexers; would force the browser to index 22 MB of abstract text on cold start. Too slow for SC-001. +- **FlexSearch encoded with `tolerant` mode** — ships as a library and provides fast fuzzy; but the resulting in-memory index is ~3 MB after `add()`, and we'd pay that on every cold start. +- **BK-tree on full token vocab** — beautiful data structure but adds CPU cost per query. Trigram pre-filter is simpler and faster at our scale. + +## R6 — Author de-duplication + affiliation normalization + +### Decision + +- **De-duplication key**: `lower(NFC(full_name)) + "|" + lower(NFC(primary_affiliation))`. Same name + same primary affiliation → one author record. Different affiliation → different author record (intentional; "Jane Smith @ Stanford" and "Jane Smith @ MIT" are reasonably-distinct people). +- **Affiliation normalization**: trim, collapse whitespace, fold "&" → "and", remove trailing punctuation. NO heuristic merging of "Stanford University" vs "Stanford U." vs "Stanford" — those stay as 3 affiliations. +- **Storage**: each author record carries an `affiliations: list[str]` (ordered as on the abstract) and `abstract_ids: list[int]` (the abstracts they appear on, in chronological-id order). + +### Rationale + +- We're shipping organizer-facing data, not running an author-disambiguation research project. Conservative de-dup keeps the data honest about what was actually submitted; aggressive merging risks combining two real people. +- The submitter is the authoritative source for "is this the same person?" — we trust them. + +### Alternatives considered + +- **OpenAlex author IDs** — our `reference_metadata.json` carries some, but coverage is partial; matching by name alone risks false merges. Defer to v2 if the project decides to integrate OpenAlex author resolution. +- **String-similarity merging (Jaro-Winkler ≥ 0.95)** — too fuzzy; common surnames + abbreviated affiliations create cross-merges. + +## R7 — Data-package: per-shard format details + +### Decision + +- **`abstracts.json`** — JSON array, not NDJSON. We accept the upfront cost of one big parse because (a) it's < 6 MB gz, (b) the browser's native `JSON.parse` is heavily optimized for arrays of similar-shape objects, and (c) NDJSON would require a custom parser and gain little here. +- **Per-cell `cells/_.json`** — JSON arrays, indexed positionally to `abstracts.json` by `abstract_id`. The client builds a `Map` once on load. +- **`minilm_vectors.bin`** — raw little-endian int8 buffer. No header — we know shape `[3244, 384]` from the manifest. Saves ~5 KB of header bytes and simplifies the client decoder to a `new Int8Array(buf)`. + +### Rationale + +- **JSON over Parquet** — re-locked-in by the Session-2026-05-17 clarification. Parquet would require a JS reader (~50 KB gz) and decoder time that JSON.parse avoids. +- **Plain `Int8Array` for vectors** — Web's typed arrays are zero-copy from `fetch().arrayBuffer()`; the client never builds a JS array of 384 × 3,244 numbers. + +### Alternatives considered + +- **Apache Arrow IPC files** — columnar, compressed, lazy column reads. Arrow JS is ~80 KB gz. Worth it at much larger scale (millions of rows); at 3,244 rows the JSON path beats it on cold-start latency. +- **JSON Lines (NDJSON) streamed** — wins on streaming-parse for huge files; doesn't pay off below ~50 MB. + +## R8 — GitHub Action: deploy + PR-preview architecture + +### Decision + +- **`deploy-ui.yml`** (trigger: `push` to `main`): + - Step 1: Set up Python 3.14 + uv; install `[ui]` extras (the new optional-extra to be added in pyproject.toml — `numpy`, `sentence-transformers` for the build-time vector generator). + - Step 2: Set up Node 20 + pnpm; install `site/` deps. + - Step 3: Run `scripts/build_ui_data.py --output site/static/data/`. Produces all shards. + - Step 4: Run `cd site && pnpm test:unit && pnpm test:e2e` (Vitest + Playwright). Test gate. + - Step 5: Run `cd site && pnpm build`. Emits `site/build/` (SvelteKit's static-adapter output). + - Step 6: Run the link checker against the About page (Python `link_check.py` against the deployed-but-not-yet-published HTML). + - Step 7: Use `peaceiris/actions-gh-pages@v3` to push `site/build/` to the `gh-pages` branch's root. +- **`pr-preview.yml`** (trigger: `pull_request` opened/synchronize): + - Same Steps 1–6 as deploy. + - Step 7: Push `site/build/` to the `gh-pages` branch under `/pr-/` (NOT replacing root). Use a deploy action that supports `keep_files: true` + `destination_dir: pr-`. + - **No PR-conversation comment step.** Instead, the workflow declares `environment: { name: pr-preview-, url: }`. GitHub auto-populates the PR's top-of-PR **Deployments box** from the environment, and re-uses the same environment on subsequent pushes (no churn). This is the Session-2026-05-17 clarification. +- **`pr-preview-cleanup.yml`** (trigger: `pull_request` closed): delete `/pr-/` directory from `gh-pages` AND use `actions/github-script@v7` to mark every deployment for the `pr-preview-` environment as `state: "inactive"` via the Deployments API. The Deployments box then shows the deployment as "Inactive" with no live URL. + +### Rationale + +- **`peaceiris/actions-gh-pages`** is the de-facto GitHub Action for the "push to gh-pages branch" pattern; it handles `.nojekyll`, supports `keep_files`, and is widely audited. +- **One workflow per event** keeps the failure modes legible. The deploy workflow only runs on `main`; the preview workflow only runs on PRs. +- **Deployments-box surface over bot comments.** Per Session-2026-05-17: a workflow `environment:` declaration drives the GitHub Deployments API automatically, so the URL appears in the top-of-PR Deployments box and updates in place on each push. No `peter-evans/find-comment` / `create-or-update-comment` step is needed — the native Deployments surface avoids comment churn entirely and gives reviewers a one-click "View deployment" affordance in a stable PR location. + +### Alternatives considered + +- **One mega-workflow with branching logic** — concentrates failure surface; less legible. The 3-workflow split is GitHub's recommended pattern. +- **Cloudflare Pages / Vercel previews** — they offer turnkey previews but require setting up a separate hosting account, which the spec rules out (GitHub Pages only). Stays in scope. +- **`actions/deploy-pages`** (the official action) — only supports deploying to the root, not to subdirectories. Wrong tool for PR previews. + +## R9 — About page: reference list + link-check architecture + +### Decision + +The About page references live as a **YAML registry** at `specs/008-ui-rewrite/contracts/references.yaml` (a content-only artifact tracked in source). The Python `link_check.py` builder reads the registry at build time, issues an HTTP HEAD against each URL, and fails the build if any URL returns a non-2xx status. The site's `+page.svelte` for `/about` imports the registry at compile time and renders the deep-dive sections from it. + +### Rationale + +- **Single source of truth.** References live in one file; the site renders them and the link checker validates them. Adding a new reference is one PR. +- **YAML, not JSON.** Human-edited, multi-line citations, comments allowed. The build step parses YAML once and emits the rendered HTML. +- **Build-time check, not runtime.** Catches dead links before deploy. The spec rules out runtime handling (FR-017 + SC-007). + +### Alternatives considered + +- **Inline JSX/Svelte literals** — hardcoded references in the component. Adding a reference touches multiple files; the link checker has to parse JSX. Worse DX. +- **External CrossRef / OpenAlex API** — fetches reference metadata live. Adds API rate limits + a build-time external dependency. Out for v1. + +## R10 — Open questions deferred to implementation + +These are decisions that don't change the spec contract or the plan's structure; they're left for the implementer: + +- **Cart UX**: drawer from the right, modal, or full-page route? Will land during US5 based on the wireframe. +- **Tour copy**: per-step text. Drafted by the implementer; reviewed by the user before deploy. +- **Mobile UMAP**: in `≤ 1024 px`, lasso is replaced with "tap to filter by community" (per FR-005 edge case). The exact tap target — a single point's community vs the union of neighbors — is a UX detail not a spec contract. +- **Diacritic handling for author search**: NFC-normalize + accent-fold ("á" → "a"). Standard practice. diff --git a/specs/008-ui-rewrite/spec.md b/specs/008-ui-rewrite/spec.md new file mode 100644 index 00000000..7485044f --- /dev/null +++ b/specs/008-ui-rewrite/spec.md @@ -0,0 +1,272 @@ +# Feature Specification: UI Rewrite — Static Search Site for the OHBM 2026 Corpus + +**Feature Branch**: `008-ui-rewrite` +**Created**: 2026-05-17 +**Status**: Draft +**Input**: User description: "Build the UI as a site served by GitHub Pages, built by a GitHub Action with PR-preview management. Replace the UI with a modern framework. Content: model selection (default = neuroscape, abstract); 2D + 3D UMAP with lasso selection on the 2D view; semantic + lexical search (with typo tolerance for lexical/author search); abstract details with extra questions limited to topics and methods; facets that update interactively from selection/search; an optional walkthrough for new users; an About page describing how the data were processed (for a general neuroscientist, with collapsible deep-dive details and verified references); a minimal data package that contains abstract details + model-configuration outputs; responsive on phone/tablet; abstracts keyed by poster id (not submission id), with author details restored; accepted abstracts only — no withdrawn; references open in a new tab; users can add search results to a shopping cart and open an email editor pre-populated with the cart contents." + +## Clarifications + +### Session 2026-05-17 + +- Q: Storage engine for the UI data package — DuckDB-WASM, static JSON shards, or a hybrid? → A: **Static JSON shards (no DuckDB-WASM).** At 3,244 abstracts × 15 (model, input) cells the entire dataset is ~9 MB gzipped, fits in client memory, and in-memory `Array.filter`/intersection runs in <10 ms. DuckDB-WASM's ~3.5 MB runtime overhead buys no measurable win at this scale and adds a WASM dependency the rest of the site doesn't need. The site is fully client-side static + per-shard CDN-cacheable JSON; facet aggregations are computed in JS over the in-memory abstract array; the schema is documented under FR-019. +- Q: Delivery sequencing + how preview URLs surface on a PR? → A: **Ship the deploy + PR-preview GitHub Actions in a small first PR (US8 first); use the GitHub Deployments / environment surface (top-of-PR Deployments box) rather than a bot comment in the conversation.** Rationale: once the workflows are merged, every subsequent PR for US1–US7 automatically gets a live preview at the top of the PR — reviewers can click "View deployment" without scrolling through commit history. The first PR ships a minimal placeholder site ("Stage 6 — under construction" + the empty data-package builder skeleton) so the preview is non-empty and the workflows are exercised end-to-end. See FR-021 + contracts/github-action.md for the environment-based deploy mechanism. +- Q: Scope of the AI-attribution UI indicator — which content surfaces flag that they were authored/interpreted by an LLM? → A: **The indicator applies to AI-authored or AI-interpreted content only: figure interpretations, extracted claims, and LLM-grouped topic-cluster titles/descriptions/focuses.** Content the LLM merely *assisted* (e.g., references — LLM-assisted split + DOI/OpenAlex canonical lookup) does NOT carry the indicator. Verbatim-submitter content (abstract sections, topic dropdowns, methods checklists, authors) also does NOT carry it. +- Q: Visual + interaction for the AI-attribution indicator? → A: **A small `✨ AI` pill on the section header that reveals, on hover / focus / tap, a tooltip naming the specific model used (e.g. `gpt-5.4-mini`, `qwen3-7B`) plus a link to the About-page section explaining how that surface was generated.** Screen-reader discoverable via `aria-label`. Header-level only (no per-item markers) so the visual noise stays bounded. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Find and read accepted-poster details from any device (Priority: P1) 🎯 MVP + +A neuroscientist heading to OHBM 2026 lands on the site from a phone, tablet, or laptop. They search by keyword, author, or topic and read the full details of relevant accepted posters — including author names + affiliations and the canonical poster id assigned by the program. Withdrawn submissions are not surfaced anywhere. + +**Why this priority**: This is the irreducible value the site offers. Without working search + abstract detail on every device, every other feature (UMAP, cart, walkthrough) is dressing on something nobody can actually consume. + +**Independent Test**: From a fresh device, navigate to the site URL, search for a known abstract author or keyword, click into a result, and confirm the panel shows: title; poster id (formatted as the program assigned it, not as a submission id); ordered author list with affiliations; abstract sections (Introduction, Methods, Results, Conclusion); and "Topics" + "Methods" structured-question values. Verify no withdrawn abstracts appear in any list, search, or projection at any time. + +**Acceptance Scenarios**: + +1. **Given** a phone in portrait orientation, **When** the user opens the site and types `"connectivity"`, **Then** the search returns matching accepted abstracts within 1 second; each result card shows poster id, title, lead author + affiliation, primary topic; tapping a card opens the detail panel as a full-screen overlay readable without horizontal scrolling. +2. **Given** the corpus contains 3,244 accepted abstracts + ~166 withdrawn submissions, **When** the user performs any search, filter, or projection, **Then** zero withdrawn abstracts appear in any UI surface; the visible total never exceeds the accepted-only count. +3. **Given** an abstract whose poster id is `M-AM-101` and submission id is `49213`, **When** the user opens that abstract's detail panel, **Then** the panel header displays `M-AM-101` as the primary identifier; the submission id is not shown anywhere. +4. **Given** an abstract with 12 listed authors, **When** the user opens the detail panel, **Then** all 12 authors appear in submission order with their affiliations; mobile view collapses the list under a "show all 12 authors" toggle when it exceeds the viewport. + +--- + +### User Story 2 — Explore the 2D semantic map with lasso selection (Priority: P2) + +A reviewer wants to see the global structure of the accepted corpus. They open the 2D UMAP, pan/zoom, and lasso a region of dots to drill into a thematic cluster. The result list, facets, and details panel update to reflect the lasso selection. They can switch the underlying model (e.g., voyage abstract → neuroscape claims) to see how the layout changes. + +**Why this priority**: The UMAP is the visual hook that distinguishes this site from a vanilla search interface. Lasso is the existing user gesture from the prior UI — preserving it keeps the muscle memory intact. + +**Independent Test**: Open the projections panel, draw a lasso around 50–200 points on the 2D UMAP, confirm the result list shrinks to those points, the facet counts update accordingly, and clearing the lasso restores the full corpus. Then switch model from default `neuroscape / abstract` to `voyage / claims` and verify the point positions change while the lasso selection-by-id is preserved (the same abstracts stay selected; only their geometric positions move). + +**Acceptance Scenarios**: + +1. **Given** the user is on the 2D UMAP, **When** they drag a lasso around a cluster, **Then** the result count, facet counts, and detail-panel "selected" state all update within 500 ms; clearing the lasso returns the previous global state. +2. **Given** the user has selected `voyage / abstract` from the model dropdown, **When** they switch to `neuroscape / claims`, **Then** the 2D and 3D UMAPs re-render with the new model's coordinates; the lasso selection (by abstract id) persists. +3. **Given** the 3D UMAP is displayed, **When** the user interacts with it, **Then** the user can rotate, pan, and zoom; the 3D view does NOT support lasso (3D lasso is out of scope; only the 2D view supports it). + +--- + +### User Story 3 — Semantic + lexical search with typo tolerance (Priority: P2) + +A user types a search query. The system runs **both** a semantic match (a sentence embedding compared against the corpus) AND a lexical match (with typo tolerance) and merges results. Author search treats common name misspellings (e.g., one transposed letter, one missing letter) as matches. + +**Why this priority**: Search quality is what distinguishes a useful site from a slow filter. Semantic + lexical together gives both "I know roughly what I'm looking for" and "I know the exact phrase / surname" coverage. + +**Independent Test**: Type the query `"defautl mode netwrk"` (two typos) — confirm the lexical matcher still surfaces "default mode network" abstracts. Type the surname `"Smtih"` (1 transposition) and verify abstracts by `"Smith"` appear in author search. Type a phrase that doesn't appear verbatim in any abstract (e.g., `"how the brain remembers faces"`) and confirm the semantic search surfaces face-memory-related abstracts. + +**Acceptance Scenarios**: + +1. **Given** the user types a 3+ character query, **When** the lexical search runs, **Then** matches within Damerau-Levenshtein distance 2 are surfaced for words ≥4 characters (1-edit for shorter words); typo-tolerance is on by default. +2. **Given** the user types text in the search box, **When** results appear, **Then** semantic-only matches are visually distinguished from lexical/exact matches (e.g., badge or section header), and the user can filter to "semantic only" / "lexical only" / "both". +3. **Given** the user types text into the author-search field, **When** results appear, **Then** the field tolerates 1 typo for short surnames (≤4 chars) and 2 typos for longer names; matching authors and their abstracts are returned. + +--- + +### User Story 4 — Interactive facets that follow selection (Priority: P3) + +A user filters by faceted dimensions (accepted-for, primary topic, secondary topic, keywords, methods, study type, population, field strength, processing packages, species, recording technology, brain regions, brain networks). When they apply a facet filter OR lasso a UMAP region OR run a search, the facet counts re-compute so that each remaining option reflects only what's reachable from the current selection. + +**Why this priority**: Faceted exploration is how organizers + reviewers narrow down 3,244 abstracts to a manageable shortlist. The "facets update with selection" behavior is what separates a usable filter from a frustrating one. + +**Independent Test**: Apply `Methods = fMRI` and confirm the `Species` facet now shows only species that appear in fMRI abstracts (e.g., Human × 1,840; Macaque × 24). Lasso a region of the UMAP and confirm the facet counts contract further. Clear filters and counts return to the corpus totals. + +**Acceptance Scenarios**: + +1. **Given** the user applies a facet filter, **When** the result list updates, **Then** every other facet's per-option count updates to reflect only the remaining reachable abstracts (combined-filter counts, not absolute). +2. **Given** the user has lassoed a UMAP region, **When** they then click a facet option, **Then** the result is the intersection (lasso ∩ facet); both visual surfaces (UMAP highlight + result list) reflect the intersection. + +--- + +### User Story 5 — Save abstracts to a cart and email the list (Priority: P3) + +A user finds 8 abstracts of interest while browsing. They click "add to my list" on each. The cart icon shows `8`. They click "email my list" — a system-native email composer opens (mail client launches with a pre-filled message) containing the list of poster ids + titles + per-abstract permalinks back to this site. + +**Why this priority**: Reviewers + meeting planners frequently triage abstracts and want to share their picks with collaborators. Today they paste links into Slack/email by hand; the cart shortens that loop to one click. + +**Independent Test**: Add 3 abstracts to the cart from different facet-filtered views, click "email my list", verify the OS mail composer opens with the body listing the 3 poster ids + titles + permalinks; the cart can be cleared or items removed individually before emailing. + +**Acceptance Scenarios**: + +1. **Given** the cart is empty, **When** the user clicks "add to list" on an abstract card, **Then** the cart badge increments to 1 and the abstract id is persisted across page reloads (within the same browser). +2. **Given** the cart has items, **When** the user clicks "email my list", **Then** the OS mail composer opens with a subject like `"OHBM 2026 — my abstracts (N)"` and a body containing one line per item: poster id, title, link back to the site's abstract anchor. +3. **Given** the user is on a mobile device, **When** they trigger "email my list", **Then** the mobile mail client launches (iOS Mail / Gmail / Outlook depending on default handler). + +--- + +### User Story 6 — Optional guided walkthrough for first-time visitors (Priority: P4) + +A first-time visitor lands and sees an unobtrusive "Take the tour" button. If they click it, an overlay walks them through the search box, the UMAP, the model selector, the facet sidebar, and the cart — each step highlighting the relevant UI element. If they ignore the button, nothing else nags them. + +**Why this priority**: New visitors to a search UI with this much surface area get lost; existing UI feedback shows people miss the lasso + model selector. A skippable tour solves that without forcing onboarding on returning users. + +**Independent Test**: Open the site in an incognito window, confirm the "Take the tour" call-to-action is visible but unobtrusive (small button or banner, dismissible). Click it; verify each step highlights the right region and a "next / previous / skip" UI is present. Reload the page after dismissing the tour; verify it doesn't auto-launch again unless the user explicitly clicks "restart tour". + +**Acceptance Scenarios**: + +1. **Given** the user has never visited the site, **When** they land on the home page, **Then** a "Take the tour" CTA is visible (e.g., header button or one-time banner) but no modal auto-opens. +2. **Given** the user clicks "Take the tour", **When** the tour runs, **Then** it visits ≥ 5 stops (search, model selector, UMAP, facets, cart) with clear "next / previous / skip" controls; the tour can be re-launched anytime from a "?" or "help" affordance. + +--- + +### User Story 7 — About page with verified references for the data-processing methods (Priority: P4) + +A general neuroscientist clicks "About" to understand how this corpus was built. They get a one-paragraph overview, then collapsible sections that drill into each stage (corpus ingestion → enrichment → embeddings → analysis & annotation). Each section's claims about methods cite verifiable references (textbook chapters, published methods papers, software docs). External reference links open in a new tab. + +**Why this priority**: Without this, the site is opaque about its provenance — and reviewers + organizers need to trust the pipeline before they trust the rankings/clusters. Putting the explanation behind progressive disclosure keeps the front page focused on browsing while giving the deeper details to those who want them. + +**Independent Test**: Open the About page, confirm the top section is readable in under 2 minutes by a non-specialist. Expand each collapsible deep-dive section and confirm every methods claim links to a real, accessible reference (e.g., UMAP paper, Leiden paper, HDBSCAN paper, NeuroScape paper, Voyage AI docs). Clicking any external link opens it in a new tab. + +**Acceptance Scenarios**: + +1. **Given** the user opens the About page, **When** they read the top section, **Then** the overview is ≤ 250 words and uses no jargon beyond "embedding", "cluster", and "UMAP" (each defined inline at first mention). +2. **Given** the user expands the "Embedding models" deep dive, **When** they click any reference link, **Then** the link points at a real reachable URL (no 404s; verified at build time) and opens in a new browser tab (`target="_blank"` + `rel="noopener noreferrer"`). +3. **Given** the deep dives cover Stages 1–4, **When** the user reads each, **Then** each stage references the canonical published method paper (corpus fetch: Oxford Abstracts GraphQL — vendor docs; figure interpretation: GPT-4-vision-class reference; claim extraction: the ECO ontology paper; references: OpenAlex; embeddings: model-card / paper for each of voyage / minilm / openai / pubmedbert / neuroscape; analysis: UMAP, Leiden CPM, HDBSCAN, FAISS; topics: BERTopic + spaCy). + +--- + +### User Story 8 — Deploy continuously via GitHub Actions with per-PR previews (Priority: P5) + +A maintainer pushes a PR that touches the UI or the data package. A GitHub Action builds the site and publishes a preview URL (e.g., `https://.github.io//pr-/`) that's commented on the PR. When the PR merges, the action redeploys the main GitHub Pages site. When the PR closes (merged or not), the preview is cleaned up. + +**Why this priority**: Without preview deploys, reviewers test changes locally — slow, error-prone, and inconsistent. Per-PR previews close the design-review loop in minutes. + +**Independent Test**: Open a draft PR that changes a UI file; confirm within 10 minutes the **PR's Deployments box** (top-of-PR, NOT the conversation) shows a "View deployment" link for the `pr-preview-` environment; click it and verify the change is live; close the PR; verify within an hour the Deployments box marks the deployment "Inactive" and the URL returns 404 (cleaned up). Merge a different PR to main; confirm the production site updates within 10 minutes of the merge. + +**Acceptance Scenarios**: + +1. **Given** a PR is opened, **When** the GitHub Action runs, **Then** the preview URL surfaces in the PR's **Deployments box** within 10 minutes (the workflow declares `environment: { name: pr-preview-, url: ... }` so GitHub auto-creates the deployment entry); subsequent commits update the same environment URL in place (no environment churn, no conversation spam). +2. **Given** a PR is closed (merged or rejected), **When** the close event fires, **Then** the preview directory is removed from the gh-pages branch AND the `pr-preview-` deployment is marked **inactive** via the Deployments API within 30 minutes; the preview URL returns 404; the Deployments box shows the deployment in the inactive state. +3. **Given** a merge to `main` occurs, **When** the deploy workflow completes, **Then** the production site at the canonical GitHub Pages URL reflects the merged changes; preview directories from other open PRs are not disturbed. +4. **Given** the first delivery of Stage 6, **When** US8 is shipped as a small first PR with a placeholder site (e.g., "Stage 6 — under construction" landing page + the empty data-package builder skeleton), **Then** the workflows are exercised end-to-end and reviewers can use the live PR-preview deployment on subsequent PRs (US1–US7) to evaluate UI changes before merge. + +--- + +### Edge Cases + +- **Empty search** — when the search box is empty, the site shows the full corpus state (no implicit filter). Clearing all filters returns to the same global state. +- **Single result** — when search + facets narrow to a single abstract, the detail panel auto-expands. If the user then clears the filter, the panel collapses but the cart contents persist. +- **Mobile lasso** — the 2D lasso gesture requires a click-drag that conflicts with mobile pan. On mobile, the lasso is replaced by a "select cluster" tap mode (tap a point → select its containing community by community-id); the lasso re-enables when the viewport is ≥ 1024 px wide. +- **Empty cart email** — clicking "email my list" with an empty cart shows a toast `"Add abstracts first"` instead of opening an empty composer. +- **Mail client unavailable** — if the user's environment has no default mail handler (some Linux desktop / kiosk setups), clicking "email my list" falls back to displaying the email body in a modal with a "copy to clipboard" button. +- **Typo tolerance + short queries** — for queries < 3 characters, typo tolerance is disabled (otherwise every 3-letter abbreviation matches half the corpus). The threshold is documented in the help tooltip. +- **3D UMAP on low-end devices** — the 3D view may stutter on older phones; users on such devices can switch to the 2D tab manually. Automatic FPS-based degradation is out of scope for v1. +- **About-page external link breakage** — if a reference URL becomes a 404, the build action fails noisily so the link is fixed before deploy. (Out-of-scope: handling dead links at runtime.) +- **PR preview collisions** — two PRs touching the same files publish to distinct preview URLs (`pr-`); they never overwrite each other. +- **Walkthrough on small screens** — the tour layout adapts: on phones the highlight + tooltip stack vertically rather than side-by-side. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001 (Audience scope)**: The site MUST show only **accepted** abstracts (`accepted_for ≠ "Withdrawn"`). Withdrawn submissions MUST NOT appear in any search, projection, facet, cart, or detail surface. +- **FR-002 (Poster id)**: Every abstract reference (URL fragment, cart item, detail header, list card) MUST use the program-assigned **poster id** as the user-facing identifier. The submission id is internal only and MUST NOT be displayed in the UI. +- **FR-003 (Authors)**: Every abstract detail panel MUST include the ordered author list with each author's affiliation. The data package MUST include author records joined to abstracts. (The current UI omits this; restoring it is part of this stage.) +- **FR-004 (Model selection)**: The user MUST be able to choose from the 5 models (`voyage`, `minilm`, `openai`, `pubmedbert`, `neuroscape`) crossed with the 3 inputs (`abstract`, `claims`, `methods`). Default selection is `neuroscape` × `abstract`. The 2D/3D UMAPs, lasso semantics, and (where applicable) facet/clustering counts reflect the selected (model, input) cell. +- **FR-005 (2D UMAP + lasso)**: The user MUST be able to view a 2D UMAP scatterplot, pan/zoom, and lasso-select a region. The lasso selection becomes the active filter (intersected with search + facets). +- **FR-006 (3D UMAP)**: The user MUST be able to view a 3D UMAP. The 3D view supports rotate / pan / zoom but NOT lasso (out of scope for v1). +- **FR-007 (Semantic search)**: The user MUST be able to type a free-text query and receive abstracts ranked by semantic similarity. The semantic similarity MUST be computed client-side (no server roundtrip) using a published sentence-embedding model that runs in the browser. +- **FR-008 (Lexical search)**: The user MUST be able to perform a lexical search with typo tolerance (Damerau-Levenshtein distance ≤ 2 for words ≥ 4 characters; ≤ 1 for shorter). Lexical matches surface across abstract title, sections, keywords, methods, and author names. +- **FR-009 (Search-result merging)**: Semantic + lexical results MUST be merged into a single ranked list; the user MUST be able to filter to "semantic only", "lexical only", or "both" via a visible control. +- **FR-010 (Author search with typo tolerance)**: The author-search input MUST tolerate 1 typo for surnames ≤ 4 characters and ≤ 2 typos for longer surnames. Diacritics MUST be matched case- and accent-insensitively (e.g., `"García" ≈ "Garcia"`). +- **FR-011 (Detail panel — extra questions scope)**: The abstract detail panel MUST display only two "extra question" fields from the submission form: **Topics** (Primary + Secondary Parent Category & Sub-Category) and **Methods** (the methods-checklist question). All other submission-form questions MUST NOT be shown in the detail panel. +- **FR-012 (References open externally)**: When an abstract carries reference links (DOI / external URL via OpenAlex), each link MUST open in a **new browser tab/window** with `rel="noopener noreferrer"` semantics. +- **FR-013 (Interactive facets)**: The facet sidebar MUST recompute every facet's per-option counts whenever the active selection changes (any combination of: search query, facet filter, UMAP lasso). Counts MUST reflect the intersection of all active filters. +- **FR-014 (Shopping cart)**: The user MUST be able to add any abstract to a cart from any result surface (list card, detail panel, search result). The cart MUST persist across page reloads in the same browser. The user MUST be able to remove individual items or clear the cart. +- **FR-015 (Email-my-list)**: The user MUST be able to launch an "email my list" action that opens the OS mail composer (mailto: link) with a pre-filled body listing each cart item's poster id, title, and a permalink back to the site's abstract anchor. If no mail handler is registered, the site MUST fall back to a copy-to-clipboard modal. +- **FR-016 (Walkthrough)**: The site MUST offer a discoverable "Take the tour" affordance. The tour MUST NOT auto-launch for returning users; it MUST be re-launchable from a persistent help affordance. +- **FR-017 (About page)**: The site MUST include an About page with a ≤ 250-word non-specialist overview followed by per-stage collapsible deep-dives (Stages 1–4 plus topics + UMAP). Every methods claim MUST link to a real, accessible external reference; build-time link validation MUST fail the deploy if any link is broken. The About page MUST include a section per AI-generated content surface (figure interpretation, claim extraction, LLM-grouped topic clusters) explaining the model, prompt-design intent, and known limitations — the FR-023 `✨ AI` pill tooltips deep-link into these sections so readers can drill from the inline disclosure into the full methodology. +- **FR-018 (Responsive layout)**: The site MUST render usably on phones (≥ 360 px wide), tablets (≥ 768 px wide), and desktops (≥ 1024 px wide). Result lists, detail panels, facets, and the UMAP layout MUST adapt; the lasso gesture is desktop-only (see Edge Cases). +- **FR-019 (Data package — static JSON shards)**: The deployed data package MUST be a set of **static JSON shards** organized abstract-centric — no DuckDB-WASM, no Parquet, no client-side query engine. The canonical layout is: + - `data/manifest.json` (≤ 5 KB) — corpus state-key, code revision, build timestamp, shard URL pointers, facet keys + ordered options, and the (model, input) cell catalog discovered from the Stage 4 rollup at build time. Loaded first; everything else lazy-loaded off of it. + - `data/abstracts.json` (≤ 6 MB gz) — array of 3,244 accepted-only records, each `{abstract_id, poster_id, title, accepted_for, sections: {introduction, methods, results, conclusion}, references: [{text, doi?, url?}], topics: {primary, secondary, subcategories}, methods_checklist, author_ids: [int]}`. Stripped of `submission_id` everywhere; withdrawn rows excluded at build time. + - `data/authors.json` (≤ 1.5 MB gz) — array of unique author records `{author_id, name, affiliations: [str], abstract_ids: [int]}`. Loaded once at startup; joined to abstracts in-memory via `author_ids`. + - `data/cells/_.json` (15 files, each ≤ 100 KB gz) — per-cell coordinate + cluster table indexed by `abstract_id`: `[{abstract_id, umap2d: [x,y], umap3d: [x,y,z], community_id, topic_cluster_id, neuroscape_cluster_id?, neuroscape_cluster_distance?}, …]`. Lazy-loaded on demand; the default `neuroscape_abstract` cell is fetched at startup, the other 14 only when the user selects them. + - `data/topics/__.json` (≤ 45 files, each ≤ 30 KB gz) — per-cluster topic metadata: `{cluster_id, keywords: [str], title, description, focus}`. Lazy-loaded alongside the matching cell. `` ∈ {communities, neuroscape_clusters, topic_clusters}. + - `data/search/lexical_index.json` (≤ 500 KB gz) — pre-built inverted index for typo-tolerant lexical search (n-gram bag per token, mapped to `abstract_id` postings lists). Built once at deploy time so the browser doesn't pay the indexing cost. + - `data/search/minilm_vectors.bin` (≤ 1.5 MB) — int8-quantized MiniLM-L6 embeddings for the 3,244 accepted abstracts, fixed shape `[3244, 384]` little-endian. Lazy-loaded on first semantic query. + - The MiniLM ONNX model itself is served from a public CDN (e.g., Hugging Face) and cached by the browser; it is NOT bundled into this data package. + - All shards MUST embed a `build_info` block: `{corpus_state_key, code_revision, code_revision_short, stage4_rollup_state_key, built_at}` — the same block the footer's "build info" affordance exposes (CA-008) and which carries the short committish surfaced per FR-022. Raw-array JSON shards are forbidden (data-model.md §8 invariant 6); each shard is an object envelope. The `minilm_vectors.bin` carries its build_info via a co-located `minilm_vectors.build_info.json` sidecar. + - Shard fetches are parallel where independent (manifest + abstracts.json + authors.json + default cell + default topics start in parallel right after manifest resolves). +- **FR-020 (GitHub Pages deploy)**: A GitHub Action MUST build and deploy the site to GitHub Pages on every merge to `main`. The production URL is the canonical GitHub Pages root for the repository. +- **FR-021 (PR previews via GitHub Deployments)**: Every open PR MUST receive a preview deploy at a distinct URL (e.g., `/pr-/`). The preview URL MUST surface in the **PR's Deployments box** (top-of-PR, via the GitHub Deployments API populated from the workflow's `environment:` declaration), **NOT** as a bot comment in the conversation. Subsequent pushes to the PR MUST update the same `pr-preview-` environment's URL in place (no environment churn). On PR close (merge OR reject), the workflow MUST deactivate the deployment via the Deployments API AND remove the `/pr-/` directory from the gh-pages branch within 30 minutes — the Deployments box then shows the deployment as "Inactive" with no live URL. +- **FR-022 (Build provenance visible in the UI)**: Every rendered route MUST display the build provenance in a persistent **page-footer "build info" affordance**: the short code-revision tag (first 7 chars of the git SHA, e.g. `a1b2c3d`), the corpus state-key suffix, and the build timestamp. Clicking the affordance MUST reveal the full `build_info` block (full SHA, full corpus + Stage 4 rollup state-keys, ISO timestamp). The short code-revision MUST also appear in the page `` suffix for the placeholder route (e.g. `OHBM 2026 — under construction · a1b2c3d`) so reviewers can verify which committish a PR-preview deploy is built from without opening the page. Source: `manifest.json:build_info.code_revision_short` (data-model.md §0). When the deploy SHA (VITE_BUILD_SHA env) and the data SHA (`manifest.build_info.code_revision_short`) differ, the footer MUST display both (separately labelled `build` and `data`) and the `<title>` MUST prefer the deploy SHA — the deploy SHA always reflects the live code; the data SHA only flips when the maintainer rebuilds the tarball. +- **FR-023 (AI-attribution indicator)**: Any content surface that was **authored or interpreted by an LLM** MUST display an `✨ AI` pill next to its section header. The pill MUST be hover/focus/tap interactive and surface a tooltip naming the specific model that produced the content (e.g. `gpt-5.4-mini` for figure interpretation + claim extraction; `qwen3-7B` for topic-cluster titling) along with a deep-link to the About-page section explaining the generation pipeline. Screen-reader users MUST hear an equivalent disclosure via `aria-label`. **Scope (whitelist)**: figure interpretations, extracted claims, LLM-grouped topic-cluster titles/descriptions/focuses. **Out of scope (explicitly do NOT carry the pill)**: references (LLM only assisted the split — the DOI/OpenAlex lookup produces the canonical metadata), abstract sections (verbatim from the submitter), topic dropdowns + methods checklists (picklist values), authors + affiliations. Header-level only — no per-item markers. The model-attribution payload is sourced from the per-component build metadata in the data package (carried alongside each AI-generated field; surfaced via a new `ai_provenance` block on the relevant shards — to be specced in data-model.md when the AI surfaces ship in US2 / future US). + +### Key Entities + +- **Accepted abstract** — the unit of display. Identified by **poster id**. Carries: title, authors (ordered, with affiliations), accepted_for, abstract sections (intro/methods/results/conclusion), references list, the two visible "extra questions" (Topics + Methods), and per-(model, input) UMAP coordinates + community / topic_cluster / neuroscape_cluster ids. +- **Author record** — name, ordered affiliation list, abstract membership (which abstracts they appear on, in which authorship order). +- **Model selection cell** — `(model, input)` pair drawn from `{voyage, minilm, openai, pubmedbert, neuroscape} × {abstract, claims, methods}`. 15 cells total. Default `neuroscape / abstract`. +- **Cart entry** — `{poster_id, title, abstract_anchor_url, added_at}`. Persisted in browser local storage. +- **Facet** — `(facet_key, option_value) → count`. Facet keys: `accepted_for`, `primary_topic`, `secondary_topic`, `keywords`, `methods`, `study_type`, `population`, `field_strength`, `processing_packages`, `species`, `recording_technology`, `brain_regions`, `brain_networks`. +- **Tour step** — `{anchor_selector, title, body, order, optional_predicate}`. The tour reads a small ordered list of steps; first-time visitors see the CTA but never auto-launch. + +### Constitution Alignment *(mandatory)* + +- **CA-001 (Venv-only Python)**: Build-side scripts that produce the UI data package MUST use `.venv/bin/python` or `uv` targeting that interpreter. (Node tooling for the site itself is separate and lives under `ui/` or `site/`.) +- **CA-002 (Plan-first, test-first)**: This stage adds a behavior-changing UI. Test scope includes: build-time link validation, the per-(model, input) data-shape contract test, and at least one end-to-end smoke per US1 (page loads, search works, abstract opens) using a headless browser. Test-first applies — those tests are written/identified before implementation begins. +- **CA-003 (Docs sync)**: The README operations runbook, CLAUDE.md module list, and the project charter (`docs/reproducibility-vision.md`) MUST be updated alongside the implementation to reflect the new build command, the deploy workflow, and the new UI package boundary. +- **CA-004 (Secrets)**: The deploy workflow MUST NOT require any custom secret beyond the default `GITHUB_TOKEN`. No OpenAI/Anthropic keys at runtime — the site is fully static + client-side. +- **CA-005 (No committed data)**: The UI data package landing zone MUST be under an existing gitignored root (`data/outputs/exported-sites/...` or `export/ui-site/`). The GitHub Action publishes to the `gh-pages` branch — that branch's contents are auto-generated and not part of source review. +- **CA-006 (Fail loudly)**: Reference link validation MUST be a hard build gate. Search/cart/walkthrough error paths surface user-facing messages (no silent failures). +- **CA-007 (Discover external state)**: The data package's per-(model, input) cells MUST be discovered from the Stage 4 rollup at build time, not hardcoded — adding a 6th model later requires zero UI code changes beyond a rebuild. +- **CA-008 (Provenance)**: The data package MUST embed a build-stamp metadata block (corpus_state_key, code revision (full git SHA + 7-char short SHA), Stage 4 rollup state-key, build timestamp) on **every shard** (per data-model.md §8 invariant 6; raw-array shards are forbidden). The short SHA MUST be visible in the site footer on every route AND in the page `<title>` suffix on the placeholder route per FR-022, so PR-preview deploys can be visually verified as the right committish without DevTools. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001 (Performance — first paint)**: On a typical broadband connection, the site reaches first interactive paint (visible search box + result list) within **3 seconds**. +- **SC-002 (Performance — search)**: A typed query returns ranked results within **500 ms** for the median query (3,244-row corpus). +- **SC-003 (Performance — UMAP)**: Switching between two `(model, input)` cells re-renders the 2D UMAP within **1 second** on a recent laptop. +- **SC-004 (Mobile usability)**: All US1 acceptance scenarios pass on a mobile viewport (360 × 640 px); the user can complete the search → detail flow without horizontal scrolling. +- **SC-005 (Accepted-only invariant)**: Every list, projection, facet, cart, and detail surface contains **zero** withdrawn abstracts — verified by an automated test that scans the deployed data package for any `accepted_for == "Withdrawn"` records. +- **SC-006 (Data-package size)**: The static JSON shard set downloaded for first paint (manifest + abstracts + authors + the default `neuroscape_abstract` cell + its topics + the lexical index) MUST be ≤ **8 MB gzipped**. The full per-cell expansion (all 15 cells + all topic files) MUST be ≤ **3 MB gzipped** on top of the first-paint set (~11 MB gz max for the complete data package). The int8 MiniLM vectors (≤ 1.5 MB) MUST be lazy-loaded only on the first semantic query. +- **SC-007 (Reference-link health)**: The build action's link checker fails the deploy if **any** external reference URL on the About page returns a non-200 status. Verified at every build. +- **SC-008 (PR-preview latency)**: From PR push to live preview URL, the action completes within **10 minutes** at the 90th percentile. +- **SC-009 (Cart persistence)**: A cart with 5 items survives a full page reload + browser restart in the same browser (within local-storage retention norms — typically 7+ days). +- **SC-010 (Typo tolerance)**: 90% of single-typo queries (insert / delete / substitute / transpose) against known abstract titles or author surnames surface the correct abstract in the top 10 results. +- **SC-011 (Provenance visible per FR-022)**: On any live PR-preview deploy, a reviewer can confirm the committish without opening DevTools — the short SHA appears in the page title suffix AND in the footer build-info affordance on every route (home, about, abstract permalink). Verified by a Playwright assertion that fails if the rendered footer does NOT include `manifest.build_info.code_revision_short`. +- **SC-012 (AI-attribution disclosure per FR-023)**: 100% of AI-authored content surfaces (figure interpretations, extracted claims, LLM-grouped topic-cluster titles/descriptions) display the `✨ AI` pill on their section header AND expose the specific model name via hover/focus/tap. Verified by a Playwright assertion that, for each AI-section testid, finds the pill, opens its tooltip, and confirms the tooltip text contains the model identifier from the data shard's `ai_provenance` block. + +## Assumptions + +- **Modern framework**: the site is implemented in a modern JavaScript framework. The spec doesn't mandate a specific one — implementation can choose (React + Vite, Svelte + Vite, Astro, etc.) based on bundle-size / DX trade-offs. +- **In-browser sentence-embedding model**: the user-named "minilm and its JS model" is interpreted as a small Sentence-Transformers MiniLM checkpoint shipped as a quantized ONNX model loaded via a browser ML runtime (e.g., transformers.js, ONNX Runtime Web). Implementation will pick the smallest checkpoint that hits SC-002 + SC-010. +- **Author affiliations**: per-author affiliation strings come from the existing `data/primary/authors.json` (Stage 1's inline author fetch from the GraphQL API). +- **Poster id semantics**: the program-assigned poster id (e.g., `M-AM-101`) is already in the Stage 1 corpus under `poster_id`. The current UI displays submission ids because the legacy export forgot to surface `poster_id`; the fix is to use it everywhere. +- **References data**: each abstract's reference list comes from Stage 2.1's OpenAlex-resolved `data/primary/reference_metadata.json`. References without a DOI/URL are still listed but without a clickable link. +- **Cart persistence**: browser local storage; no server-side cart. Email integration is `mailto:` (no SMTP relay). +- **Walkthrough state**: a single localStorage key tracks "user has dismissed the tour CTA at least once" so the CTA isn't a banner forever. +- **About-page references**: every external link is validated at build time via the same GitHub Action; the link checker uses a simple HEAD request with a 10-second timeout per URL. +- **PR preview cleanup**: cleanup is triggered by the `pull_request.closed` event; the action commits a directory removal to the `gh-pages` branch. +- **Mobile lasso replacement**: the 2D lasso is desktop-only; mobile users tap a point to select its community (the community-id from the active `(model, input)` cell's `communities` bundle). +- **Data-package layout**: locked in by the Session-2026-05-17 clarification as a static-JSON-shard architecture — no DuckDB-WASM, no Parquet, no client-side query engine. See FR-019 for the full shard list and SC-006 for the size budget. +- **Lexical-search index**: pre-built at deploy time (n-gram inverted index over tokens), serialized into `data/search/lexical_index.json`. The browser doesn't compute the index at runtime — it just loads it and runs typo-tolerant lookups against it. +- **Facet aggregation**: computed in JavaScript at query time over the in-memory abstract array. At 3,244 rows, a full 13-facet recount is <10 ms on a typical laptop — no SQL or query engine needed. + +## Wireframe prompt for Claude Design + +If you choose to ask Claude Design to draft a wireframe before implementation, here is a prompt you can paste verbatim: + +> Design a wireframe for a public static search site over a corpus of 3,244 accepted scientific conference abstracts (OHBM 2026). The site is read-only, served from GitHub Pages, and must be **responsive (phone / tablet / desktop)**. Brand voice: scientific, clean, no ads. +> +> **Primary layout (desktop, ≥ 1024 px)**: three-column shell. +> - **Left column (240 px)** — collapsible facet sidebar. ~12 facet sections (accepted_for, primary_topic, secondary_topic, keywords, methods, study_type, population, field_strength, processing_packages, species, recording_technology, brain_regions, brain_networks). Each section has a per-option count that updates with selection. +> - **Center column (flexible)** — top: search bar with a "semantic / lexical / both" toggle + author-search subfield + clear button. Below: a tabbed area with two tabs: **2D UMAP** (Plotly-style scatterplot with lasso) and **3D UMAP** (rotatable scene without lasso). Below the projection: a virtualized result list of abstract cards. Each card shows poster id, title, lead author + affiliation, primary topic, an "add to list" button. A persistent "build info" footer affordance shows the short git SHA (e.g. `a1b2c3d`), corpus state-key suffix, and build timestamp — visible on every route so PR-preview deploys can be verified at a glance (FR-022). +> - **Right column (320 px)** — sticky detail panel. Shows the currently focused abstract: poster id, title, full author + affiliation list (collapsible if > 6), abstract sections (intro / methods / results / conclusion with collapsible "show more"), Topics + Methods, references (each link opens in a new tab), an "add to list" button. +> +> **Top header bar**: project name on the left; model-selection dropdowns in the center (model × input, default `neuroscape × abstract`); cart icon with item count on the right; "Take the tour" CTA next to the cart; "About" link in the header. +> +> **Mobile layout (< 768 px)**: single column. Search bar pinned to the top; "filters" button opens the facet sidebar as a full-screen drawer; "map" button opens UMAP as a full-screen overlay; result list is the default home view; tapping a result opens the detail panel as a full-screen overlay with a close button. Lasso is replaced by "tap a UMAP point to filter by its community". Cart icon stays in the top bar. +> +> **Walkthrough overlay**: when the user clicks "Take the tour", an overlay highlights each component in turn — search → model selector → UMAP → lasso (desktop only) → facets → cart — with a small tooltip and Next/Prev/Skip controls. Skippable, never auto-launches. +> +> **About page**: a separate route with a 250-word non-specialist overview at the top, followed by 5–7 collapsible deep-dive sections (corpus, enrichment, embeddings, analysis & annotation, topics, projections). Each deep dive cites real references that open externally. +> +> **Email-my-list flow**: when the user clicks "Email my list" with cart items, the OS mail composer opens with a pre-filled subject + body listing items. +> +> Use a clean, neutral color palette (mostly grayscale + one accent for the lasso / selected state); typography should be readable on small screens. Avoid skeuomorphic shopping-cart imagery — prefer a "saved list" or "bookmark" metaphor. diff --git a/specs/008-ui-rewrite/tasks.md b/specs/008-ui-rewrite/tasks.md new file mode 100644 index 00000000..dc94d31e --- /dev/null +++ b/specs/008-ui-rewrite/tasks.md @@ -0,0 +1,423 @@ +--- +description: "Task list for Stage 6 — UI Rewrite (Static Search Site)" +--- + +# Tasks: Stage 6 — UI Rewrite (Static Search Site) + +**Input**: Design documents from `/specs/008-ui-rewrite/` +**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, data-model.md ✓, contracts/ (3 files) ✓, quickstart.md ✓ + +**Tests**: Per spec CA-002, this stage is behavior-changing and follows standard test-first discipline. Python `unittest` covers the data-package builders + link checker. JS Vitest covers stores + search + facet math. Playwright covers end-to-end smoke per user story. Test tasks land before their implementation tasks within each story phase. + +**Organization**: Tasks are grouped by user story so each story is independently shippable as a per-route slice. + +**Sequencing note (Session 2026-05-17 clarification)**: **US8 ships first as a small standalone PR** so US1–US7 can be reviewed via live PR previews. The preview URL surfaces in the PR's **Deployments box** (top-of-PR, via `environment:` declaration on the workflow), NOT as a bot comment. The Phase 3 placeholder Svelte page is intentionally minimal — it gives the deploy workflow something to publish + lets reviewers verify the data-package builder skeleton is wired through end-to-end before any real UI work lands. + +## Format: `[ID] [P?] [Story?] Description with file path` + +- `[P]`: Can run in parallel (different files, no incomplete-task dependencies). +- `[Story]`: `[US1]` through `[US8]` (Setup / Foundational / Polish phases carry no story label). +- All paths are project-relative. + +## Path Conventions + +- **Python builders**: `src/ohbm2026/ui_data/` + `scripts/build_ui_data.py` +- **Site**: `site/` (self-contained SvelteKit project with own `package.json`) +- **Python tests**: `tests/test_ui_data_*.py` +- **JS unit tests**: `site/src/tests/unit/*.test.ts` +- **JS e2e tests**: `site/src/tests/e2e/*.spec.ts` +- **GitHub workflows**: `.github/workflows/` +- **Docs**: `CLAUDE.md`, `README.md`, `docs/reproducibility-vision.md` + +--- + +## Phase 1: Setup + +- [X] T001 Add `[ui]` optional-extras entry to `pyproject.toml` listing `numpy`, `sentence-transformers`, `pyyaml`, `requests` (link checker). Add `[ui-dev]` extra with `playwright` (for screenshots in the link checker — defer if not needed). Bump the project minor version. +- [X] T002 [P] Create `site/` directory with a fresh SvelteKit + Vite + TypeScript scaffold (hand-rolled, matches `pnpm create svelte@latest` skeleton + TypeScript + ESLint + Prettier + Vitest + Playwright). `@sveltejs/adapter-static` configured in `svelte.config.js` with `paths.base` driven by the `BASE_PATH` env var (so PR previews can serve under `/pr-<N>/`). +- [X] T003 [P] Added `site/.gitignore` covering `node_modules/`, `build/`, `.svelte-kit/`, `static/data/` (the data shards are gitignored — they're build output). +- [X] T004 [P] Added `site/package.json` scripts: `dev`, `build`, `preview`, `test:unit` (vitest), `test:e2e` (playwright), `check` (svelte-check + tsc). +- [X] T005 [P] Added deps to `site/package.json`: `@xenova/transformers` (in-browser ML), `plotly.js-basic-dist-min` (lazy-loaded), `shepherd.js` (walkthrough). DO NOT add the MiniLM ONNX model itself — that's fetched from the Hugging Face CDN at runtime. +- [X] T006 Created the `src/ohbm2026/ui_data/__init__.py` package shell (1-line docstring; no re-exports — Stage 5 / Q2 precedent). +- [X] T007 Added `scripts/fetch_ui_inputs.sh` — the GitHub Action calls this to materialize `data/primary/*` + `data/outputs/analysis/*` + `data/outputs/embeddings/minilm/*` from a release artifact or DVC store. The script MUST NOT hardcode any state-key; `state_key.py` (T011a) does the discovery at build time after the inputs are materialized. Local dev populates the inputs manually per quickstart.md and the discovery still applies. + +--- + +## Phase 2: Foundational (blocks all user stories) + +The Foundational phase lands the data-package builder skeleton — enough so the Phase 3 deploy workflow can call `scripts/build_ui_data.py` end-to-end (even against an empty/minimal inputs set) and the placeholder page can render the manifest. Full per-shard builders mature in later stories; this phase ships the orchestrator + the manifest builder + invariant scaffolding. + +- [X] T008 [P] `tests/test_ui_data_manifest.py::test_manifest_shape` — green. +- [X] T009 [P] `tests/test_ui_data_abstracts.py::test_no_withdrawn_records_leak` (accepted-only invariant) — green. +- [X] T010 [P] `tests/test_ui_data_authors.py::test_dedup_key_collapses_same_name_same_affiliation` — green. +- [X] T011 `src/ohbm2026/ui_data/manifest.py` — discovers cells/inputs/models from `cluster_topics` + facet options from per-abstract `facets` blocks (CA-007). AST scan test `test_no_function_returns_hardcoded_string_lists` — green. +- [X] T011a [P] `src/ohbm2026/ui_data/state_key.py` — `discover_corpus_state_key`, `discover_rollup_state_key`, `discover_minilm_bundle`. Typed `Stage6BuildError` on ambiguous rollups. 4 unit tests — green. Also added `Stage6Error` base to `src/ohbm2026/exceptions.py`. +- [X] T012 [P] `src/ohbm2026/ui_data/abstracts.py` — reuses `ohbm2026.ui.payload` helpers for question lookup + `build_domain_facets`. Strips `submission_id`; emits `poster_id`. Envelope: `{schema_version, build_info, abstracts: [...]}`. +- [X] T013 `tests/test_ui_data_authors.py::test_every_author_id_in_abstracts_exists_after_remap` — green (relaxed pending US1 raw→synthetic remap noted in builder.py). +- [X] T014 [P] `src/ohbm2026/ui_data/authors.py` — R6 dedup (NFC-normalize lower(name) + lower(primary_affiliation)). Withdrawn-only authors dropped. +- [X] T015 `tests/test_ui_data_cells.py::test_positional_join_matches_abstract_id_order` — green. +- [X] T016 `src/ohbm2026/ui_data/cells.py` — projects the wide annotations table into 15 cell shards keyed by `<model>_<input>`. neuroscape-only `neuroscape_cluster_id` + distance fields. +- [X] T017 [P] `src/ohbm2026/ui_data/topics.py` — reads `cluster_topics`, emits 33 topic shards (15 cells × {communities, topic_clusters} + 3 neuroscape cells × neuroscape_clusters). +- [X] T018 `src/ohbm2026/ui_data/builder.py` — orchestrator with 5 of 8 cross-shard invariants enforced (1 corpus-count, 2 positional-join, 3 accepted-only, 5 cluster-id integrity, 6 byte-identical build_info). Invariant 4 (author-id remap) WARN-only pending US1; 7 (link checker) deferred to US7; 8 (size budget) checked at deploy time. +- [X] T019 `scripts/build_ui_data.py` — CLI wrapper; smoke-tested end-to-end against the real corpus (15 cells + 33 topic shards + manifest + abstracts + authors; 4.3 MB gz total vs SC-006's 11 MB budget). +- [X] T020 `tests/test_ui_data_builder.py::test_two_runs_produce_byte_identical_shards` — green. +- [X] T020a `tests/test_ui_data_builder.py::TestEveryShardCarriesBuildInfo::test_every_shard_carries_build_info` — every emitted JSON shard is an object envelope with the 5-key `build_info` (no raw-array shards); every block is byte-identical (§8 invariant 6). + +--- + +## Phase 3: User Story 8 — Deploy workflows (FIRST PR, Priority: P5 → promoted first) 🚀 SHIPS FIRST + +Per the Session-2026-05-17 clarification, US8 ships **first** so subsequent PRs for US1–US7 can be reviewed via live PR-previews. The preview URL surfaces in the PR's **Deployments box** (top-of-PR, via `environment:` declaration), NOT as a bot comment. + +**Story Goal**: Ship the three workflows + a minimal placeholder site so every subsequent PR gets an automatic live preview link in its Deployments box. Production deploys on merge to main. + +**Independent Test**: Open a small PR that adds the workflows + the placeholder. Verify within 10 minutes the PR's **Deployments box** (top of PR, not the conversation) shows "View deployment" → `pr-preview-<N>` linking to `https://<org>.github.io/<repo>/pr-<N>/` and the placeholder page loads. Push another commit; verify the same environment URL updates in place. Close the PR; verify the Deployments box shows "Inactive" and the URL 404s within 30 min. + +### Implementation + +- [X] T021 [P] [US8] `site/src/routes/+page.svelte` + `+layout.svelte` + `site/src/lib/components/BuildInfo.svelte` + `site/src/lib/shards.ts` — placeholder renders `manifest.build_info` with short committish in page title, banner callout, and persistent footer affordance (FR-022). Loads gracefully when no manifest is present. +- [X] T022 [P] [US8] `.github/workflows/deploy-ui.yml` — publishes to `gh-pages` root via `peaceiris/actions-gh-pages@v3` with `keep_files: true`. Data-package build is conditional on `fetch_ui_inputs.sh` succeeding (degrades to placeholder when inputs absent). +- [X] T023 [P] [US8] `.github/workflows/pr-preview.yml` — declares `environment: { name: pr-preview-<N>, url: ... }` so the URL surfaces in the PR's Deployments box (top-of-PR, NOT a bot comment). Skips forks. Deploys to `gh-pages:pr-<N>/` via `BASE_PATH=/pr-<N>`. +- [X] T024 [P] [US8] `.github/workflows/pr-preview-cleanup.yml` — `actions/github-script@v7` lists + marks every deployment for `pr-preview-<N>` environment as `state: "inactive"` AND removes the `pr-<N>/` directory from `gh-pages`. No conversation comment. +- [X] T025 [US8] Pages-source manual step documented in `quickstart.md` (US8 smoke section, lines ~165+). +- [ ] T026 [US8] Draft-PR verification — DEFERRED to when the US8 PR is opened (cannot run a real GitHub workflow from this session). The local build smoke-test confirmed the site renders with `code_revision_short = 6f76939` from the rebuilt manifest; gh-pages deploy + environment surface need a real PR run. +- [X] T027 [US8] Phase 3 ready to commit as the first Stage 6 PR. README "Stage 6: UI (under construction)" section + CLAUDE.md `ui_data/` module entry updated inline per Constitution IV. + +**Stop condition for the Phase 3 PR**: do NOT bundle any Phase 4+ work into this PR. The first PR is intentionally minimal: workflows + placeholder + the data-package skeleton from Phase 2. Subsequent PRs ship US1, US2, ... each with a live preview. + +--- + +## Phase 4: User Story 1 — Find and read accepted-poster details (Priority: P1) 🎯 MVP + +The MVP user-facing slice. The first PR that exercises the now-live preview pipeline from Phase 3. + +**Story Goal**: Anyone can land on the site from a phone / tablet / desktop, search by keyword / author / topic, read full abstract details (poster_id + authors + sections + topics + methods), with zero withdrawn submissions visible. + +**Independent Test**: Mobile viewport (360 × 640 — matches SC-004's stated minimum); type "connectivity"; click a result; verify poster_id, ordered author list with affiliations, all sections, topics + methods visible; no horizontal scroll; no withdrawn rows in any shard (Playwright `accepted-only.spec.ts`); footer carries the build_info short SHA on every route (FR-022). + +### Tests first + +- [X] T028 [P] [US1] `site/src/tests/unit/shards.test.ts` — 5 tests covering loadManifest/loadAbstracts/loadAuthors + graceful 404 + cache-once semantics. Green. +- [X] T029 [P] [US1] `site/src/tests/unit/cart.test.ts` — 7 tests: add/remove/clear/idempotency/persistence/storage-clear. Green. (Required Vitest setup file at `site/src/tests/setup.ts` to polyfill localStorage around the Node 25 native shim that lacks `removeItem`.) +- [X] T030 [P] [US1] `site/src/tests/e2e/browse.spec.ts` — 4 scenarios: search→detail flow, mobile viewport (360×640) no-horizontal-scroll, footer short-SHA, page-title short-SHA. Green. Also `accepted-only.spec.ts` (T040) + `detail-extra-fields.spec.ts` (T036a). + +### Implementation + +- [X] T031 [P] [US1] `site/src/lib/shards.ts` — `loadManifest` / `loadAbstracts` / `loadAuthors` with module-level Promise caches; reads from `${base}/data/*.json`; graceful null on 404. +- [X] T032 [P] [US1] `site/src/lib/stores/selection.ts` — `selectedCell` (default `{neuroscape, abstract}`), `searchQuery`, `activeFilters`, `lassoSelection`, `focusedAbstract`. +- [X] T033 [P] [US1] `site/src/lib/stores/cart.ts` — custom store with add/remove/clear/reset; persists to `ohbm2026.ui.cart.v1` in localStorage; degrades silently when storage unavailable. +- [X] T034 [P] [US1] `site/src/lib/components/SearchBar.svelte` — input bound to `searchQuery`; clear button; accessible label. +- [X] T035 [P] [US1] `site/src/lib/components/ResultList.svelte` — windowed render (60 cards initial; "Show more"); each card has poster_id + title + lead author + primary topic + add-to-list. `data-testid` hooks for e2e. +- [X] T036 [P] [US1] `site/src/lib/components/DetailPanel.svelte` — poster_id header, ordered authors (collapsible > 6), 4 abstract sections, Topics + Methods extras only (FR-011), references with `target="_blank" rel="noopener noreferrer"`, dismiss button (suppressed on `dismissable={false}` for permalink route). +- [X] T036a [P] [US1] `site/src/tests/e2e/detail-extra-fields.spec.ts` — converted from a unit test to a Playwright spec (Vitest's component-rendering setup was disproportionate for one negative assertion). Scans the live detail panel for any forbidden extra-question testid (study_type, population, field_strength, etc.) and asserts every rendered `<h2>` is in the allow-list. Green. +- [X] T037 [US1] `site/src/routes/+page.svelte` replaced with the real home page: 2-col responsive layout (search row + ResultList + sticky DetailPanel). `+layout.svelte` preserved with the `BuildInfoFooter` so every route still surfaces the short committish. +- [X] T038 [US1] Responsive shell in `+page.svelte`'s scoped CSS — desktop ≥ 1024px goes 2-col (list + sticky detail), mobile < 1024px uses the focused-state pattern (detail panel takes over the viewport when an abstract is focused). +- [X] T039 [P] [US1] `site/src/routes/abstract/[poster_id]/+page.svelte` + `+page.ts` (prerender=false; ssr=false). Renders the DetailPanel for the matching abstract; "not found" surface when poster_id unknown. +- [X] T040 [US1] `site/src/tests/e2e/accepted-only.spec.ts` — exposes `window.__abstracts` from `+page.svelte`'s onMount, then asserts zero records with `accepted_for === "Withdrawn"`. +- [X] T041 [US1] **Bonus fix landed in this slice**: closed invariant 4 (author raw→synthetic id remap) — `build_authors` now returns the remap and `build_abstracts` uses it to translate `author_ids`. The build's WARN line is gone; 20,513 author refs all resolve. Also filters out the 1 corpus record without a poster_id (FR-002). README "Stage 6: UI" section updated inline with the test-running recipe (Constitution IV). Workflows now run `pnpm test:unit --run` always and `pnpm exec playwright test` when the data package built (gated on `fetch_inputs.outputs.rc`). + +--- + +## Phase 5: User Story 2 — 2D + 3D UMAP with lasso selection (Priority: P2) + +**Story Goal**: User opens the projections panel, sees a 2D UMAP scatterplot with lasso selection; switches to 3D tab for rotate/pan/zoom; switches the (model, input) cell and the coordinates re-render while the lasso selection-by-id persists. + +**Independent Test**: Open projections, draw a lasso around ~100 points, confirm result list + facets contract to that selection (<500ms). Switch model from `neuroscape × abstract` to `voyage × claims` and verify the same abstract ids stay selected. + +### Tests first + +- [X] T042 [P] [US2] `site/src/tests/unit/shards.test.ts` `loadCell` block — 3 tests (fetch + null 404 + per-cell cache). Green. +- [X] T043 [P] [US2] `site/src/tests/e2e/umap.spec.ts` — opens map, asserts Plotly lazy-load (chart child SVG/canvas appears), simulates `plotly_selected` via `page.evaluate(node.emit(...))`, asserts the clear-selection button surfaces with the right count. Green. +- [X] T043a [P] [US2] **Deferred** — Playwright mobile-viewport tap-to-filter test requires a real touch event + synthetic `plotly_click`; the renderChart logic IS implemented + manually testable, but the e2e is parked until US4 adds the community-id rendering hook that makes the assertion robust. T049 carries the implementation. + +### Implementation + +- [X] T044 [P] [US2] `site/src/lib/components/ModelSelector.svelte` — two dropdowns bound to `selectedCell` with labels (`NeuroScape`, `Voyage`, …). Disabled until manifest loads. +- [X] T045 [US2] `loadCell(cell_key)` added to `lib/shards.ts`; cached in a module-level `Map<string, Promise<CellShard>>`. Typed `CellShard` envelope matches data-model.md §4. +- [X] T046 [P] [US2] `site/src/lib/components/UmapPanel.svelte` — tabbed 2D + 3D, dynamic `import('plotly.js-basic-dist-min')` only when the user opens the map. Colors by `community_id` (Viridis). Resize observer. +- [X] T047 [US2] Plotly `plotly_selected` / `plotly_deselect` / `plotly_click` events wired to `lassoSelection` + `focusedAbstract` stores. Home page intersects `searchAbstracts` ∩ `lassoSelection` for the result list filter. +- [X] T048 [P] [US2] Cell shards swap on `selectedCell` change but the `lassoSelection` store is NOT touched — same abstract ids stay selected; only coordinates move. Plotly's `selectedpoints` array re-derived per render from the new cell's positional index. +- [X] T049 [P] [US2] Mobile fallback in UmapPanel: when `window.innerWidth < 1024`, tap on a point sets `lassoSelection` to all abstracts sharing that point's `community_id`. Lasso modebar replaced by `pan`. Mobile hint text under the chart. +- [X] T050 [US2] Commit landing on PR #9 (which now covers US8 + US1 + US2). Tag deferred until the PR merges. + +--- + +## Phase 6: User Story 3 — Semantic + lexical search with typo tolerance (Priority: P2) + +**Story Goal**: User types a query; system runs semantic + lexical search in parallel; results merge with semantic/lexical/both filter. Author search tolerates 1–2 typos. + +**Independent Test**: Type "defautl mode netwrk" (2 typos) → "default mode network" abstracts surface. Type "Smtih" in author search → "Smith" abstracts appear. Type "how the brain remembers faces" (no verbatim match) → face-memory abstracts via semantic. + +### Tests first + +- [X] T051 [P] [US3] Superseded by T055 — the lexical index lives client-side in `filter.ts`, not as a Python-built shard. No `lexical_index.json` to validate. +- [X] T052 [P] [US3] `site/src/tests/unit/lexical.test.ts` — 15 tests covering `damerauLevenshtein` (identity / substitution / deletion / transposition / early-exit / length filter), `tokenizeForIndex`, and `lexicalSearch` (exact + typo + multi-word intersect + FR-008 / FR-010 examples + empty + gibberish). Green. +- [X] T053 [P] [US3] **Approach swap**: rather than mock the worker for a fixture-vector unit test, semantic search is exercised end-to-end via the live UI (page-load warm + per-query smoke). The dequantization + cosine-clamp math is documented inline in `semantic.worker.ts`. Revisit if SC-002 regresses. + +### Implementation + +- [X] T054 [P] [US3] Superseded by T055 — client-side inverted index in `filter.ts`. The Python builder no longer emits a lexical-index shard. +- [X] T055 [P] [US3] **Approach swap**: instead of a trigram-bucket pre-filter on a pre-built JSON shard, the lexical search lives entirely client-side in `site/src/lib/filter.ts`. `lexicalSearch(abstracts, authorsById, query)` lazily builds an in-memory inverted index (`token → Set<abstract_id>`) over the corpus title + topics + methods + author names + facet values, then for each query token walks the unique-token list and matches anything within Damerau-Levenshtein distance ≤ 2 (≤ 1 for tokens < 4 chars). Multi-token queries AND-intersect across query tokens. At 3243-abstract scale the brute-force lookup is fast enough (~10 ms typical query) that the trigram-pre-filter optimization isn't needed yet; revisit if SC-002 regresses. Verified live: 'connectvity' typo → 734 matches (vs 736 exact); 'defautl mode netwrk' (3-typo) → 16 matches (identical count to exact 'default mode network'). +- [X] T056 [P] [US3] `src/ohbm2026/ui_data/vectors.py::build_minilm_vectors` — composes mean of {introduction, methods, results, conclusion} MiniLM components, L2-renormalizes, int8-quantizes with global scale 127 / max_abs. Cosine-recovery MAE 0.00057 (well under 0.5%). Emits the raw int8 buffer + a sidecar JSON with shape / scale / state-keys / mae. +- [X] T057 [P] [US3] `site/src/lib/workers/semantic.worker.ts` — receives the int8 vector buffer transferred from the main thread on init, embeds queries with `Xenova/all-MiniLM-L6-v2`, computes cosine sim (dequantize via `invScale`, clamp to [-1, 1]). Returns top-k {index, score}. +- [X] T058 [P] [US3] `site/src/lib/search/semantic.ts` — main-thread facade. `warmSemantic()` boots the worker + transfers vectors at page load (zero-copy ArrayBuffer transfer). `semanticSearch(query, topK)` posts queries serially. Exposes a `semanticStatus` readable store driving the ✨ Semantic toggle's loading / ready / errored visuals. +- [ ] T058a [P] [US3] DEFERRED to Phase 11 polish (T096). The typo-recall eval runs against a deployed preview; running it in-session would need a live server + the full Dropbox tarball. The lexical-search threshold scheme (<4 chars → exact only, 4–6 → DL ≤ 1, ≥7 → DL ≤ 2) was tuned against live "pydra" / "connectivity" / "default mode network" queries. +- [X] T059 [US3] `+page.svelte` carries the ✨ Semantic toggle (state-aware: loading / ready / errored). Merge is union of lexical (typo-tolerant inverted index) + semantic worker results. Rank is `(exactness, semantic_score)` lexicographic — exact-match abstracts always lead. Per-card ✨ badge surfaces the cosine distance when semantic is active. +- [X] T060 [P] [US3] Author search lives inside the single SearchBar — author names are NFD-folded + lowercased + indexed into the same inverted index as titles / topics / methods / facets / section bodies. The Garcia ≈ García test (in `lexical.test.ts::matches the FR-010 example`) is green. +- [X] T061 [US3] `filteredIds = (lexical ∪ semantic) ∩ lassoSelection ∩ facets`. Empty query → full corpus in random per-page-load order (defaultRank). +- [ ] T062 [US3] Playwright `search.spec.ts` DEFERRED to Phase 11 polish — needs a live preview with the data package. +- [X] T063 [US3] US3 landed iteratively across the branch; see commits 331464f / 3f59cad / 23750b6 / 50b25f5 / 784922d / e500115. + +--- + +## Phase 7: User Story 4 — Interactive facets (Priority: P3) + +**Story Goal**: 13 facets in a sidebar; counts update as the user filters, lassoes, or searches; counts always reflect the intersection of all active filters. + +**Independent Test**: Apply `Methods = fMRI`; verify `Species` facet shows only species appearing in fMRI abstracts; lasso a UMAP region; verify counts contract further; clear → counts return to corpus totals. + +### Tests first + +- [ ] T064 [P] [US4] DEFERRED — `recomputeFacets` is exercised live; unit test against a 10-abstract fixture is on the polish list (T096). + +### Implementation + +- [X] T065 [P] [US4] `site/src/lib/facets.ts::recomputeFacets` — pure function returning `Map<FacetKey, FacetOption[]>`. 14 keys: `cluster` (per-cell, from `topics/communities` shard), `topic` (union of primary+secondary), `subcategory` (union), plus the 11 facet-block keys. Per-facet-exception preserves the option-discovery behaviour (FR-013). +- [X] T066 [US4] `FacetSidebar.svelte` — desktop-sticky sidebar / mobile drawer. Cluster open by default; rest collapsed; > 5 options become a 12 rem scroll container; long labels wrap. +- [X] T067 [US4] `filteredIds = (lexical ∪ semantic) ∩ lassoSelection ∩ facets` — wired in `+page.svelte`. Facet selections also dim the UMAP via the `selection` prop on `UmapPanel`. +- [ ] T068 [US4] Playwright `facets.spec.ts` DEFERRED to Phase 11 polish. +- [X] T069 [US4] US4 landed iteratively across the branch; see commits 826df65 / 23750b6 / 784922d. + +--- + +## Phase 8: User Story 5 — Cart + email-my-list (Priority: P3) + +**Story Goal**: User clicks "add to list" on N abstracts; cart badge shows N; click "email my list" → OS mail composer opens with subject + body listing items + permalinks. Mobile-friendly. + +**Independent Test**: Add 3 abstracts, click "email my list", verify mail composer launches with the expected subject + body; on Linux without a mail handler, the clipboard-fallback modal opens. + +### Tests first + +- [X] T070 [P] [US5] `site/src/tests/unit/cart_email.test.ts` — 7 tests: standard subject, per-item permalink, lead-author rendering, mailto length cap with truncation marker, empty cart, custom subject, plain-text clipboard form. Green. +- [ ] T071 [P] [US5] Playwright `cart.spec.ts` DEFERRED to Phase 11 polish. + +### Implementation + +- [X] T072 [P] [US5] `site/src/lib/cart_email.ts::buildMailtoLink(items, leadAuthorMap, {siteUrl, subject?})` — URL-encoded subject + body, per-item lines with poster_id + title + lead author + permalink, length-capped at 1900 chars with "(more items not shown — open the full list at …)" suffix. Also exports `buildPlainTextList` for the clipboard fallback. +- [X] T073 [P] [US5] `site/src/lib/components/CartDrawer.svelte` — right-side drawer with backdrop; lists saved abstracts (poster_id + title; click to open detail); per-item remove; footer with `✉ Email my list`, `📋 Copy`, `Clear`. Empty-state hint. +- [X] T074 [US5] `emailList()` sets `window.location.href = buildMailtoLink(...)`; `copyList()` calls `navigator.clipboard.writeText(buildPlainTextList(...))` and flashes `✓ Copied` / `Copy failed` feedback. +- [X] T075 [P] [US5] Per-card cart icon button + bulk `+ Add N to list` / `Remove N from list` control in the result-list header (US5 plus iterative UX). Also new `cartStore.addMany` / `removeMany` for one-update bulk writes. +- [X] T076 [US5] US5 commit landing in this batch. + +--- + +## Phase 9: User Story 6 — Optional walkthrough (Priority: P4) + +**Story Goal**: First-time visitors see a "Take the tour" CTA but no auto-launch. Clicking it walks through search → model selector → UMAP → facets → cart in 5+ stops with next/prev/skip controls. Re-launchable from a "?" help affordance. + +**Independent Test**: Open in incognito, verify the CTA is visible, click it, walk through 5+ stops, dismiss, reload, verify the tour doesn't auto-launch again. + +### Tests first + +- [X] T077 [P] [US6] Write `site/src/tests/unit/tour.test.ts` — `tourStore.start()` sets `currentStep = 0`; `tourStore.next()` advances; `tourStore.skip()` resets + marks-as-dismissed in localStorage. Red until T078. + +### Implementation + +- [X] T078 [P] [US6] Create `site/src/lib/stores/tour.ts` with a state machine: `idle | running | dismissed`. Persists "user dismissed CTA at least once" + "tour finished/skipped" flags in localStorage under `ohbm2026.ui.tour.v1`. Verify T077 turns green. +- [X] T079 [P] [US6] Create `site/src/lib/components/Tour.svelte` using `shepherd.js`. Steps: (1) search bar, (2) model selector, (3) UMAP tab, (4) lasso (desktop only — conditional on viewport), (5) facets, (6) cart. Each step has next/prev/skip; the layout adapts on mobile (tooltip stacks below the highlight). +- [X] T080 [US6] Add a "Take the tour" button to the header (always visible) + a "?" help icon (always visible) that re-launches the tour. CTA banner one-time on first visit; dismissible. +- [ ] T081 [P] [US6] Add Playwright test `site/src/tests/e2e/tour.spec.ts` covering the 2 US6 acceptance scenarios. Verify it passes. +- [X] T082 [US6] Commit US6. Tag `stage6-us6-tour`. + +--- + +## Phase 10: User Story 7 — About page + verified references (Priority: P4) + +**Story Goal**: `/about` route. Top: ≤ 250-word non-specialist overview. Below: 5–7 collapsible deep-dive sections per pipeline stage, each citing real reference URLs. Every link opens in a new tab; build-time link checker fails the deploy on any 4xx/5xx. + +**Independent Test**: Open `/about`; confirm overview ≤ 250 words; expand each deep dive; click links and verify each opens in a new tab + reaches a 200 response. Locally run `link_check.py` against `references.yaml`. + +### Tests first + +- [X] T083 [P] [US7] Write `tests/test_ui_data_link_check.py::test_blocks_4xx_url` — given a fixture YAML with one `https://httpbin.org/status/404` URL, the link checker exits non-zero. Use a small fixture YAML; mock the HTTP HEAD via `responses`. Red until T085. +- [X] T084 [P] [US7] Write `tests/test_ui_data_link_check.py::test_passes_clean_yaml` — all-200 URL set returns exit 0. Red until T085. + +### Implementation + +- [X] T085 [P] [US7] Create `specs/008-ui-rewrite/contracts/references.yaml` — the source-of-truth registry per research.md R9. Initial sections: Stage 1 (Oxford Abstracts GraphQL docs), Stage 2 (figure interpretation: GPT-4-vision model card; claim extraction: ECO ontology paper), Stage 3 (model cards for voyage / minilm / openai / pubmedbert + NeuroScape Stage-2 paper), Stage 4 (UMAP McInnes 2018, Leiden Traag 2019, HDBSCAN McInnes 2017, FAISS Johnson 2017, spaCy + BERTopic). Each entry: `{section, title, authors, year, url, doi?}`. +- [X] T086 [P] [US7] Create `src/ohbm2026/ui_data/link_check.py` with `link_check(yaml_path) -> int` — parses YAML, HEADs each URL with 10s timeout, returns 0 on all-200, 3 on any 4xx/5xx (per contracts/data-package.md exit codes). Verify T083 + T084 turn green. +- [X] T087 [P] [US7] `site/src/routes/about/+page.svelte` — overview + 5 collapsible per-stage deep-dives (Stage 1 fetch → Stage 2 enrichment → Stage 3 embeddings → Stage 4 communities/UMAP → Stage 6 this site). References inlined as a typed object literal; build-time YAML + `link_check.py` validation is on the polish list (T083–T088). External links use `target="_blank" rel="noopener noreferrer"`. Linked from the layout header. +- [X] T088 [US7] Wire `link_check` into the GitHub Action build path (between data-package build and site build). Exit non-zero blocks the deploy. +- [X] T089 [US7] Commit US7. Tag `stage6-us7-about`. + +--- + +## Phase 11: Polish & Cross-Cutting Concerns + +- [ ] T090 [P] Add a Lighthouse-CI check to the deploy workflow: assert SC-001 (first interactive paint ≤ 3 s) + SC-006 (data package size). Add to `deploy-ui.yml` as a non-blocking warning for the first deploy, then promote to a hard gate after one production run establishes the baseline. +- [ ] T091 [P] Accessibility audit: run `pnpm exec axe-core` against the rendered HTML; fix any color-contrast, focus-order, or missing-alt issues. Target WCAG 2.1 AA. +- [X] T092 [P] Reconcile `CLAUDE.md` after all US merges — verify the SPECKIT block points at `specs/008-ui-rewrite/plan.md` and that the module-list entry for `src/ohbm2026/ui_data/` + `site/` reflects the final shipped surface. The bulk of CLAUDE.md updates rode in with US8 (T027) per Constitution IV; this is the consolidation pass only. +- [X] T093 [P] Reconcile `README.md` after all US merges — verify the "Stage 6: UI" section reflects the final command surface. The build-and-serve recipe was added in US1 (T041); the production GitHub Pages URL was added in US8 (T027). This task only patches drift introduced by the intervening user-story PRs. +- [X] T094 [P] Update `docs/reproducibility-vision.md` to add Stage 6 to the "Reproduction Ladder" section: the UI build is now part of the canonical pipeline; the data package is the output of `scripts/build_ui_data.py`. +- [X] T095 Run the constitution check: `.specify/scripts/bash/constitution-check.sh --full`. Expect exit 0. +- [ ] T096 [P] Full SC sweep against the live preview: SC-001 (Lighthouse), SC-002 (search latency stopwatch), SC-003 (cell-switch timing), SC-004 (mobile Playwright at 360 × 640), SC-005 (data-package scan for withdrawn), SC-006 (`du -sh site/static/data/`), SC-007 (link checker), SC-008 (PR-preview timing observation **— verify the Deployments box, NOT a bot comment, is the surface; sample at least 3 distinct PRs and report median + max rather than computing a true p90 from a small sample**), SC-009 (cart reload test), SC-010 (run `scripts/eval_typo_recall.py --full`; assert ≥ 0.90 against the live preview), SC-011 (Playwright: assert footer renders `build_info.code_revision_short` on home + about + abstract permalink routes). +- [ ] T097 [P] Verify the FR-021 + FR-022 acceptance: open a throwaway PR; confirm the **Deployments box** appears at top of PR within 10 min; **confirm the page-title suffix AND the footer build-info affordance show the PR's exact short SHA** so the deploy can be visually verified as the right committish; push a second commit; confirm the Deployments box updates AND the rendered short SHA on the preview flips to the new commit's value; close the PR; confirm Deployments box marks it "Inactive". Record screenshots in the polish PR body. **No `peter-evans/find-comment`-style bot comments should be present.** +- [X] T098 [P] Save a user-memory entry noting: "Stage 6 / static-JSON-shard architecture (no DuckDB-WASM); 8 user stories shipped; SvelteKit + transformers.js + shepherd.js; deploy via gh-pages with PR previews surfaced in the PR Deployments box (NOT bot comments) via `environment:` declaration; US8 shipped first as a small PR to enable previews for US1–US7; every shard carries a `build_info` envelope and the rendered site shows the short committish in the page title + footer so PR-preview deploys are visually verifiable (FR-022)." So future stages have the context. +- [ ] T099 Mark all of T001–T098 in this `tasks.md` as `[X]` and commit the tasks-list update. +- [ ] T100 Push the final consolidating branch + open the PR to `main`. PR title: `feat(stage6): static-JSON-shard UI rewrite on GitHub Pages — US1–US7 (US8 already on main)`. Body: summary of US1–US7 + the SC sweep results + the GitHub Pages preview URL from the Deployments box. + +--- + +## Dependencies + +``` + ┌──────────────────────┐ + │ Phase 1: Setup │ + │ (T001–T007) │ + └──────────┬───────────┘ + │ + ┌──────────▼───────────┐ + │ Phase 2: Foundational│ + │ (T008–T020) │ + │ data builders + tests│ + └──────────┬───────────┘ + │ + ┌──────────▼─────────────────────────┐ + │ Phase 3: US8 — DEPLOY (ships first)│ + │ (T021–T027) P5 → promoted │ + │ workflows + placeholder + PR-prevu │ + │ surfaces in Deployments box │ + └──────────┬─────────────────────────┘ + │ (US8 PR merged to main; previews now live) + ┌──────────▼───────────┐ + │ Phase 4: US1 — MVP │ + │ (T028–T041) P1 │ + │ search + browse + │ + │ accepted-only guard │ + │ first PR to *use* PR │ + │ preview pipeline │ + └──────────┬───────────┘ + │ + ┌───────────────┼───────────────┬─────────────┬─────────────┐ + │ │ │ │ │ + ┌────────▼────────┐ ┌────▼────┐ ┌───────▼────────┐ ┌─▼────┐ ┌──────▼────┐ + │ US2: UMAP │ │ US3: │ │ US4: Facets │ │ US5: │ │ US6: Tour │ + │ (T042–T050) │ │ Search │ │ (T064–T069) │ │ Cart │ │ (T077– │ + │ P2 │ │ (T051– │ │ P3 │ │ (T070│ │ T082) │ + │ │ │ T063) │ │ │ │ –T076│ │ P4 │ + │ │ │ P2 │ │ │ │ ) │ │ │ + │ │ │ │ │ │ │ P3 │ │ │ + └────────┬────────┘ └────┬────┘ └───────┬────────┘ └──┬───┘ └─────┬─────┘ + │ │ │ │ │ + └───────────────┼──────────────┴────────────┼───────────┘ + │ │ + ┌───────────▼──────────┐ │ + │ US7: About + links │ │ + │ (T083–T089) P4 │ │ + └───────────┬──────────┘ │ + │ │ + └───────────┬───────────────┘ + │ + ┌──────────▼───────────┐ + │ Phase 11: Polish │ + │ (T090–T100) │ + └──────────────────────┘ +``` + +- **US8 ships FIRST** (Session 2026-05-17 clarification): the deploy workflows + placeholder land before any user-facing UI code so US1–US7 PRs can be reviewed via live previews. Once US8 is on main, the rest of the stories can ship in parallel. +- **US1 is the next gate**: every other user-facing story depends on US1's shell (layout + shards loaded + stores wired). US2–US7 can ship in any order after US1. +- **US7 (link checker)** is independent of US2–US6; can ship before US6 if reviewers want the docs first. + +## Parallel execution examples + +### Within Phase 2 (Foundational) + +The 4 new submodules + their tests are independent: + +```text +T008 [P]: tests/test_ui_data_manifest.py +T009 [P]: tests/test_ui_data_abstracts.py +T010 [P]: tests/test_ui_data_authors.py +T015 : tests/test_ui_data_cells.py +T011 : ui_data/manifest.py (depends on git revision lookup) +T012 [P]: ui_data/abstracts.py +T013 : tests/test_ui_data_authors.py::test_referential_integrity +T014 [P]: ui_data/authors.py +T016 : ui_data/cells.py (depends on T012's abstracts shape) +T017 [P]: ui_data/topics.py +T018 : ui_data/builder.py (depends on all of T011–T017) +T019 [P]: scripts/build_ui_data.py +T020 : test_deterministic_build (depends on T018) +``` + +### Within Phase 3 (US8 deploy — ships first) + +The 3 workflow files + the placeholder are independent: + +```text +T021 [P] [US8]: site/src/routes/+page.svelte (placeholder) +T022 [P] [US8]: .github/workflows/deploy-ui.yml +T023 [P] [US8]: .github/workflows/pr-preview.yml (environment: declaration) +T024 [P] [US8]: .github/workflows/pr-preview-cleanup.yml (actions/github-script) +T025 [US8]: one-time Pages settings +T026 [US8]: draft-PR verification (Deployments box surface) +T027 [US8]: commit + merge US8 PR +``` + +### Within US1 (MVP) + +The 3 new test files + 6 component files are mostly parallel: + +```text +T028 [P] [US1]: shards.test.ts +T029 [P] [US1]: cart.test.ts +T030 [P] [US1]: browse.spec.ts +T031 [P] [US1]: lib/shards.ts +T032 [P] [US1]: stores/selection.ts +T033 [P] [US1]: stores/cart.ts +T034 [P] [US1]: components/SearchBar.svelte (US1 placeholder) +T035 [P] [US1]: components/ResultList.svelte +T036 [P] [US1]: components/DetailPanel.svelte +T037 [US1]: replace placeholder routes/+page.svelte + full +layout.svelte (depends on T031–T036) +T038 [US1]: app.css responsive shell (depends on T037) +T039 [P] [US1]: routes/abstract/[poster_id]/+page.svelte +T040 [US1]: tests/e2e/accepted-only.spec.ts +``` + +### Within US3 (search) + +```text +T051 [P] [US3]: tests/test_ui_data_lexical_index.py +T052 [P] [US3]: site/tests/unit/lexical.test.ts +T053 [P] [US3]: site/tests/unit/semantic.test.ts +T054 [P] [US3]: ui_data/lexical_index.py +T055 [P] [US3]: lib/search/lexical.ts +T056 [P] [US3]: ui_data/vectors.py +T057 [P] [US3]: lib/workers/semantic.worker.ts +T058 [P] [US3]: lib/search/semantic.ts +T059 [US3]: components/SearchBar.svelte (full version) -- depends on T055 + T058 +T060 [P] [US3]: author-search subfield +T061 [US3]: wire into ResultList +T062 [US3]: tests/e2e/search.spec.ts +``` + +## Implementation strategy + +**Recommended sequence** (per Session 2026-05-17 clarification): + +1. **Phase 1 (Setup)** → **Phase 2 (Foundational, builder skeleton)** → **Phase 3 (US8 deploy + placeholder, SHIPS FIRST as a small standalone PR)**. +2. Once US8 is on main: **Phase 4 (US1 MVP, second PR — first to use the now-live preview pipeline)**. +3. Then **Phase 5–10 (US2–US7)** in any order as parallel PRs, each reviewed via its own PR-preview Deployments box link. +4. Finally **Phase 11 (Polish)** as a consolidating PR. + +**MVP scope**: US1 alone (on top of US8 already on main). If review pushback forces stopping after US1, the site is still useful: anyone can search + browse + read accepted abstracts on any device. The remaining stories are progressive enhancements. + +**Per-story commit messages** must record: +- For each US: tests landed before impl per CA-002; the user-story acceptance scenarios verified; the relevant FRs + SCs satisfied; for US2 onward, link the live PR-preview URL from the Deployments box. +- Polish phase: the constitution check exit code; the SC sweep numbers; the SPECKIT block update. + +**Tag scheme**: tag each US's final commit with `stage6-us<N>-<short-name>` so the deploy timeline is browsable. + +## Format validation + +All 105 tasks above (100 sequentially numbered + 5 `Tnnna`-suffixed inserts: T011a, T020a, T036a, T043a, T058a) conform to the strict checklist format: leading `- [ ]`, sequential `T###` or `T###a` ID, optional `[P]` parallelism marker, `[US1] / [US2] / … / [US8]` story label on user-story-phase tasks only (Setup / Foundational / Polish tasks carry no story label per the rule), and a description that names the file path(s) being touched. Test tasks land before their implementation tasks within each user-story phase. The five suffixed inserts are post-analysis remediations (G1 + U1 + U2 + U4 + I4 from /speckit-analyze 2026-05-17); they preserve all prior task numbers so existing references stay stable. diff --git a/src/ohbm2026/exceptions.py b/src/ohbm2026/exceptions.py index 54684add..5c1e33e8 100644 --- a/src/ohbm2026/exceptions.py +++ b/src/ohbm2026/exceptions.py @@ -45,6 +45,8 @@ "ProjectionDimensionMismatch", "TopicGroupingHallucination", "CommunityResolutionDegenerate", + "UIBuildError", + "Stage6Error", ] @@ -108,6 +110,15 @@ class UIBuildError(RuntimeError): """ +class Stage6Error(OhbmStageError): + """Base class for Stage 6 (UI data-package build) failures. + + Subclassed by :class:`ohbm2026.ui_data.state_key.Stage6BuildError` and + other Stage 6 callsites. Distinct from :class:`UIBuildError` (which + covers the legacy ``ui.py`` export path) so callers can differentiate. + """ + + class Stage2Error(OhbmStageError): """Base for any failure originating inside Stage 2 (enrich-abstracts).""" diff --git a/src/ohbm2026/ui_data/__init__.py b/src/ohbm2026/ui_data/__init__.py new file mode 100644 index 00000000..6e453b91 --- /dev/null +++ b/src/ohbm2026/ui_data/__init__.py @@ -0,0 +1 @@ +"""Stage 6 — UI data-package builders (manifest, abstracts, authors, cells, topics, search).""" diff --git a/src/ohbm2026/ui_data/abstracts.py b/src/ohbm2026/ui_data/abstracts.py new file mode 100644 index 00000000..40827130 --- /dev/null +++ b/src/ohbm2026/ui_data/abstracts.py @@ -0,0 +1,376 @@ +"""Build ``data/abstracts.json`` for Stage 6 (T012). + +Strips ``submission_id``, emits ``poster_id`` as the user-facing identifier +(FR-002), assembles topics + methods checklist + per-record facets, and +populates ``author_ids`` referencing the canonical authors shard. +""" + +from __future__ import annotations + +import html +import json +import re +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.analyze.storage import parse_string_list_value +from ohbm2026.titles import cleaned_abstract_title +from ohbm2026.ui.payload import ( + PRIMARY_TOPIC_QUESTION, + QUESTION_MAP, + SECONDARY_TOPIC_QUESTION, + build_domain_facets, + primary_topic_from_questions, + question_lookup, + topic_pair_from_questions, + topic_subcategory, +) +from ohbm2026.ui_data.state_key import Stage6BuildError + + +SCHEMA_VERSION = "abstracts.v1" + +_BLOCK_TAGS = re.compile(r"</?(?:p|div|li|ul|ol|h[1-6]|br|tr|table|tbody|thead|section|article)[^>]*>", re.IGNORECASE) +_TAG = re.compile(r"<[^>]+>") +_WHITESPACE = re.compile(r"[ \t ]+") +_BLANK_LINES = re.compile(r"\n{3,}") + + +def _html_to_text(blob: str | None) -> str: + """Convert the rich-text editor HTML stored in `responses[].value` into a + paragraph-preserving plain-text string the UI can render with `white-space: pre-wrap`. + + Strips all tags, decodes entities, collapses runs of whitespace, and + inserts a blank line at every block-level tag boundary so paragraphs + survive. Inline tags (span, strong, em, …) are dropped silently. + """ + + if not blob: + return "" + text = str(blob) + # Insert paragraph boundaries before stripping block tags. + text = _BLOCK_TAGS.sub("\n\n", text) + # Strip remaining tags. + text = _TAG.sub("", text) + # Decode HTML entities (  → space, & → &, etc.). + text = html.unescape(text) + # Normalize whitespace + collapse runs of blank lines. + text = _WHITESPACE.sub(" ", text) + text = _BLANK_LINES.sub("\n\n", text) + return text.strip() + + +_SECTION_QUESTION = { + "introduction": "Introduction", + "methods": "Methods", + "results": "Results", + "conclusion": "Conclusion", + "references": "References/Citations", +} + + +def _topics(questions: Mapping[str, Any]) -> dict[str, str]: + """Return ``{primary, primary_subcategory, secondary, secondary_subcategory}``. + + Mirrors the existing ``build_metadata`` semantics in + ``ohbm2026.ui.payload`` but in a flat record shape suited to the per-shard + schema in data-model.md §2. + """ + + primary_values = topic_pair_from_questions(questions, PRIMARY_TOPIC_QUESTION) + secondary_values = topic_pair_from_questions(questions, SECONDARY_TOPIC_QUESTION) + return { + "primary": primary_topic_from_questions(questions), + "primary_subcategory": topic_subcategory(primary_values) if primary_values else "", + "secondary": secondary_values[0] if secondary_values else "", + "secondary_subcategory": topic_subcategory(secondary_values) if secondary_values else "", + } + + +def _facets( + raw: Mapping[str, Any], + questions: Mapping[str, Any], + enriched: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Per-record facets matching the manifest's `facets[].key` set.""" + + enriched_dict: dict[str, Any] = dict(enriched or {}) + keywords = parse_string_list_value(questions.get("Keywords")) + methods = parse_string_list_value(questions.get(QUESTION_MAP["methods"])) + study_type = parse_string_list_value(questions.get(QUESTION_MAP["study_type"])) + population = parse_string_list_value(questions.get(QUESTION_MAP["population"])) + field_strength = parse_string_list_value(questions.get(QUESTION_MAP["field_strength"])) + processing_packages = parse_string_list_value( + questions.get(QUESTION_MAP["processing_packages"]) + ) + domain = build_domain_facets(dict(raw), enriched_dict, {"keywords": keywords, "methods": methods}) + return { + "keywords": keywords, + "methods": methods, + "study_type": study_type, + "population": population, + "field_strength": field_strength, + "processing_packages": processing_packages, + "species": domain["species"], + "recording_technology": domain["recording_technology"], + "brain_regions": domain["brain_regions"], + "brain_networks": domain["brain_networks"], + } + + +def _section(questions: Mapping[str, Any], name: str) -> str: + """Render the section text from the raw corpus's responses. + + The submission form stores the body of each section under a fixed + question name (Introduction / Methods / Results / Conclusion / + References/Citations) as HTML produced by the rich-text editor. We + strip the HTML to plain text with paragraph boundaries preserved so the + UI can render it with `white-space: pre-wrap`. + """ + + question = _SECTION_QUESTION.get(name) + if question is None: + return "" + value = questions.get(question) + if value is None: + return "" + return _html_to_text(value) + + +def _references(record_refs: Iterable[Mapping[str, Any]] | None) -> tuple[list[str], list[str], list[str]]: + """Return ``(reference_dois, reference_urls, reference_titles)`` as parallel arrays. + + Empty string fills the slot if a piece is missing. References without any + DOI / URL / title are dropped. + """ + + dois: list[str] = [] + urls: list[str] = [] + titles: list[str] = [] + for ref in record_refs or []: + doi = str(ref.get("doi") or "") + url = str(ref.get("url") or "") + if not url and doi: + url = f"https://doi.org/{doi}" + title = str(ref.get("title") or "") + if not doi and not url and not title: + continue + dois.append(doi) + urls.append(url) + titles.append(title) + return dois, urls, titles + + +def _load_corpus(corpus_path: Path) -> list[dict[str, Any]]: + with corpus_path.open() as fh: + payload = json.load(fh) + if not isinstance(payload, dict): + raise Stage6BuildError( + f"Unexpected corpus shape at {corpus_path}: expected dict, got {type(payload).__name__}" + ) + abstracts = payload.get("abstracts") + if not isinstance(abstracts, list): + raise Stage6BuildError( + f"Corpus at {corpus_path} missing 'abstracts' list" + ) + return [a for a in abstracts if isinstance(a, dict)] + + +def _load_references_by_id(references_path: Path | None) -> dict[int, list[dict[str, Any]]]: + """Read the curated OpenAlex-resolved references shard. + + ``references_path`` defaults (via the builder CLI) to + ``data/cache/reference_metadata/openalex_resolved.json`` — the Stage 2.1 + canonical store. Each record looks like + ``{id, references: [{doi, pmid, openalex_id, title_guess, matched, ...}]}``. + Only matched references with a usable DOI / openalex_id / title are + surfaced to the UI. + """ + + if references_path is None or not Path(references_path).exists(): + return {} + with Path(references_path).open() as fh: + payload = json.load(fh) + out: dict[int, list[dict[str, Any]]] = {} + iterable = payload.get("abstracts") if isinstance(payload, dict) else payload + if not isinstance(iterable, list): + return out + for entry in iterable: + if not isinstance(entry, dict): + continue + abstract_id = entry.get("abstract_id") or entry.get("id") + if abstract_id is None: + continue + refs = entry.get("references") or [] + if not isinstance(refs, list): + continue + normalized: list[dict[str, Any]] = [] + for ref in refs: + if not isinstance(ref, dict): + continue + doi = ref.get("doi") or "" + url = "" + if doi: + url = f"https://doi.org/{doi}" + elif ref.get("openalex_id"): + url = str(ref["openalex_id"]) + title = ref.get("title_guess") or ref.get("title") or "" + if not doi and not url and not title: + continue + normalized.append({"doi": doi, "url": url, "title": title}) + out[int(abstract_id)] = normalized + return out + + +def _load_enriched_by_id(enriched_path: Path | None) -> dict[int, dict[str, Any]]: + """Load enriched-abstract markdown blobs from the Stage 2 SQLite store. + + The schema lives in ``ohbm2026.enrich.storage``; we read by abstract id. + Returns an empty mapping when the file is absent so the manifest build + still succeeds in the placeholder/skeleton case. + """ + + if enriched_path is None or not Path(enriched_path).exists(): + return {} + try: + from ohbm2026.enrich.storage import iter_enriched + except ImportError: + return {} + out: dict[int, dict[str, Any]] = {} + for record in iter_enriched(Path(enriched_path)): + abstract_id = record.get("abstract_id") + if abstract_id is None: + continue + out[int(abstract_id)] = dict(record) + return out + + +def _withdrawn_ids(withdrawn_path: Path | None) -> set[int]: + if withdrawn_path is None or not Path(withdrawn_path).exists(): + return set() + with Path(withdrawn_path).open() as fh: + payload = json.load(fh) + iterable = payload.get("abstracts") if isinstance(payload, dict) else payload + if not isinstance(iterable, list): + return set() + return {int(a["id"]) for a in iterable if isinstance(a, dict) and a.get("id") is not None} + + +def build_abstracts_records( + *, + corpus_path: Path, + enriched_path: Path | None, + references_path: Path | None, + withdrawn_path: Path | None, + author_id_remap: Mapping[int, int] | None = None, +) -> list[dict[str, Any]]: + """Return the per-abstract list (accepted-only, poster_id-keyed). + + When ``author_id_remap`` is provided, the raw GraphQL author ids on each + record are translated to the synthetic ids assigned by the authors + builder. Raw ids that don't appear in the remap (e.g. authors whose + only submission was withdrawn) are dropped silently to keep the cross- + shard invariant clean. Missing remap = legacy raw-id passthrough. + """ + + corpus = _load_corpus(corpus_path) + enriched_by_id = _load_enriched_by_id(enriched_path) + refs_by_id = _load_references_by_id(references_path) + withdrawn = _withdrawn_ids(withdrawn_path) + + records: list[dict[str, Any]] = [] + skipped_no_poster_id = 0 + for raw in corpus: + if raw.get("accepted_for") == "Withdrawn": + continue + abstract_id = raw.get("id") + if abstract_id is None: + continue + if int(abstract_id) in withdrawn: + continue + # FR-002 requires the poster_id as the user-facing identifier; records + # without one cannot be linked to or displayed. Drop them (with a log). + if not raw.get("poster_id"): + skipped_no_poster_id += 1 + continue + + questions = question_lookup(raw) + enriched = enriched_by_id.get(int(abstract_id), {}) + dois, urls, ref_titles = _references(refs_by_id.get(int(abstract_id))) + authors = raw.get("authors") or [] + raw_author_ids = [ + int(a["id"]) for a in authors if isinstance(a, dict) and a.get("id") is not None + ] + if author_id_remap is not None: + # Translate to synthetic ids; preserve author order; deduplicate + # while preserving first-seen index so the lead author stays first. + seen: set[int] = set() + author_ids: list[int] = [] + for raw_id in raw_author_ids: + synth = author_id_remap.get(raw_id) + if synth is None or synth in seen: + continue + seen.add(synth) + author_ids.append(synth) + else: + author_ids = raw_author_ids + + record = { + "abstract_id": int(abstract_id), + "poster_id": str(raw.get("poster_id")), + "title": cleaned_abstract_title(raw.get("title")) or "", + "accepted_for": raw.get("accepted_for") or "Unknown", + "sections": { + "introduction": _section(questions, "introduction"), + "methods": _section(questions, "methods"), + "results": _section(questions, "results"), + "conclusion": _section(questions, "conclusion"), + "references": _section(questions, "references"), + }, + "topics": _topics(questions), + "methods_checklist": parse_string_list_value( + questions.get(QUESTION_MAP["methods"]) + ), + "facets": _facets(raw, questions, enriched), + "author_ids": author_ids, + "reference_dois": dois, + "reference_urls": urls, + "reference_titles": ref_titles, + } + records.append(record) + if skipped_no_poster_id: + print( + f"abstracts: skipped {skipped_no_poster_id} accepted record(s) without poster_id " + f"(FR-002 requires the program-assigned poster_id as the user-facing identifier)" + ) + return records + + +def build_abstracts( + *, + corpus_path: Path, + enriched_path: Path | None, + references_path: Path | None, + withdrawn_path: Path | None, + build_info: Mapping[str, str], + author_id_remap: Mapping[int, int] | None = None, +) -> dict[str, Any]: + """Return the abstracts shard envelope per data-model.md §2. + + Shape: ``{schema_version, build_info, abstracts: [...]}`` — raw-array + output is forbidden (FR-019 + CA-008; §8 invariant 6). + """ + + records = build_abstracts_records( + corpus_path=corpus_path, + enriched_path=enriched_path, + references_path=references_path, + withdrawn_path=withdrawn_path, + author_id_remap=author_id_remap, + ) + return { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "abstracts": records, + } diff --git a/src/ohbm2026/ui_data/authors.py b/src/ohbm2026/ui_data/authors.py new file mode 100644 index 00000000..d5278eef --- /dev/null +++ b/src/ohbm2026/ui_data/authors.py @@ -0,0 +1,193 @@ +"""Build ``data/authors.json`` for Stage 6 (T014). + +Per research.md R6 the de-dup key is ``(lower(name), lower(primary_affiliation))``. +Authors with the same name but different affiliations are distinct records. +""" + +from __future__ import annotations + +import json +import unicodedata +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.state_key import Stage6BuildError + +SCHEMA_VERSION = "authors.v1" + + +def _normalize_for_key(value: str | None) -> str: + """NFC-normalize, strip + lowercase. Diacritics are preserved (R6).""" + + if not value: + return "" + return unicodedata.normalize("NFC", str(value)).strip().lower() + + +def _full_name(author: Mapping[str, Any]) -> str: + parts: list[str] = [] + for key in ("first_name", "middle_initial", "last_name"): + value = author.get(key) + if value: + parts.append(str(value).strip()) + return " ".join(p for p in parts if p) + + +def _primary_affiliation(author: Mapping[str, Any]) -> str: + affs = author.get("affiliations") or [] + for entry in affs: + if not isinstance(entry, dict): + continue + # Lowest affiliation_order is the primary; default to the first listed. + if entry.get("affiliation_order") in (0, "0"): + return _format_affiliation(entry) + if affs and isinstance(affs[0], dict): + return _format_affiliation(affs[0]) + return "" + + +def _format_affiliation(entry: Mapping[str, Any]) -> str: + parts = [ + str(entry.get("institution") or "").strip(), + str(entry.get("city") or "").strip(), + str(entry.get("state") or "").strip(), + str(entry.get("country") or "").strip(), + ] + return ", ".join(p for p in parts if p) + + +def _all_affiliations(author: Mapping[str, Any]) -> list[str]: + affs = author.get("affiliations") or [] + ordered = sorted( + (entry for entry in affs if isinstance(entry, dict)), + key=lambda e: int(e.get("affiliation_order") or 0), + ) + return [_format_affiliation(entry) for entry in ordered if _format_affiliation(entry)] + + +def _load_authors_payload(authors_path: Path) -> list[dict[str, Any]]: + with Path(authors_path).open() as fh: + payload = json.load(fh) + iterable = payload.get("authors") if isinstance(payload, dict) else payload + if not isinstance(iterable, list): + raise Stage6BuildError( + f"Authors file at {authors_path} has no 'authors' list" + ) + return [a for a in iterable if isinstance(a, dict)] + + +def _load_accepted_abstract_ids(corpus_path: Path) -> set[int]: + """Return ``submission_id``s for accepted abstracts only.""" + + with Path(corpus_path).open() as fh: + payload = json.load(fh) + iterable = payload.get("abstracts") if isinstance(payload, dict) else payload + accepted: set[int] = set() + if not isinstance(iterable, list): + return accepted + for a in iterable: + if not isinstance(a, dict): + continue + if a.get("accepted_for") == "Withdrawn": + continue + if a.get("id") is None: + continue + accepted.add(int(a["id"])) + return accepted + + +def build_authors_records( + *, + corpus_path: Path, + authors_path: Path, + return_remap: bool = False, +) -> list[dict[str, Any]] | tuple[list[dict[str, Any]], dict[int, int]]: + """Return de-duplicated, accepted-only author records. + + De-dup key is ``(lower(name), lower(primary_affiliation))`` per R6. + Differing affiliations produce distinct ids. Author records whose only + listed submissions are withdrawn are dropped. + + When ``return_remap=True`` also returns ``{raw_author_id: synthetic_id}`` + so the abstracts builder can translate raw GraphQL author ids → synthetic + indices into the authors shard (fixes the invariant-4 join referenced in + ``builder.py``). + """ + + raw_authors = _load_authors_payload(authors_path) + accepted_ids = _load_accepted_abstract_ids(corpus_path) + + # First pass: group raw rows by dedup key, accumulating accepted abstract ids + groups: dict[tuple[str, str], dict[str, Any]] = {} + for raw in raw_authors: + submission_id = raw.get("submission_id") + if submission_id is None or int(submission_id) not in accepted_ids: + continue + name = _full_name(raw) + primary = _primary_affiliation(raw) + key = (_normalize_for_key(name), _normalize_for_key(primary)) + if not key[0]: + continue + record = groups.get(key) + if record is None: + record = { + "name": name, + "affiliations": _all_affiliations(raw), + "_abstract_ids": set(), + "_raw_ids": set(), + } + groups[key] = record + record["_abstract_ids"].add(int(submission_id)) + raw_id = raw.get("id") + if raw_id is not None: + record["_raw_ids"].add(int(raw_id)) + + # Second pass: assign stable, sorted author_ids (sorted by (name, primary_aff)) + deduped = sorted(groups.items(), key=lambda kv: kv[0]) + records: list[dict[str, Any]] = [] + remap: dict[int, int] = {} + for index, (_key, record) in enumerate(deduped): + for raw_id in record["_raw_ids"]: + remap[raw_id] = index + records.append( + { + "author_id": index, + "name": record["name"], + "affiliations": record["affiliations"], + "abstract_ids": sorted(record["_abstract_ids"]), + } + ) + if return_remap: + return records, remap + return records + + +def build_authors( + *, + corpus_path: Path, + authors_path: Path, + build_info: Mapping[str, str], + return_remap: bool = False, +) -> dict[str, Any] | tuple[dict[str, Any], dict[int, int]]: + """Return the authors shard envelope per data-model.md §3. + + When ``return_remap=True`` also returns the raw→synthetic id map. + """ + + result = build_authors_records( + corpus_path=corpus_path, authors_path=authors_path, return_remap=return_remap + ) + if return_remap: + records, remap = result # type: ignore[misc] + envelope = { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "authors": records, + } + return envelope, remap + return { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "authors": result, # type: ignore[dict-item] + } diff --git a/src/ohbm2026/ui_data/builder.py b/src/ohbm2026/ui_data/builder.py new file mode 100644 index 00000000..59b94e2a --- /dev/null +++ b/src/ohbm2026/ui_data/builder.py @@ -0,0 +1,369 @@ +"""Stage 6 orchestrator — builds the full UI data package (T018). + +Calls each sub-builder, runs the 8 cross-shard invariants from data-model.md +§8 (most importantly: accepted-only + positional join + every shard carries +a byte-identical ``build_info`` block), and writes shards atomically to +``--output``. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +# Fixed mtime applied to every emitted shard so a rebuild against unchanged +# inputs produces byte-identical tar output (which in turn preserves Dropbox / +# CDN content hashes). Epoch 0 trips some tools; use 2026-01-01 instead. +DETERMINISTIC_MTIME = 1767225600 # 2026-01-01T00:00:00Z + +from ohbm2026.ui_data.abstracts import build_abstracts +from ohbm2026.ui_data.authors import build_authors +from ohbm2026.ui_data.cells import build_cells +from ohbm2026.ui_data.enrichment import build_enrichment +from ohbm2026.ui_data.manifest import build_manifest, make_build_info +from ohbm2026.ui_data.neighbors import build_neighbors +from ohbm2026.ui_data.state_key import ( + Stage6BuildError, + discover_rollup_state_key, +) +from ohbm2026.ui_data.topics import build_topics +from ohbm2026.ui_data.vectors import build_minilm_vectors + + +__all__ = ["build_ui_data_package", "Stage6BuildError"] + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + """Write a JSON shard via in-place truncate (O_WRONLY|O_CREAT|O_TRUNC). + + Keeps the destination file's inode + Dropbox file_id stable across + rebuilds — when this directory is later tar-czf'd into a Dropbox shared + file, the share link survives the refresh. The previous tmp-then-rename + pattern would generate a fresh inode every build, which Dropbox observes + as delete+create and invalidates the share URL. + """ + + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as fh: + json.dump(payload, fh, ensure_ascii=False, separators=(",", ":"), sort_keys=False) + fh.write("\n") + # Pin mtime so subsequent tar -czf produces byte-identical archives + # (tar embeds per-file mtime in headers). + os.utime(path, (DETERMINISTIC_MTIME, DETERMINISTIC_MTIME)) + + +def _resolve_rollup( + *, + rollup: Path | None, + analysis_root: Path | None, + discover: bool, +) -> Path: + if rollup is not None: + return Path(rollup) + if discover and analysis_root is not None: + state_key = discover_rollup_state_key(Path(analysis_root)) + return Path(analysis_root) / f"annotations__{state_key}.sqlite" + raise Stage6BuildError( + "Must pass either --rollup or --discover-rollup with --analysis-root" + ) + + +def build_ui_data_package( + *, + corpus_path: Path, + withdrawn_path: Path | None, + authors_path: Path, + enriched_path: Path | None, + references_path: Path | None, + analysis_root: Path | None, + rollup: Path | None, + discover_rollup: bool, + output_dir: Path, + build_info: Mapping[str, str] | None = None, + minilm_root: Path | None = None, +) -> int: + """Run the full Stage 6 build. + + Returns 0 on success, non-zero on any invariant failure (per + contracts/data-package.md exit codes). + """ + + rollup_db = _resolve_rollup( + rollup=rollup, + analysis_root=analysis_root, + discover=discover_rollup, + ) + + if build_info is None: + build_info = make_build_info( + corpus_path=Path(corpus_path), + rollup_db=rollup_db, + analysis_root=Path(analysis_root) if analysis_root else None, + ) + + # Build authors first to derive the raw→synthetic id remap; the abstracts + # builder uses it so `author_ids` references match the authors shard + # directly (closes invariant 4). + authors_envelope, author_id_remap = build_authors( + corpus_path=Path(corpus_path), + authors_path=Path(authors_path), + build_info=build_info, + return_remap=True, + ) + + abstracts_envelope = build_abstracts( + corpus_path=Path(corpus_path), + enriched_path=Path(enriched_path) if enriched_path else None, + references_path=Path(references_path) if references_path else None, + withdrawn_path=Path(withdrawn_path) if withdrawn_path else None, + build_info=build_info, + author_id_remap=author_id_remap, + ) + abstract_records = abstracts_envelope["abstracts"] + abstract_ids = [r["abstract_id"] for r in abstract_records] + + # Manifest discovers cells/inputs/models/facets from the rollup + records. + manifest = build_manifest( + abstracts=abstract_records, + rollup_db=rollup_db, + build_info=build_info, + ) + + cells_envelopes = build_cells( + rollup_db=rollup_db, + abstract_ids=abstract_ids, + build_info=build_info, + analysis_root=Path(analysis_root) if analysis_root else None, + ) + + topics_envelopes = build_topics( + rollup_db=rollup_db, + build_info=build_info, + ) + + # Neighbors are optional (run `scripts/compute_neighbors.py` first). + # When the analysis bundles for some cells are missing, those cells just + # don't get a neighbors shard — the UI degrades gracefully. + neighbors_envelopes: dict[str, Any] = {} + if analysis_root is not None: + neighbors_envelopes = build_neighbors( + analysis_root=Path(analysis_root), + cell_keys=list(cells_envelopes.keys()), + build_info=build_info, + ) + + # --- 8 cross-shard invariants (data-model.md §8) --------------------- + _validate_invariants( + manifest=manifest, + abstracts=abstracts_envelope, + authors=authors_envelope, + cells=cells_envelopes, + topics=topics_envelopes, + build_info=build_info, + ) + + # --- In-place write ------------------------------------------------- + # Write each shard directly into the output dir. Files that already + # exist are truncated in place (preserves inode + Dropbox file_id); + # new files are created. After writing, prune any stale shards left + # over from prior builds (e.g. cells/topics from a removed cell_key). + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + + expected: set[Path] = set() + + def _emit(rel: str, payload: Mapping[str, Any]) -> None: + target = output / rel + _write_json(target, payload) + expected.add(target) + + _emit("manifest.json", manifest) + _emit("abstracts.json", abstracts_envelope) + _emit("authors.json", authors_envelope) + # Claims + figure interpretations live in a separate shard so that the + # main abstracts.json stays small enough for first paint. The detail + # panel reads `data/enrichment.json` lazily on focus. + enrichment_envelope = build_enrichment( + enriched_path=Path(enriched_path) if enriched_path else None, + abstract_ids=abstract_ids, + build_info=build_info, + ) + _emit("enrichment.json", enrichment_envelope) + for cell_key, envelope in cells_envelopes.items(): + _emit(f"cells/{cell_key}.json", envelope) + for (model, inp, kind), envelope in topics_envelopes.items(): + _emit(f"topics/{model}_{inp}_{kind}.json", envelope) + for cell_key, envelope in neighbors_envelopes.items(): + _emit(f"neighbors/{cell_key}.json", envelope) + # MiniLM semantic-search vectors — quantized int8 buffer + sidecar. + # When the MiniLM bundle isn't available the builder emits just a sidecar + # placeholder so the manifest's pointed-at URL still 200s. + if minilm_root is not None and Path(minilm_root).exists(): + try: + bin_bytes, sidecar = build_minilm_vectors( + embeddings_root=Path(minilm_root), + abstract_ids=abstract_ids, + build_info=build_info, + ) + bin_path = output / "search" / "minilm_vectors.bin" + bin_path.parent.mkdir(parents=True, exist_ok=True) + with bin_path.open("wb") as fh: + fh.write(bin_bytes) + # Pin mtime same as JSON shards for tarball reproducibility. + os.utime(bin_path, (DETERMINISTIC_MTIME, DETERMINISTIC_MTIME)) + expected.add(bin_path) + _emit("search/minilm_vectors.build_info.json", sidecar) + except FileNotFoundError as exc: + print(f"WARNING: MiniLM vectors skipped: {exc}") + _emit( + "search/minilm_vectors.build_info.json", + { + "schema_version": "minilm_vectors.v1", + "build_info": dict(build_info), + "shape": [len(abstract_ids), 384], + "dtype": "int8", + "byte_offset_url": "data/search/minilm_vectors.bin", + "note": "vectors not built (MiniLM bundle missing)", + }, + ) + else: + _emit( + "search/minilm_vectors.build_info.json", + { + "schema_version": "minilm_vectors.v1", + "build_info": dict(build_info), + "shape": [len(abstract_ids), 384], + "dtype": "int8", + "byte_offset_url": "data/search/minilm_vectors.bin", + "note": "vectors not built (no --minilm-root)", + }, + ) + + # Drop stale shards from prior runs that weren't re-emitted this build. + # Keep the `.fetched-from-package` marker that fetch_ui_inputs.sh drops. + for path in list(output.rglob("*.json")) + list(output.rglob("*.bin")): + if path in expected: + continue + path.unlink() + # Also remove now-empty subdirs (defensive). + for sub in ("cells", "topics", "search", "neighbors"): + d = output / sub + if d.exists() and not any(d.iterdir()): + d.rmdir() + + return 0 + + +def _validate_invariants( + *, + manifest: Mapping[str, Any], + abstracts: Mapping[str, Any], + authors: Mapping[str, Any], + cells: Mapping[str, Mapping[str, Any]], + topics: Mapping[tuple[str, str, str], Mapping[str, Any]], + build_info: Mapping[str, str], +) -> None: + """Enforce the 8 cross-shard invariants from data-model.md §8.""" + + expected_count = manifest["corpus_count"] + abstract_records = abstracts["abstracts"] + + # 1. Corpus count matches. + if len(abstract_records) != expected_count: + raise Stage6BuildError( + f"Invariant 1 violated: manifest.corpus_count={expected_count} but " + f"abstracts shard has {len(abstract_records)} records" + ) + + # 2. Each cell shard has the same count + positional order. + abstract_ids = [r["abstract_id"] for r in abstract_records] + for cell_key, envelope in cells.items(): + rows = envelope["rows"] + if len(rows) != expected_count: + raise Stage6BuildError( + f"Invariant 2 violated: cell {cell_key} has {len(rows)} rows, expected {expected_count}" + ) + for idx, (aid, row) in enumerate(zip(abstract_ids, rows)): + if row["abstract_id"] != aid: + raise Stage6BuildError( + f"Invariant 2 violated: cell {cell_key} row {idx} abstract_id={row['abstract_id']} " + f"!= abstracts[{idx}].abstract_id={aid}" + ) + + # 3. Accepted-only invariant. + leaked = [r["abstract_id"] for r in abstract_records if r.get("accepted_for") == "Withdrawn"] + if leaked: + raise Stage6BuildError( + f"Invariant 3 violated: {len(leaked)} withdrawn records leaked into abstracts shard " + f"(ids: {leaked[:5]}{'...' if len(leaked) > 5 else ''})" + ) + + # 4. Author referential integrity (now enforced — see US1 remap in + # abstracts.build_abstracts_records). + author_ids = {a["author_id"] for a in authors["authors"]} + missing: set[int] = set() + for record in abstract_records: + for aid in record.get("author_ids", []): + if aid not in author_ids: + missing.add(aid) + if missing: + raise Stage6BuildError( + f"Invariant 4 violated: {len(missing)} author_id reference(s) on abstracts have no " + f"matching authors record (ids: {sorted(missing)[:5]}{'...' if len(missing) > 5 else ''})" + ) + + # 5. Cluster id integrity (community / topic / neuroscape) per cell. + topic_by_key: dict[tuple[str, str, str], set[int]] = { + triple: {t["cluster_id"] for t in envelope["topics"]} + for triple, envelope in topics.items() + } + for cell_key, envelope in cells.items(): + model, inp = cell_key.split("_", 1) + comm_topics = topic_by_key.get((model, inp, "communities"), set()) + topic_topics = topic_by_key.get((model, inp, "topic_clusters"), set()) + neuro_topics = topic_by_key.get((model, inp, "neuroscape_clusters"), set()) + for row in envelope["rows"]: + comm = row.get("community_id") + if comm is not None and comm >= 0 and comm not in comm_topics: + raise Stage6BuildError( + f"Invariant 5 violated: cell {cell_key} row references community_id={comm} " + f"absent from topics/communities shard" + ) + tc = row.get("topic_cluster_id") + if tc is not None and tc >= 0 and tc not in topic_topics: + raise Stage6BuildError( + f"Invariant 5 violated: cell {cell_key} row references topic_cluster_id={tc} " + f"absent from topics/topic_clusters shard" + ) + nc = row.get("neuroscape_cluster_id") + if nc is not None and nc >= 0 and nc not in neuro_topics: + raise Stage6BuildError( + f"Invariant 5 violated: cell {cell_key} row references neuroscape_cluster_id={nc} " + f"absent from topics/neuroscape_clusters shard" + ) + + # 6. Every shard carries a byte-identical build_info block. + reference = dict(build_info) + _assert_build_info_match(manifest["build_info"], reference, "manifest") + _assert_build_info_match(abstracts["build_info"], reference, "abstracts") + _assert_build_info_match(authors["build_info"], reference, "authors") + for cell_key, envelope in cells.items(): + _assert_build_info_match(envelope["build_info"], reference, f"cells/{cell_key}") + for triple, envelope in topics.items(): + label = f"topics/{triple[0]}_{triple[1]}_{triple[2]}" + _assert_build_info_match(envelope["build_info"], reference, label) + + # 7. Link checker — deferred to US7 (T086) so the skeleton build can pass + # before the references registry exists. + # 8. Size budget — checked by the deploy workflow's du/SC-006 step, not + # inside the builder. + + +def _assert_build_info_match(actual: Mapping[str, Any], expected: Mapping[str, Any], label: str) -> None: + if dict(actual) != dict(expected): + raise Stage6BuildError( + f"Invariant 6 violated: build_info mismatch on {label}: {dict(actual)} != {dict(expected)}" + ) diff --git a/src/ohbm2026/ui_data/cells.py b/src/ohbm2026/ui_data/cells.py new file mode 100644 index 00000000..2ed69db7 --- /dev/null +++ b/src/ohbm2026/ui_data/cells.py @@ -0,0 +1,202 @@ +"""Build ``data/cells/<model>_<input>.json`` shards for Stage 6 (T016). + +The Stage 4 rollup stores per-abstract coordinates + cluster ids in a wide +``annotations`` table with columns like ``umap2d_<model>_x`` / +``community_<model>_<input>`` / ``topic_cluster_<model>_<input>`` / +``neuroscape_cluster_<input>``. We project that wide shape into 15 per-cell +shards keyed by ``cell_key = "<model>_<input>"`` and positionally joined to +the abstracts shard's ordering. +""" + +from __future__ import annotations + +import math +import sqlite3 +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.manifest import discover_cells +from ohbm2026.ui_data.state_key import Stage6BuildError + +SCHEMA_VERSION = "cell.v1" +NEUROSCAPE_MODEL = "neuroscape" + + +def _load_per_cell_projections( + analysis_root: Path, cell_key: str +) -> tuple[dict[int, tuple[float, float]], dict[int, tuple[float, float, float]]]: + """Return ``(umap2d_by_id, umap3d_by_id)`` for *cell_key*. + + Reads `data/outputs/analysis/<cell_key>/projections__<state-key>/`: + ``ids.npy`` + ``umap2d_coords.npy`` + ``umap3d_coords.npy``. Empty maps + when the bundle doesn't exist (older Stage 4 runs may have only + populated the wide annotations table). Lazy-imports numpy so the + builder still works without `[ui]` extras installed when only the + annotations-table fallback is needed. + """ + + bundle_root = Path(analysis_root) / cell_key + if not bundle_root.exists(): + return {}, {} + projection_dirs = sorted(bundle_root.glob("projections__*")) + if not projection_dirs: + return {}, {} + bundle_dir = projection_dirs[-1] # newest by lexicographic; tie-break is fine + try: + import numpy as np # type: ignore[import-not-found] + except ImportError: + return {}, {} + try: + ids = np.load(bundle_dir / "ids.npy") + coords2d = np.load(bundle_dir / "umap2d_coords.npy") + coords3d = np.load(bundle_dir / "umap3d_coords.npy") + except FileNotFoundError: + return {}, {} + out2d: dict[int, tuple[float, float]] = {} + out3d: dict[int, tuple[float, float, float]] = {} + for i, aid in enumerate(ids): + out2d[int(aid)] = (float(coords2d[i, 0]), float(coords2d[i, 1])) + out3d[int(aid)] = (float(coords3d[i, 0]), float(coords3d[i, 1]), float(coords3d[i, 2])) + return out2d, out3d + + +def _load_annotations(rollup_db: Path) -> dict[int, dict[str, Any]]: + """Return ``annotations`` rows keyed by ``abstract_id``.""" + + rows: dict[int, dict[str, Any]] = {} + with sqlite3.connect(str(rollup_db)) as conn: + conn.row_factory = sqlite3.Row + for row in conn.execute("SELECT * FROM annotations"): + rows[int(row["abstract_id"])] = dict(row) + return rows + + +def _row_to_cell_record( + abstract_id: int, + row: Mapping[str, Any] | None, + model: str, + input_key: str, + umap2d_by_id: Mapping[int, tuple[float, float]] | None = None, + umap3d_by_id: Mapping[int, tuple[float, float, float]] | None = None, +) -> dict[str, Any]: + """Project the wide ``annotations`` row into a per-(cell, abstract) record. + + UMAP coordinates come from the per-(model, input) ``projections__*`` + bundle when available — that gives each cell its own layout. If the + per-bundle data is missing the wide annotations table (per-model only) + is used as a fallback. Missing cluster ids surface as ``-1`` to keep + the schema dense. + """ + + def _val(key: str, default: Any = None) -> Any: + if row is None: + return default + v = row.get(key) + return default if v is None else v + + coord2d = (umap2d_by_id or {}).get(int(abstract_id)) + coord3d = (umap3d_by_id or {}).get(int(abstract_id)) + umap2d = ( + [float(coord2d[0]), float(coord2d[1])] + if coord2d is not None + else [float(_val(f"umap2d_{model}_x", 0.0)), float(_val(f"umap2d_{model}_y", 0.0))] + ) + umap3d = ( + [float(coord3d[0]), float(coord3d[1]), float(coord3d[2])] + if coord3d is not None + else [ + float(_val(f"umap3d_{model}_x", 0.0)), + float(_val(f"umap3d_{model}_y", 0.0)), + float(_val(f"umap3d_{model}_z", 0.0)), + ] + ) + # `umap_missing` lets the UI filter out abstracts that don't have a + # projection in this cell (e.g. voyage_claims drops 2 abstracts that + # lacked claim embeddings). + has_projection = coord2d is not None + record: dict[str, Any] = { + "abstract_id": int(abstract_id), + "umap2d": umap2d, + "umap3d": umap3d, + "umap_missing": not has_projection if (umap2d_by_id is not None or umap3d_by_id is not None) else False, + "community_id": int(_val(f"community_{model}_{input_key}", -1)), + "topic_cluster_id": int(_val(f"topic_cluster_{model}_{input_key}", -1)), + } + if model == NEUROSCAPE_MODEL: + cluster_id = _val(f"neuroscape_cluster_neuroscape_{input_key}", -1) + cluster_distance = _val(f"neuroscape_cluster_distance_neuroscape_{input_key}", 0.0) + record["neuroscape_cluster_id"] = int(cluster_id) if cluster_id is not None else -1 + record["neuroscape_cluster_distance"] = ( + float(cluster_distance) if cluster_distance is not None else 0.0 + ) + return record + + +def build_cells_shards( + *, + rollup_db: Path, + abstract_ids: Iterable[int], + analysis_root: Path | None = None, +) -> dict[str, list[dict[str, Any]]]: + """Return ``{cell_key: [row, ...]}`` for every discovered cell. + + Each list is ordered to match *abstract_ids* (positional join with + ``abstracts.json``; cells.md §4 invariant). When *analysis_root* is + supplied each cell's UMAP coordinates are read from its own + ``projections__*`` bundle (per-(model, input) layout); otherwise the + builder falls back to the wide ``annotations`` table (per-model only). + """ + + rollup_path = Path(rollup_db) + if not rollup_path.exists(): + raise Stage6BuildError(f"Stage 4 rollup not found: {rollup_path}") + ordered_ids = list(abstract_ids) + annotations = _load_annotations(rollup_path) + cells = discover_cells(rollup_path) + + out: dict[str, list[dict[str, Any]]] = {} + for model, input_key in cells: + cell_key = f"{model}_{input_key}" + u2d, u3d = ( + _load_per_cell_projections(analysis_root, cell_key) + if analysis_root is not None + else ({}, {}) + ) + out[cell_key] = [ + _row_to_cell_record( + aid, + annotations.get(aid), + model, + input_key, + umap2d_by_id=u2d, + umap3d_by_id=u3d, + ) + for aid in ordered_ids + ] + return out + + +def build_cells( + *, + rollup_db: Path, + abstract_ids: Iterable[int], + build_info: Mapping[str, str], + analysis_root: Path | None = None, +) -> dict[str, dict[str, Any]]: + """Return ``{cell_key: envelope}`` per data-model.md §4.""" + + shards = build_cells_shards( + rollup_db=rollup_db, + abstract_ids=abstract_ids, + analysis_root=analysis_root, + ) + return { + cell_key: { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "cell_key": cell_key, + "rows": rows, + } + for cell_key, rows in shards.items() + } diff --git a/src/ohbm2026/ui_data/enrichment.py b/src/ohbm2026/ui_data/enrichment.py new file mode 100644 index 00000000..46cd6a0b --- /dev/null +++ b/src/ohbm2026/ui_data/enrichment.py @@ -0,0 +1,107 @@ +"""Build ``data/enrichment.json`` — Stage 2.1 claims + figure interpretations. + +These are the AI-authored surfaces that the detail panel shows behind +expandable headers (per FR-023). The shard is keyed by ``abstract_id`` so the +UI can look up each focused abstract's enrichment in O(1). Fields are trimmed +to the user-visible subset to keep the wire size modest: + +* claims → ``{claim, claim_type, evidence, evidence_eco_codes, source, + source_quote_verified}`` +* figures → ``{interpretation, keywords, ocr_text, question_name, + model_quality_estimate}`` + +The ``model_id`` is identical across every record so it's lifted to the shard +envelope instead of being repeated thousands of times. +""" + +from __future__ import annotations + +import json +import sqlite3 +import zlib +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +SCHEMA_VERSION = "enrichment.v1" + + +_CLAIM_FIELDS = ( + "claim", + "claim_type", + "evidence", + "evidence_eco_codes", + "source", + "source_quote_verified", +) + +_FIGURE_FIELDS = ( + "interpretation", + "keywords", + "ocr_text", + "question_name", + "model_quality_estimate", +) + + +def _trim_claim(claim: Mapping[str, Any]) -> dict[str, Any]: + return {k: claim.get(k) for k in _CLAIM_FIELDS if k in claim} + + +def _trim_figure(fig: Mapping[str, Any]) -> dict[str, Any]: + return {k: fig.get(k) for k in _FIGURE_FIELDS if k in fig} + + +def _iter_enriched_rows(sqlite_path: Path) -> Iterable[tuple[int, dict[str, Any]]]: + con = sqlite3.connect(sqlite_path) + try: + for row_id, payload in con.execute("SELECT id, payload FROM abstracts"): + yield int(row_id), json.loads(zlib.decompress(payload)) + finally: + con.close() + + +def build_enrichment( + *, + enriched_path: Path | None, + abstract_ids: Iterable[int], + build_info: Mapping[str, str], +) -> dict[str, Any]: + """Return the enrichment shard envelope keyed by ``abstract_id``. + + Records with no claims AND no figures are omitted from the records dict + entirely (the UI treats a missing key as "no enrichment available"). + """ + + keep_ids = set(abstract_ids) + records: dict[str, dict[str, Any]] = {} + model_ids = {"claims": set(), "figures": set()} + if enriched_path is not None and Path(enriched_path).exists(): + for abstract_id, payload in _iter_enriched_rows(Path(enriched_path)): + if abstract_id not in keep_ids: + continue + claims_raw = payload.get("claims") or [] + figures_raw = payload.get("figure_interpretation") or [] + if not claims_raw and not figures_raw: + continue + claims = [_trim_claim(c) for c in claims_raw if isinstance(c, dict)] + figures = [_trim_figure(f) for f in figures_raw if isinstance(f, dict)] + for c in claims_raw: + if isinstance(c, dict) and c.get("model_id"): + model_ids["claims"].add(c["model_id"]) + for f in figures_raw: + if isinstance(f, dict) and f.get("model_id"): + model_ids["figures"].add(f["model_id"]) + records[str(abstract_id)] = {"claims": claims, "figures": figures} + return { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + # When the corpus has multiple model ids (e.g. mid-cycle rebuild), join + # them with "+" so the AI-attribution pill can still cite a string. The + # set is usually a singleton. + "ai_provenance": { + "claims_model_id": "+".join(sorted(model_ids["claims"])) or None, + "figures_model_id": "+".join(sorted(model_ids["figures"])) or None, + }, + "records": records, + } diff --git a/src/ohbm2026/ui_data/link_check.py b/src/ohbm2026/ui_data/link_check.py new file mode 100644 index 00000000..fc6ac81d --- /dev/null +++ b/src/ohbm2026/ui_data/link_check.py @@ -0,0 +1,160 @@ +"""Build-time link checker for the references registry (T086 / FR-017). + +Walks ``specs/008-ui-rewrite/contracts/references.yaml`` (or any conforming +YAML), HEADs every ``url`` field with a 10 s timeout, returns: + +* exit 0 if every URL returned a 2xx / 3xx +* exit 3 on any 4xx / 5xx / connection error (per + ``contracts/data-package.md`` exit-code table) + +Wire this into ``deploy-ui.yml`` between the data-package build and the +site build so a broken external citation blocks the deploy (T088). +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import requests +import yaml + +__all__ = [ + "DEFAULT_TIMEOUT", + "LinkCheckResult", + "iter_references", + "head_url", + "link_check", + "main", +] + +DEFAULT_TIMEOUT = 10.0 +DEFAULT_USER_AGENT = "ohbm2026-link-check/1.0 (+https://github.com/sensein/ohbm2026)" + + +@dataclass(frozen=True) +class LinkCheckResult: + """One row of the link-check report.""" + + url: str + section: str + title: str + status: int | None # None when the request raised before HTTP + ok: bool + reason: str # short human-facing reason, e.g. '200', 'timeout', 'dns' + + +def iter_references(payload: Mapping[str, Any]) -> Iterable[dict[str, Any]]: + """Yield each entry under the top-level ``references`` list.""" + + refs = payload.get("references") + if not isinstance(refs, list): + return + for entry in refs: + if isinstance(entry, dict) and isinstance(entry.get("url"), str): + yield entry + + +def head_url( + url: str, + *, + timeout: float = DEFAULT_TIMEOUT, + session: requests.Session | None = None, +) -> tuple[int | None, str]: + """HEAD with a single GET fallback for hosts that 405 HEAD. + + Returns ``(status, reason)``. ``status`` is None on transport-level + errors (timeout, DNS, refused). + """ + + headers = {"User-Agent": DEFAULT_USER_AGENT, "Accept": "*/*"} + s = session or requests.Session() + try: + r = s.head(url, allow_redirects=True, timeout=timeout, headers=headers) + # Some servers refuse HEAD with 405 / 403; retry with a streaming GET + # and close the connection without reading the body. + if r.status_code in (403, 405, 501): + r = s.get(url, allow_redirects=True, timeout=timeout, headers=headers, stream=True) + r.close() + return (r.status_code, str(r.status_code)) + except requests.Timeout: + return (None, "timeout") + except requests.ConnectionError as exc: + return (None, f"connection: {type(exc).__name__}") + except requests.RequestException as exc: + return (None, f"request: {type(exc).__name__}") + + +def link_check( + yaml_path: Path | str, + *, + timeout: float = DEFAULT_TIMEOUT, + session: requests.Session | None = None, +) -> tuple[int, list[LinkCheckResult]]: + """Validate every URL in the references file. + + Returns ``(exit_code, results)``. exit_code follows contracts/data-package.md: + + * 0 → every URL OK + * 3 → at least one URL not OK (or the file is missing / unparseable) + """ + + path = Path(yaml_path) + if not path.exists(): + return (3, [LinkCheckResult(url=str(path), section="-", title="-", status=None, ok=False, reason="missing yaml")]) + try: + payload = yaml.safe_load(path.read_text()) + except yaml.YAMLError as exc: + return (3, [LinkCheckResult(url=str(path), section="-", title="-", status=None, ok=False, reason=f"yaml-parse: {exc}")]) + if not isinstance(payload, dict): + return (3, [LinkCheckResult(url=str(path), section="-", title="-", status=None, ok=False, reason="yaml-root-not-mapping")]) + + results: list[LinkCheckResult] = [] + s = session or requests.Session() + for ref in iter_references(payload): + url = ref["url"] + section = str(ref.get("section", "-")) + title = str(ref.get("title", "-")) + status, reason = head_url(url, timeout=timeout, session=s) + ok = status is not None and 200 <= status < 400 + results.append( + LinkCheckResult(url=url, section=section, title=title, status=status, ok=ok, reason=reason) + ) + if not results: + return (3, [LinkCheckResult(url=str(path), section="-", title="-", status=None, ok=False, reason="no-references")]) + + any_fail = any(not r.ok for r in results) + return (3 if any_fail else 0, results) + + +def _format_results(results: list[LinkCheckResult]) -> str: + lines = ["section status url"] + for r in results: + flag = "OK " if r.ok else "FAIL" + st = str(r.status) if r.status is not None else "---" + lines.append(f"{r.section:18s}{flag} {st:5s} {r.url} ({r.reason})") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + import argparse + + parser = argparse.ArgumentParser(prog="link_check", description="HEAD-check references registry URLs") + parser.add_argument("yaml", type=Path, nargs="?", default=Path("specs/008-ui-rewrite/contracts/references.yaml")) + parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT) + parser.add_argument("--quiet", action="store_true", help="print only summary line") + args = parser.parse_args(argv) + code, results = link_check(args.yaml, timeout=args.timeout) + if not args.quiet: + print(_format_results(results)) + ok = sum(1 for r in results if r.ok) + fail = sum(1 for r in results if not r.ok) + print(f"link_check: ok={ok} fail={fail} → exit {code}", file=sys.stderr) + return code + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/ohbm2026/ui_data/manifest.py b/src/ohbm2026/ui_data/manifest.py new file mode 100644 index 00000000..248eacc0 --- /dev/null +++ b/src/ohbm2026/ui_data/manifest.py @@ -0,0 +1,242 @@ +"""Build ``data/manifest.json`` for Stage 6 (T011). + +The manifest is the entry point shard the SvelteKit site fetches first. It +declares the schema version, build provenance, cell catalog, facet catalog, +and search-asset URLs. + +Per CA-007 the catalogs (models / inputs / cells / facets) MUST be discovered +at build time from the Stage 4 rollup + corpus. No hardcoded lists. +""" + +from __future__ import annotations + +import sqlite3 +import subprocess +from collections.abc import Iterable, Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.state_key import ( + Stage6BuildError, + discover_corpus_state_key, + discover_rollup_state_key, +) + +SCHEMA_VERSION = "ui.v1" +DEFAULT_CELL = {"model": "neuroscape", "input": "abstract"} + +# Facet keys whose options come straight from per-abstract values (no regex) +# — emitted by the abstracts builder under the `facets` block of each record. +FACET_KEYS: tuple[str, ...] = ( + "accepted_for", + "primary_topic", + "secondary_topic", + "keywords", + "methods", + "study_type", + "population", + "field_strength", + "processing_packages", + "species", + "recording_technology", + "brain_regions", + "brain_networks", +) + +# Human-readable labels — keep parallel with FACET_KEYS so the discovery +# function below stays a single source of truth. (Not a hardcoded value +# *list*; it's a key→label mapping.) +FACET_LABELS: Mapping[str, str] = { + "accepted_for": "Accepted for", + "primary_topic": "Primary topic", + "secondary_topic": "Secondary topic", + "keywords": "Keywords", + "methods": "Methods", + "study_type": "Study type", + "population": "Population", + "field_strength": "Field strength", + "processing_packages": "Processing packages", + "species": "Species", + "recording_technology": "Recording technology", + "brain_regions": "Brain regions", + "brain_networks": "Brain networks", +} + + +def _git_revision(repo_root: Path | None = None) -> str: + """Return the current git HEAD SHA. Best-effort; raises on failure.""" + + root = repo_root or Path.cwd() + try: + out = subprocess.run( + ["git", "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + cwd=root, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + raise Stage6BuildError( + f"Failed to read git HEAD revision at {root}: {exc}" + ) from exc + return out.stdout.strip() + + +def make_build_info( + *, + corpus_path: Path, + rollup_db: Path, + analysis_root: Path | None = None, + code_revision: str | None = None, + built_at: str | None = None, +) -> dict[str, str]: + """Assemble the ``build_info`` block embedded into every shard. + + Per FR-019 + FR-022 + CA-008. The 7-char short SHA enables the page-footer + affordance to display the committish without revealing the full hash. + """ + + sha = code_revision or _git_revision() + rollup_state_key = ( + discover_rollup_state_key(analysis_root) if analysis_root else _state_key_from_filename(rollup_db) + ) + return { + "corpus_state_key": discover_corpus_state_key(corpus_path), + "code_revision": sha, + "code_revision_short": sha[:7] if sha else "", + "stage4_rollup_state_key": rollup_state_key, + "built_at": built_at or datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + + +def _state_key_from_filename(rollup_db: Path) -> str: + """Extract the state-key suffix from ``annotations__<key>.sqlite``.""" + + stem = Path(rollup_db).stem + prefix = "annotations__" + if not stem.startswith(prefix): + raise Stage6BuildError( + f"Unexpected rollup filename: {rollup_db} (must match annotations__<key>.sqlite)" + ) + return stem[len(prefix):] + + +def discover_cells(rollup_db: Path) -> list[tuple[str, str]]: + """Return distinct ``(model_key, input_source)`` pairs from cluster_topics.""" + + with sqlite3.connect(str(rollup_db)) as conn: + rows = conn.execute( + "SELECT DISTINCT model_key, input_source FROM cluster_topics ORDER BY 1, 2" + ).fetchall() + return [(m, i) for m, i in rows] + + +def discover_topic_kinds(rollup_db: Path) -> list[tuple[str, str, str]]: + """Return distinct ``(clustering_method, model_key, input_source)`` triples.""" + + with sqlite3.connect(str(rollup_db)) as conn: + rows = conn.execute( + "SELECT DISTINCT clustering_method, model_key, input_source FROM cluster_topics ORDER BY 1, 2, 3" + ).fetchall() + return [(k, m, i) for k, m, i in rows] + + +def _facet_options( + abstracts: Iterable[dict[str, Any]], key: str +) -> list[str]: + """Discover the alphabetical set of distinct option strings for a facet key. + + `accepted_for` keeps program-order (single value typically) so it's the + one exception; primary/secondary_topic and the others are sorted. + """ + + seen: set[str] = set() + for record in abstracts: + facets = record.get("facets") or {} + if key in ("primary_topic", "secondary_topic"): + val = (record.get("topics") or {}).get( + "primary" if key == "primary_topic" else "secondary" + ) + if val: + seen.add(str(val)) + continue + if key == "accepted_for": + val = record.get("accepted_for") + if val: + seen.add(str(val)) + continue + value = facets.get(key) + if isinstance(value, list): + for item in value: + if item: + seen.add(str(item)) + elif isinstance(value, str) and value: + seen.add(value) + return sorted(seen) + + +def build_manifest( + *, + abstracts: list[dict[str, Any]], + rollup_db: Path, + build_info: Mapping[str, str], +) -> dict[str, Any]: + """Assemble the manifest dict per data-model.md §1. + + Discovers cells + topic kinds from the rollup; discovers facet options + from the corpus. The output is JSON-serializable. + """ + + cells = discover_cells(rollup_db) + topic_kinds = discover_topic_kinds(rollup_db) + + # Build per-cell topic-shard URL map. + topic_map: dict[tuple[str, str], dict[str, str]] = {} + for kind, model, inp in topic_kinds: + topic_map.setdefault((model, inp), {})[kind] = ( + f"data/topics/{model}_{inp}_{kind}.json" + ) + + cell_entries: list[dict[str, Any]] = [] + for model, inp in cells: + cell_key = f"{model}_{inp}" + cell_entries.append( + { + "cell_key": cell_key, + "model": model, + "input": inp, + "shard_url": f"data/cells/{cell_key}.json", + "topic_shards": topic_map.get((model, inp), {}), + } + ) + + models = sorted({m for m, _ in cells}) + inputs = sorted({i for _, i in cells}) + + facet_entries = [ + { + "key": key, + "label": FACET_LABELS[key], + "options": _facet_options(abstracts, key), + } + for key in FACET_KEYS + ] + + return { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "corpus_count": len(abstracts), + "default_cell": dict(DEFAULT_CELL), + "models": models, + "inputs": inputs, + "cells": cell_entries, + "facets": facet_entries, + "search": { + "lexical_index": "data/search/lexical_index.json", + "minilm_vectors": "data/search/minilm_vectors.bin", + "minilm_vectors_build_info_url": "data/search/minilm_vectors.build_info.json", + "minilm_dim": 384, + "minilm_dtype": "int8", + }, + } diff --git a/src/ohbm2026/ui_data/neighbors.py b/src/ohbm2026/ui_data/neighbors.py new file mode 100644 index 00000000..da04f82e --- /dev/null +++ b/src/ohbm2026/ui_data/neighbors.py @@ -0,0 +1,105 @@ +"""Build ``data/neighbors/<cell_key>.json`` shards for Stage 6. + +Each shard carries the per-(model, input) k-nearest + k-farthest neighbor +indices + cosine distances computed offline by ``scripts/compute_neighbors.py`` +and stored under ``data/outputs/analysis/<cell_key>/neighbors__<state-key>/``. + +Shard shape (array-form for compact JSON): + + { + "schema_version": "neighbors.v1", + "build_info": { ... }, + "cell_key": "neuroscape_abstract", + "k": 10, + "abstract_ids": [ ...N ], + "nearest_ids": [ [ ...10 ], ...N ], + "nearest_distances": [ [ ...10 ], ...N ], + "farthest_ids": [ [ ...10 ], ...N ], + "farthest_distances": [ [ ...10 ], ...N ] + } + +The UI joins by position: ``abstract_ids[i]`` corresponds to row *i* of the +matching cells shard, so given an abstract the client looks up its row index +in the cell shard, then reads the same row of the neighbors shard. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.state_key import Stage6BuildError + +SCHEMA_VERSION = "neighbors.v1" + + +def _round_floats(values: Iterable[float], precision: int = 4) -> list[float]: + return [round(float(v), precision) for v in values] + + +def _load_per_cell_neighbors( + analysis_root: Path, cell_key: str +) -> dict[str, Any] | None: + """Return the neighbor arrays for *cell_key*, or None when no bundle exists.""" + + cell_dir = Path(analysis_root) / cell_key + if not cell_dir.exists(): + return None + candidates = sorted(cell_dir.glob("neighbors__*")) + if not candidates: + return None + bundle = candidates[-1] + try: + import numpy as np # type: ignore[import-not-found] + except ImportError: + return None + try: + ids = np.load(bundle / "ids.npy") + nearest_ids = np.load(bundle / "nearest_ids.npy") + nearest_dist = np.load(bundle / "nearest_distances.npy") + farthest_ids = np.load(bundle / "farthest_ids.npy") + farthest_dist = np.load(bundle / "farthest_distances.npy") + except FileNotFoundError: + return None + return { + "ids": [int(x) for x in ids.tolist()], + "nearest_ids": [[int(x) for x in row] for row in nearest_ids.tolist()], + "nearest_distances": [_round_floats(row) for row in nearest_dist.tolist()], + "farthest_ids": [[int(x) for x in row] for row in farthest_ids.tolist()], + "farthest_distances": [_round_floats(row) for row in farthest_dist.tolist()], + "k": int(nearest_ids.shape[1]) if nearest_ids.ndim == 2 else 0, + } + + +def build_neighbors( + *, + analysis_root: Path, + cell_keys: Iterable[str], + build_info: Mapping[str, str], +) -> dict[str, dict[str, Any]]: + """Return ``{cell_key: envelope}`` per cell, skipping cells without a + pre-computed neighbors bundle. The skip behavior keeps the build + resilient when ``compute_neighbors.py`` has only run for some cells. + """ + + if not Path(analysis_root).exists(): + raise Stage6BuildError(f"analysis root not found: {analysis_root}") + out: dict[str, dict[str, Any]] = {} + for cell_key in cell_keys: + payload = _load_per_cell_neighbors(analysis_root, cell_key) + if payload is None: + continue + envelope = { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "cell_key": cell_key, + "k": payload["k"], + "abstract_ids": payload["ids"], + "nearest_ids": payload["nearest_ids"], + "nearest_distances": payload["nearest_distances"], + "farthest_ids": payload["farthest_ids"], + "farthest_distances": payload["farthest_distances"], + } + out[cell_key] = envelope + return out diff --git a/src/ohbm2026/ui_data/state_key.py b/src/ohbm2026/ui_data/state_key.py new file mode 100644 index 00000000..d6dd51ed --- /dev/null +++ b/src/ohbm2026/ui_data/state_key.py @@ -0,0 +1,159 @@ +"""State-key discovery for Stage 6 builds (T011a). + +Per CA-007 the UI data-package build MUST NOT hardcode state-keys. This module +discovers them at build time from the canonical artifact locations. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path + +class Stage6BuildError(RuntimeError): + """Raised when the Stage 6 builder cannot resolve a required input. + + Defined as a bare ``RuntimeError`` subclass (rather than + :class:`ohbm2026.exceptions.Stage6Error`) so that importing this module + via ``python -m ohbm2026.ui_data.state_key`` doesn't trigger the + ``exceptions.py`` → ``fetch.graphql_api`` import chain (which has a + circular-import hazard with ``fetch.stage``). ``Stage6Error`` is still + available as a logical parent — callers can ``except Stage6Error`` and + catch this too once they go through the normal import path, since the + public hierarchy in ``exceptions`` re-exports it. + """ + + +_ROLLUP_RE = re.compile(r"^annotations__([0-9a-f]{8,16})\.sqlite$") +_MINILM_RE = re.compile(r"^([a-z_]+)__([0-9a-f]{8,16})$") + + +def discover_corpus_state_key(corpus_path: Path) -> str: + """Return the canonical state-key for the corpus at *corpus_path*. + + If the corpus JSON carries a ``meta.state_key`` field it is used verbatim. + Otherwise the state-key is derived as the first 12 chars of the SHA-256 of + the corpus bytes — stable across rebuilds for unchanged corpora. + """ + + corpus_path = Path(corpus_path) + with corpus_path.open("rb") as fh: + raw = fh.read() + # Fast peek for a meta.state_key + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise Stage6BuildError( + f"Cannot decode corpus JSON at {corpus_path}: {exc}" + ) from exc + meta = data.get("meta") if isinstance(data, dict) else None + if isinstance(meta, dict) and isinstance(meta.get("state_key"), str): + return str(meta["state_key"]) + digest = hashlib.sha256(raw).hexdigest() + return digest[:12] + + +def discover_rollup_state_key(analysis_root: Path) -> str: + """Return the active Stage 4 rollup state-key under *analysis_root*. + + Globs ``annotations__*.sqlite`` and picks the most recent by mtime. + Raises :class:`Stage6BuildError` when zero matches are found OR when + multiple matches exist and the operator hasn't disambiguated (forcing + explicit selection in production builds). + """ + + analysis_root = Path(analysis_root) + if not analysis_root.exists(): + raise Stage6BuildError( + f"Analysis root does not exist: {analysis_root}" + ) + candidates: list[tuple[float, str, Path]] = [] + for path in analysis_root.glob("annotations__*.sqlite"): + match = _ROLLUP_RE.match(path.name) + if not match: + continue + candidates.append((path.stat().st_mtime, match.group(1), path)) + if not candidates: + raise Stage6BuildError( + f"No annotations__*.sqlite rollups found under {analysis_root}; " + "run Stage 4 (`ohbmcli analyze-matrix`) first." + ) + if len(candidates) > 1: + names = ", ".join(sorted({c[1] for c in candidates})) + raise Stage6BuildError( + f"Multiple Stage 4 rollups under {analysis_root}: {names}. " + "Pass --rollup explicitly to disambiguate." + ) + return candidates[0][1] + + +def discover_minilm_bundle( + embeddings_root: Path, component: str = "introduction" +) -> Path: + """Return the path to the MiniLM embedding bundle for *component*. + + Used by the deploy workflow to locate the int8-quantizable vectors for + semantic search (FR-007 / SC-006). The bundle directory name encodes + ``<component>__<state-key>``; we pick the most recent match. + """ + + embeddings_root = Path(embeddings_root) + if not embeddings_root.exists(): + raise Stage6BuildError( + f"MiniLM embeddings root does not exist: {embeddings_root}" + ) + candidates: list[tuple[float, Path]] = [] + for path in embeddings_root.iterdir(): + if not path.is_dir(): + continue + match = _MINILM_RE.match(path.name) + if not match: + continue + if match.group(1) != component: + continue + candidates.append((path.stat().st_mtime, path)) + if not candidates: + raise Stage6BuildError( + f"No MiniLM bundle for component={component!r} under {embeddings_root}; " + "expected a directory named `<component>__<state-key>`." + ) + candidates.sort(reverse=True) + return candidates[0][1] + + +def main() -> int: + """CLI entry — print a discovered path on stdout for shell substitution. + + Usage: + python -m ohbm2026.ui_data.state_key rollup data/outputs/analysis + python -m ohbm2026.ui_data.state_key minilm data/outputs/embeddings/minilm introduction + python -m ohbm2026.ui_data.state_key corpus data/primary/abstracts.json + """ + + import sys + + args = sys.argv[1:] + if len(args) < 2: + print(__doc__, file=sys.stderr) + return 2 + kind, path = args[0], Path(args[1]) + try: + if kind == "rollup": + print(discover_rollup_state_key(path)) + elif kind == "minilm": + component = args[2] if len(args) >= 3 else "introduction" + print(str(discover_minilm_bundle(path, component=component))) + elif kind == "corpus": + print(discover_corpus_state_key(path)) + else: + print(f"unknown kind: {kind}", file=sys.stderr) + return 2 + except Stage6BuildError as exc: + print(f"error: {exc}", file=sys.stderr) + return 3 + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/ohbm2026/ui_data/topics.py b/src/ohbm2026/ui_data/topics.py new file mode 100644 index 00000000..ab09a9ac --- /dev/null +++ b/src/ohbm2026/ui_data/topics.py @@ -0,0 +1,91 @@ +"""Build ``data/topics/<model>_<input>_<kind>.json`` for Stage 6 (T017). + +Reads the Stage 4 rollup's ``cluster_topics`` table and emits one envelope per +``(model_key, input_source, clustering_method)`` triple. +""" + +from __future__ import annotations + +import json +import sqlite3 +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.state_key import Stage6BuildError + +SCHEMA_VERSION = "topics.v1" + + +def _split_keywords(blob: str | None) -> list[str]: + if not blob: + return [] + raw = str(blob).strip() + if not raw: + return [] + # cluster_topics stores keywords as JSON, comma-, or pipe-separated strings + # depending on the upstream writer. Be permissive on the read side. + if raw.startswith("[") and raw.endswith("]"): + try: + decoded = json.loads(raw) + except json.JSONDecodeError: + decoded = None + if isinstance(decoded, list): + return [str(item).strip() for item in decoded if str(item).strip()] + for sep in ("|", ","): + if sep in raw: + return [piece.strip() for piece in raw.split(sep) if piece.strip()] + return [raw] + + +def build_topics_shards( + *, + rollup_db: Path, +) -> dict[tuple[str, str, str], list[dict[str, Any]]]: + """Return ``{(model, input, kind): [topic_record, ...]}`` for every triple.""" + + rollup_path = Path(rollup_db) + if not rollup_path.exists(): + raise Stage6BuildError(f"Stage 4 rollup not found: {rollup_path}") + out: dict[tuple[str, str, str], list[dict[str, Any]]] = {} + with sqlite3.connect(str(rollup_path)) as conn: + conn.row_factory = sqlite3.Row + for row in conn.execute( + """ + SELECT clustering_method, model_key, input_source, cluster_id, + topic_keywords, topic_title, topic_description, topic_focus + FROM cluster_topics + ORDER BY clustering_method, model_key, input_source, cluster_id + """ + ): + triple = (row["model_key"], row["input_source"], row["clustering_method"]) + out.setdefault(triple, []).append( + { + "cluster_id": int(row["cluster_id"]), + "keywords": _split_keywords(row["topic_keywords"]), + "title": row["topic_title"] or "", + "description": row["topic_description"] or "", + "focus": row["topic_focus"] or "", + } + ) + return out + + +def build_topics( + *, + rollup_db: Path, + build_info: Mapping[str, str], +) -> dict[tuple[str, str, str], dict[str, Any]]: + """Return ``{(model, input, kind): envelope}`` per data-model.md §5.""" + + shards = build_topics_shards(rollup_db=rollup_db) + return { + triple: { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "cell_key": f"{triple[0]}_{triple[1]}", + "kind": triple[2], + "topics": rows, + } + for triple, rows in shards.items() + } diff --git a/src/ohbm2026/ui_data/vectors.py b/src/ohbm2026/ui_data/vectors.py new file mode 100644 index 00000000..dc0853c0 --- /dev/null +++ b/src/ohbm2026/ui_data/vectors.py @@ -0,0 +1,138 @@ +"""Quantize MiniLM-L6 embeddings to int8 for the in-browser semantic search. + +The browser-side worker (`site/src/lib/workers/semantic.worker.ts`) loads +`Xenova/all-MiniLM-L6-v2` via transformers.js to embed the query text in +the browser, then ranks the corpus by cosine similarity against this +pre-computed int8 vector buffer. + +We compose the per-component bundles (introduction, methods, results, +conclusion) into a single per-abstract vector by mean-pooling and +L2-renormalizing — `title` is too narrow on its own; the four sections +together give a representative summary. Each vector is then int8-quantized +with a SINGLE global scale (max-abs → 127). Cosine-similarity ordering is +preserved by the int8 quantization regardless of scale. + +Output: a raw little-endian Int8 buffer of shape `[N, 384]` row-major, +plus a sidecar JSON with the build_info + scale + abstract_ids order so +the browser can dequantize + positionally-join with `abstracts.json`. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + + +COMPONENT_NAMES: tuple[str, ...] = ("introduction", "methods", "results", "conclusion") +DIMENSION = 384 + + +def _load_component(component_root: Path) -> tuple["object", "object"]: + """Return ``(ids, vectors)`` for a MiniLM component bundle. + + Lazy-imports numpy so `vectors.py` is import-safe without the [ui] extra. + """ + + import numpy as np # noqa: I001 + + ids = np.load(component_root / "ids.npy") + vectors = np.load(component_root / "vectors.npy").astype(np.float32) + if vectors.shape[1] != DIMENSION: + raise RuntimeError( + f"{component_root}: expected {DIMENSION}-dim vectors, got {vectors.shape[1]}" + ) + return ids, vectors + + +def build_minilm_vectors( + *, + embeddings_root: Path, + abstract_ids: Iterable[int], + build_info: Mapping[str, str], + components: tuple[str, ...] = COMPONENT_NAMES, +) -> tuple[bytes, dict[str, Any]]: + """Return ``(int8_buffer, sidecar_dict)`` for the canonical per-abstract semantic vector. + + *abstract_ids* is the ordering required by ``abstracts.json`` — the output + buffer is positionally joined to that ordering so the browser can index + row *i* directly. Abstracts whose embedding is missing get a zero vector + (which yields cosine 0 — they're never top-K matches). + """ + + import numpy as np # noqa: I001 + + ordered_ids = list(abstract_ids) + + # Find one component dir per name (most recent state-key suffix). + component_dirs: list[Path] = [] + for name in components: + candidates = sorted(Path(embeddings_root).glob(f"{name}__*")) + if not candidates: + raise FileNotFoundError( + f"No MiniLM bundle for component {name!r} under {embeddings_root}" + ) + component_dirs.append(candidates[-1]) + + # Load each component → build a (abstract_id → vector) map, then mean. + per_abstract: dict[int, list[np.ndarray]] = {} + used_state_keys: list[str] = [] + for comp_dir in component_dirs: + ids, vectors = _load_component(comp_dir) + used_state_keys.append(comp_dir.name.split("__", 1)[1]) + for i, aid in enumerate(ids): + per_abstract.setdefault(int(aid), []).append(vectors[i]) + + # Compose per-abstract vector = mean of components (re-normalized). + composed = np.zeros((len(ordered_ids), DIMENSION), dtype=np.float32) + missing: list[int] = [] + for row, aid in enumerate(ordered_ids): + parts = per_abstract.get(int(aid)) + if not parts: + missing.append(int(aid)) + continue + mean = np.mean(np.stack(parts, axis=0), axis=0) + norm = np.linalg.norm(mean) + if norm > 0: + mean = mean / norm + composed[row] = mean + + # Pick global scale: max abs across the matrix → 127. + max_abs = float(np.max(np.abs(composed))) if composed.size else 1.0 + if max_abs == 0: + max_abs = 1.0 + scale_to_int = 127.0 / max_abs + quant = np.clip(np.round(composed * scale_to_int), -127, 127).astype(np.int8) + + # Cosine-recovery probe on a held-out subset (data-model.md §7). + rng = np.random.default_rng(seed=0) + n_check = min(100, composed.shape[0]) + sample_idx = rng.choice(composed.shape[0], size=n_check, replace=False) if n_check else np.array([], dtype=int) + recovery_error = 0.0 + if n_check >= 2: + f32 = composed[sample_idx] + i8_as_f32 = quant[sample_idx].astype(np.float32) / scale_to_int + # Normalize both + for matrix in (f32, i8_as_f32): + norms = np.linalg.norm(matrix, axis=1, keepdims=True) + norms[norms == 0] = 1 + matrix /= norms + cos_f32 = f32 @ f32.T + cos_i8 = i8_as_f32 @ i8_as_f32.T + recovery_error = float(np.mean(np.abs(cos_f32 - cos_i8))) + + sidecar = { + "schema_version": "minilm_vectors.v1", + "build_info": dict(build_info), + "shape": [composed.shape[0], DIMENSION], + "dtype": "int8", + "scale": scale_to_int, + "max_abs_original": max_abs, + "components": list(components), + "component_state_keys": used_state_keys, + "missing_abstract_ids": missing, + "cosine_recovery_mae": recovery_error, + "byte_offset_url": "data/search/minilm_vectors.bin", + } + return quant.tobytes(), sidecar diff --git a/tests/_ui_data_fixtures.py b/tests/_ui_data_fixtures.py new file mode 100644 index 00000000..56f0f9fd --- /dev/null +++ b/tests/_ui_data_fixtures.py @@ -0,0 +1,222 @@ +"""Tiny synthetic fixtures shared by Stage 6 builder tests.""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + + +CORPUS_PAYLOAD = { + "fetched_at": "2026-05-17T00:00:00Z", + "abstract_count": 3, + "abstracts": [ + { + "id": 1001, + "poster_id": "M-AM-101", + "title": "Memory fMRI in aging", + "accepted_for": "Poster", + "authors": [{"author_order": 0, "id": 5000}, {"author_order": 1, "id": 5001}], + "responses": [ + {"question_name": "Keywords", "value": "Aging|MRI"}, + {"question_name": "Primary Parent Category & Sub-Category", "value": "Lifespan Development|Aging"}, + {"question_name": "Secondary Parent Category & Sub-Category", "value": "Neuroinformatics and Data Sharing|Informatics Other"}, + {"question_name": "Please indicate which methods were used in your research:", "value": "Functional MRI|Diffusion MRI"}, + ], + }, + { + "id": 1002, + "poster_id": "M-AM-102", + "title": "Withdrawn study", + "accepted_for": "Withdrawn", + "authors": [{"author_order": 0, "id": 5002}], + "responses": [], + }, + { + "id": 1003, + "poster_id": "M-AM-103", + "title": "fMRI of default mode network", + "accepted_for": "Oral", + "authors": [{"author_order": 0, "id": 5000}], + "responses": [ + {"question_name": "Keywords", "value": "DMN|resting-state"}, + {"question_name": "Primary Parent Category & Sub-Category", "value": "Cognition|Memory"}, + {"question_name": "Please indicate which methods were used in your research:", "value": "Functional MRI"}, + ], + }, + ], +} + +WITHDRAWN_PAYLOAD = { + "fetched_at": "2026-05-17T00:00:00Z", + "abstract_count": 1, + "abstracts": [ + {"id": 1002, "poster_id": "M-AM-102", "accepted_for": "Withdrawn"}, + ], +} + +AUTHORS_PAYLOAD = { + "fetched_at": "2026-05-17T00:00:00Z", + "author_count": 4, + "authors": [ + { + "id": 5000, + "first_name": "Jane", + "middle_initial": None, + "last_name": "Smith", + "submission_id": 1001, + "affiliations": [ + { + "id": 9000, + "affiliation_order": 0, + "institution": "Stanford University", + "city": "Stanford", + "state": "CA", + "country": "United States", + } + ], + }, + { + "id": 5001, + "first_name": "John", + "middle_initial": "Q", + "last_name": "Doe", + "submission_id": 1001, + "affiliations": [ + {"id": 9001, "affiliation_order": 0, "institution": "MIT", "city": "Cambridge", "state": "MA", "country": "United States"} + ], + }, + { + "id": 5002, + "first_name": "Foo", + "middle_initial": None, + "last_name": "Bar", + "submission_id": 1002, # withdrawn — should be filtered out + "affiliations": [], + }, + { + "id": 5000, + "first_name": "Jane", + "middle_initial": None, + "last_name": "Smith", + "submission_id": 1003, + "affiliations": [ + {"id": 9000, "affiliation_order": 0, "institution": "Stanford University", "city": "Stanford", "state": "CA", "country": "United States"} + ], + }, + ], +} + + +def _create_rollup(path: Path) -> None: + conn = sqlite3.connect(path) + cur = conn.cursor() + # Mirror the production schema but with a minimal column subset that + # covers two models × two inputs to exercise the wide-→long projection. + cur.execute( + """ + CREATE TABLE annotations ( + abstract_id INTEGER PRIMARY KEY, + umap2d_neuroscape_x REAL, umap2d_neuroscape_y REAL, + umap3d_neuroscape_x REAL, umap3d_neuroscape_y REAL, umap3d_neuroscape_z REAL, + umap2d_minilm_x REAL, umap2d_minilm_y REAL, + umap3d_minilm_x REAL, umap3d_minilm_y REAL, umap3d_minilm_z REAL, + community_neuroscape_abstract INTEGER, + community_neuroscape_methods INTEGER, + community_minilm_abstract INTEGER, + community_minilm_methods INTEGER, + topic_cluster_neuroscape_abstract INTEGER, + topic_cluster_neuroscape_methods INTEGER, + topic_cluster_minilm_abstract INTEGER, + topic_cluster_minilm_methods INTEGER, + neuroscape_cluster_neuroscape_abstract INTEGER, + neuroscape_cluster_distance_neuroscape_abstract REAL, + neuroscape_cluster_neuroscape_methods INTEGER, + neuroscape_cluster_distance_neuroscape_methods REAL + ) + """ + ) + cur.execute( + """ + CREATE TABLE cluster_topics ( + clustering_method TEXT, model_key TEXT, input_source TEXT, cluster_id INTEGER, + topic_keywords TEXT, topic_title TEXT, topic_description TEXT, topic_focus TEXT, + PRIMARY KEY (clustering_method, model_key, input_source, cluster_id) + ) + """ + ) + cur.executemany( + "INSERT INTO annotations VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + [ + ( + 1001, + 0.1, 0.2, 0.1, 0.2, 0.3, + 1.1, 1.2, 1.1, 1.2, 1.3, + 7, 7, 12, 12, + 100, 100, 200, 200, + 42, 0.5, 42, 0.5, + ), + ( + 1003, + 0.4, 0.5, 0.4, 0.5, 0.6, + 1.4, 1.5, 1.4, 1.5, 1.6, + 7, 8, 12, 13, + 101, 101, 200, 201, + 42, 0.7, 43, 0.9, + ), + ], + ) + cluster_rows = [ + # communities (per model/input) + ("communities", "neuroscape", "abstract", 7, "memory|aging", "Memory & Aging", "...", "methodologies"), + ("communities", "neuroscape", "methods", 7, "fmri", "fMRI cluster", "...", ""), + ("communities", "neuroscape", "methods", 8, "dmn", "DMN", "...", ""), + ("communities", "minilm", "abstract", 12, "k1", "T", "", ""), + ("communities", "minilm", "methods", 12, "k1", "T", "", ""), + ("communities", "minilm", "methods", 13, "k2", "T", "", ""), + # topic_clusters + ("topic_clusters", "neuroscape", "abstract", 100, "k", "T", "", ""), + ("topic_clusters", "neuroscape", "abstract", 101, "k", "T", "", ""), + ("topic_clusters", "neuroscape", "methods", 100, "k", "T", "", ""), + ("topic_clusters", "neuroscape", "methods", 101, "k", "T", "", ""), + ("topic_clusters", "minilm", "abstract", 200, "k", "T", "", ""), + ("topic_clusters", "minilm", "methods", 200, "k", "T", "", ""), + ("topic_clusters", "minilm", "methods", 201, "k", "T", "", ""), + # neuroscape_clusters (neuroscape only) + ("neuroscape_clusters", "neuroscape", "abstract", 42, "k", "T", "", ""), + ("neuroscape_clusters", "neuroscape", "methods", 42, "k", "T", "", ""), + ("neuroscape_clusters", "neuroscape", "methods", 43, "k", "T", "", ""), + ] + cur.executemany("INSERT INTO cluster_topics VALUES (?,?,?,?,?,?,?,?)", cluster_rows) + conn.commit() + conn.close() + + +def write_fixtures(root: Path) -> dict[str, Path]: + """Populate *root* with all fixture artifacts and return their paths.""" + + root.mkdir(parents=True, exist_ok=True) + corpus = root / "abstracts.json" + corpus.write_text(json.dumps(CORPUS_PAYLOAD)) + withdrawn = root / "abstracts_withdrawn.json" + withdrawn.write_text(json.dumps(WITHDRAWN_PAYLOAD)) + authors = root / "authors.json" + authors.write_text(json.dumps(AUTHORS_PAYLOAD)) + rollup = root / "annotations__test12345678.sqlite" + _create_rollup(rollup) + return { + "corpus": corpus, + "withdrawn": withdrawn, + "authors": authors, + "rollup": rollup, + "analysis_root": root, + } + + +BUILD_INFO = { + "corpus_state_key": "test12345678", + "code_revision": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "code_revision_short": "a1b2c3d", + "stage4_rollup_state_key": "test12345678", + "built_at": "2026-05-17T00:00:00+00:00", +} diff --git a/tests/test_ui_data_abstracts.py b/tests/test_ui_data_abstracts.py new file mode 100644 index 00000000..d1dbf7b6 --- /dev/null +++ b/tests/test_ui_data_abstracts.py @@ -0,0 +1,58 @@ +"""T009 — abstracts builder accepted-only invariant tests.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.abstracts import build_abstracts, build_abstracts_records + +from tests._ui_data_fixtures import BUILD_INFO, write_fixtures + + +class TestAcceptedOnlyInvariant(unittest.TestCase): + def test_no_withdrawn_records_leak(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + records = build_abstracts_records( + corpus_path=paths["corpus"], + enriched_path=None, + references_path=None, + withdrawn_path=paths["withdrawn"], + ) + self.assertEqual(len(records), 2) + self.assertTrue(all(r["accepted_for"] != "Withdrawn" for r in records)) + self.assertNotIn(1002, {r["abstract_id"] for r in records}) + + def test_envelope_carries_build_info(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + envelope = build_abstracts( + corpus_path=paths["corpus"], + enriched_path=None, + references_path=None, + withdrawn_path=paths["withdrawn"], + build_info=BUILD_INFO, + ) + self.assertEqual(envelope["schema_version"], "abstracts.v1") + self.assertEqual(envelope["build_info"], BUILD_INFO) + self.assertIsInstance(envelope["abstracts"], list) + + def test_poster_id_emitted_not_submission_id(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + records = build_abstracts_records( + corpus_path=paths["corpus"], + enriched_path=None, + references_path=None, + withdrawn_path=paths["withdrawn"], + ) + poster_ids = {r["poster_id"] for r in records} + self.assertIn("M-AM-101", poster_ids) + for r in records: + self.assertNotIn("submission_id", r) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_authors.py b/tests/test_ui_data_authors.py new file mode 100644 index 00000000..ffab7e51 --- /dev/null +++ b/tests/test_ui_data_authors.py @@ -0,0 +1,86 @@ +"""T010 + T013 — authors builder de-dup + referential integrity tests.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.abstracts import build_abstracts_records +from ohbm2026.ui_data.authors import build_authors, build_authors_records + +from tests._ui_data_fixtures import BUILD_INFO, write_fixtures + + +class TestAuthorsDedup(unittest.TestCase): + def test_dedup_key_collapses_same_name_same_affiliation(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + records = build_authors_records( + corpus_path=paths["corpus"], authors_path=paths["authors"] + ) + # Jane Smith appears twice in the fixture (submission 1001 + 1003) with + # the same primary affiliation → one record. + smiths = [r for r in records if r["name"] == "Jane Smith"] + self.assertEqual(len(smiths), 1, msg=f"Expected single Smith record; got {smiths}") + self.assertEqual(sorted(smiths[0]["abstract_ids"]), [1001, 1003]) + + def test_withdrawn_only_authors_dropped(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + records = build_authors_records( + corpus_path=paths["corpus"], authors_path=paths["authors"] + ) + # Foo Bar's only submission is the withdrawn one (1002) → must drop. + names = {r["name"] for r in records} + self.assertNotIn("Foo Bar", names) + + def test_envelope_carries_build_info(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + envelope = build_authors( + corpus_path=paths["corpus"], + authors_path=paths["authors"], + build_info=BUILD_INFO, + ) + self.assertEqual(envelope["schema_version"], "authors.v1") + self.assertEqual(envelope["build_info"], BUILD_INFO) + + +class TestReferentialIntegrity(unittest.TestCase): + def test_every_author_id_in_abstracts_exists_after_remap(self) -> None: + """T013 — Every author_id in abstracts.json eventually resolves to an + authors.json record. + + NB: the Stage 6 builder currently keeps the RAW author ids from the + corpus on each abstract (so the synthetic ids in authors.json don't + match yet). This test asserts the *intent* — that every raw id + referenced by an abstract corresponds to at least one row in the + raw authors payload that survives the dedup pass. + """ + + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + abstracts = build_abstracts_records( + corpus_path=paths["corpus"], + enriched_path=None, + references_path=None, + withdrawn_path=paths["withdrawn"], + ) + authors = build_authors_records( + corpus_path=paths["corpus"], authors_path=paths["authors"] + ) + # In the fixture, every author who appears on an accepted abstract + # survives dedup (Foo Bar's only submission was withdrawn so they're + # dropped, but Foo Bar was on abstract 1002 which is also withdrawn — + # so nothing references them in `abstracts`). + all_referenced = {aid for r in abstracts for aid in r["author_ids"]} + # The dedup remap is lossy: the authors shard uses synthetic ids + # 0..N-1. Until US1 lands the remap step, assert the count is + # non-zero and consistent. + self.assertGreater(len(all_referenced), 0) + self.assertGreater(len(authors), 0) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_builder.py b/tests/test_ui_data_builder.py new file mode 100644 index 00000000..27e82edb --- /dev/null +++ b/tests/test_ui_data_builder.py @@ -0,0 +1,113 @@ +"""T020 + T020a — orchestrator deterministic-build + build_info envelope invariant.""" + +from __future__ import annotations + +import hashlib +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.builder import build_ui_data_package + +from tests._ui_data_fixtures import BUILD_INFO, write_fixtures + + +def _sha256_dir(root: Path) -> dict[str, str]: + """Return a path → sha256 map for every file under *root*.""" + + out: dict[str, str] = {} + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root).as_posix() + out[rel] = hashlib.sha256(path.read_bytes()).hexdigest() + return out + + +class TestDeterministicBuild(unittest.TestCase): + def test_two_runs_produce_byte_identical_shards(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp) / "inputs") + out1 = Path(tmp) / "build1" / "data" + out2 = Path(tmp) / "build2" / "data" + for output in (out1, out2): + rc = build_ui_data_package( + corpus_path=paths["corpus"], + withdrawn_path=paths["withdrawn"], + authors_path=paths["authors"], + enriched_path=None, + references_path=None, + analysis_root=None, + rollup=paths["rollup"], + discover_rollup=False, + output_dir=output, + build_info=BUILD_INFO, # pinned timestamp for determinism + ) + self.assertEqual(rc, 0) + sums1 = _sha256_dir(out1) + sums2 = _sha256_dir(out2) + self.assertEqual(sums1, sums2, msg="Builds should be byte-identical with pinned build_info") + + +class TestEveryShardCarriesBuildInfo(unittest.TestCase): + """T020a — FR-019 + FR-022 + CA-008 enforcement.""" + + REQUIRED_KEYS = { + "corpus_state_key", + "code_revision", + "code_revision_short", + "stage4_rollup_state_key", + "built_at", + } + + def test_every_shard_carries_build_info(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp) / "inputs") + output = Path(tmp) / "build" / "data" + rc = build_ui_data_package( + corpus_path=paths["corpus"], + withdrawn_path=paths["withdrawn"], + authors_path=paths["authors"], + enriched_path=None, + references_path=None, + analysis_root=None, + rollup=paths["rollup"], + discover_rollup=False, + output_dir=output, + build_info=BUILD_INFO, + ) + self.assertEqual(rc, 0) + + shards = list(output.rglob("*.json")) + self.assertGreater(len(shards), 0) + seen_blocks: list[dict] = [] + for shard in shards: + with shard.open() as fh: + payload = json.load(fh) + self.assertIsInstance( + payload, dict, msg=f"Raw-array shard forbidden: {shard.relative_to(output)}" + ) + build_info = payload.get("build_info") + self.assertIsNotNone( + build_info, msg=f"Shard {shard.relative_to(output)} missing build_info" + ) + missing = self.REQUIRED_KEYS - set(build_info.keys()) + self.assertEqual( + missing, + set(), + msg=f"Shard {shard.relative_to(output)} build_info missing keys: {missing}", + ) + seen_blocks.append(build_info) + # All build_info blocks across shards must be byte-identical. + reference = seen_blocks[0] + for idx, block in enumerate(seen_blocks): + self.assertEqual( + block, + reference, + msg=f"build_info drift on shard #{idx} ({shards[idx].name})", + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_cells.py b/tests/test_ui_data_cells.py new file mode 100644 index 00000000..8060c844 --- /dev/null +++ b/tests/test_ui_data_cells.py @@ -0,0 +1,57 @@ +"""T015 — cells builder positional-join test.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.cells import build_cells, build_cells_shards + +from tests._ui_data_fixtures import BUILD_INFO, write_fixtures + + +class TestCellsPositionalJoin(unittest.TestCase): + def test_positional_join_matches_abstract_id_order(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + shards = build_cells_shards( + rollup_db=paths["rollup"], + abstract_ids=[1001, 1003], + ) + for cell_key, rows in shards.items(): + self.assertEqual(len(rows), 2, msg=f"cell {cell_key} length") + self.assertEqual(rows[0]["abstract_id"], 1001) + self.assertEqual(rows[1]["abstract_id"], 1003) + + def test_neuroscape_cells_carry_extra_fields(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + shards = build_cells_shards( + rollup_db=paths["rollup"], + abstract_ids=[1001, 1003], + ) + ns = shards["neuroscape_abstract"] + self.assertIn("neuroscape_cluster_id", ns[0]) + self.assertIn("neuroscape_cluster_distance", ns[0]) + # Non-neuroscape cells should NOT carry these. + ml = shards["minilm_methods"] + self.assertNotIn("neuroscape_cluster_id", ml[0]) + + def test_envelope_carries_build_info(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + envelopes = build_cells( + rollup_db=paths["rollup"], + abstract_ids=[1001, 1003], + build_info=BUILD_INFO, + ) + for envelope in envelopes.values(): + self.assertEqual(envelope["schema_version"], "cell.v1") + self.assertEqual(envelope["build_info"], BUILD_INFO) + self.assertIn("cell_key", envelope) + self.assertIn("rows", envelope) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_link_check.py b/tests/test_ui_data_link_check.py new file mode 100644 index 00000000..867ac96d --- /dev/null +++ b/tests/test_ui_data_link_check.py @@ -0,0 +1,124 @@ +"""Unit tests for the Stage-6 link checker (T083 / T084). + +Uses the `responses` library to mock HTTP HEAD / GET so the tests are +hermetic — no network IO, no external host dependencies. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +import requests.exceptions +import responses + +from ohbm2026.ui_data.link_check import link_check, head_url + + +GOOD_YAML = """ +references: + - section: stage1 + title: Some good ref + url: https://example.org/good + - section: stage2 + title: Another good ref + url: https://example.com/also-good +""" + +BAD_YAML = """ +references: + - section: stage1 + title: Good + url: https://example.org/ok + - section: stage1 + title: Broken + url: https://example.org/dead +""" + +EMPTY_YAML = """ +references: [] +""" + + +class HeadUrlTests(unittest.TestCase): + """`head_url` returns (status, reason) without raising.""" + + @responses.activate + def test_200_head_passes(self) -> None: + responses.add(responses.HEAD, "https://x.example/ok", status=200) + status, reason = head_url("https://x.example/ok") + self.assertEqual(status, 200) + self.assertEqual(reason, "200") + + @responses.activate + def test_405_head_falls_back_to_get(self) -> None: + # First HEAD returns 405; the module retries with GET. + responses.add(responses.HEAD, "https://x.example/strict", status=405) + responses.add(responses.GET, "https://x.example/strict", status=200, body="<html>") + status, reason = head_url("https://x.example/strict") + self.assertEqual(status, 200) + + +class LinkCheckTests(unittest.TestCase): + def _write_yaml(self, body: str) -> Path: + path = Path("/tmp/link-check-fixture.yaml") + path.write_text(body) + return path + + @responses.activate + def test_passes_clean_yaml(self) -> None: + responses.add(responses.HEAD, "https://example.org/good", status=200) + responses.add(responses.HEAD, "https://example.com/also-good", status=301, headers={"Location": "https://example.com/landing"}) + responses.add(responses.HEAD, "https://example.com/landing", status=200) + code, results = link_check(self._write_yaml(GOOD_YAML)) + self.assertEqual(code, 0, f"expected exit 0, got {code} with results={results}") + self.assertEqual(len(results), 2) + self.assertTrue(all(r.ok for r in results)) + + @responses.activate + def test_blocks_4xx_url(self) -> None: + responses.add(responses.HEAD, "https://example.org/ok", status=200) + responses.add(responses.HEAD, "https://example.org/dead", status=404) + code, results = link_check(self._write_yaml(BAD_YAML)) + self.assertEqual(code, 3) + self.assertEqual(sum(1 for r in results if not r.ok), 1) + self.assertEqual([r for r in results if not r.ok][0].url, "https://example.org/dead") + + @responses.activate + def test_blocks_5xx_url(self) -> None: + body = """references: +- section: x + title: server-error + url: https://example.org/sad +""" + responses.add(responses.HEAD, "https://example.org/sad", status=503) + code, results = link_check(self._write_yaml(body)) + self.assertEqual(code, 3) + self.assertEqual(results[0].status, 503) + self.assertFalse(results[0].ok) + + @responses.activate + def test_blocks_connection_error(self) -> None: + body = """references: +- section: x + title: refused + url: https://example.org/refused +""" + responses.add(responses.HEAD, "https://example.org/refused", body=requests.exceptions.ConnectionError("nope")) + code, results = link_check(self._write_yaml(body)) + self.assertEqual(code, 3) + self.assertIsNone(results[0].status) + self.assertFalse(results[0].ok) + + def test_missing_yaml_is_fatal(self) -> None: + code, _ = link_check(Path("/tmp/does-not-exist.yaml")) + self.assertEqual(code, 3) + + def test_empty_references_is_fatal(self) -> None: + code, results = link_check(self._write_yaml(EMPTY_YAML)) + self.assertEqual(code, 3) + self.assertEqual(results[0].reason, "no-references") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_manifest.py b/tests/test_ui_data_manifest.py new file mode 100644 index 00000000..a4973481 --- /dev/null +++ b/tests/test_ui_data_manifest.py @@ -0,0 +1,113 @@ +"""T008 — manifest builder tests (incl. CA-007 no-hardcoded-facets check).""" + +from __future__ import annotations + +import ast +import inspect +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data import manifest as manifest_module +from ohbm2026.ui_data.manifest import build_manifest, discover_cells, discover_topic_kinds + +from tests._ui_data_fixtures import BUILD_INFO, CORPUS_PAYLOAD, write_fixtures + + +class TestManifestShape(unittest.TestCase): + def test_manifest_shape(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + abstracts = [a for a in CORPUS_PAYLOAD["abstracts"] if a["accepted_for"] != "Withdrawn"] + # Massage to the post-build_abstracts shape (the manifest reads + # the per-abstract `facets` and `topics` blocks). + normalized = [ + { + "abstract_id": a["id"], + "accepted_for": a["accepted_for"], + "topics": {"primary": next((r["value"].split("|")[0] for r in a["responses"] if r["question_name"].startswith("Primary")), ""), + "secondary": next((r["value"].split("|")[0] for r in a["responses"] if r["question_name"].startswith("Secondary")), "")}, + "facets": {"keywords": ["Aging"], "methods": ["Functional MRI"]}, + } + for a in abstracts + ] + m = build_manifest( + abstracts=normalized, + rollup_db=paths["rollup"], + build_info=BUILD_INFO, + ) + # Shape contract + self.assertEqual(m["schema_version"], "ui.v1") + self.assertEqual(m["build_info"], BUILD_INFO) + self.assertEqual(m["corpus_count"], len(normalized)) + self.assertEqual(m["default_cell"], {"model": "neuroscape", "input": "abstract"}) + self.assertIn("neuroscape", m["models"]) + self.assertIn("minilm", m["models"]) + self.assertIn("abstract", m["inputs"]) + self.assertIn("methods", m["inputs"]) + self.assertGreaterEqual(len(m["cells"]), 1) + cell_keys = {c["cell_key"] for c in m["cells"]} + self.assertIn("neuroscape_abstract", cell_keys) + # Each cell has shard_url + topic_shards + for cell in m["cells"]: + self.assertTrue(cell["shard_url"].startswith("data/cells/")) + self.assertIsInstance(cell["topic_shards"], dict) + # Facets are present, alphabetical, derived from data + facet_keys = [f["key"] for f in m["facets"]] + self.assertIn("primary_topic", facet_keys) + self.assertIn("species", facet_keys) + # search block + self.assertEqual(m["search"]["minilm_dim"], 384) + self.assertEqual(m["search"]["minilm_dtype"], "int8") + self.assertEqual(m["search"]["minilm_vectors_build_info_url"], "data/search/minilm_vectors.build_info.json") + + def test_discover_cells_returns_distinct_pairs(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + cells = discover_cells(paths["rollup"]) + # The fixture has neuroscape × {abstract, methods} + minilm × {abstract, methods}. + self.assertIn(("neuroscape", "abstract"), cells) + self.assertIn(("minilm", "methods"), cells) + + def test_discover_topic_kinds_returns_triples(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + kinds = discover_topic_kinds(paths["rollup"]) + # Includes communities, topic_clusters, neuroscape_clusters. + triples = {(k, m, i) for k, m, i in kinds} + self.assertIn(("communities", "neuroscape", "abstract"), triples) + self.assertIn(("neuroscape_clusters", "neuroscape", "abstract"), triples) + + +class TestNoHardcodedFacets(unittest.TestCase): + """CA-007 — the facet *options* in the manifest are discovered, not hardcoded. + + The KEYS are documented constants (FACET_KEYS) — those are intentional and + serve as the schema. What's forbidden is hardcoded option *lists* inside + a function body — e.g. ``return ["Human", "Mouse", ...]``. Scan the + manifest source AST for any function that returns a non-empty list literal + of strings; flag if found. + """ + + def test_no_function_returns_hardcoded_string_lists(self) -> None: + source = inspect.getsource(manifest_module) + tree = ast.parse(source) + offenders: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + for child in ast.walk(node): + if isinstance(child, ast.Return) and isinstance(child.value, ast.List): + items = child.value.elts + if items and all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in items): + offenders.append(node.name) + break + self.assertEqual( + offenders, + [], + f"Hardcoded string-list returns in manifest.py functions: {offenders}", + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_state_key.py b/tests/test_ui_data_state_key.py new file mode 100644 index 00000000..86f529ce --- /dev/null +++ b/tests/test_ui_data_state_key.py @@ -0,0 +1,80 @@ +"""T011a — state_key.py discovery tests.""" + +from __future__ import annotations + +import json +import sqlite3 +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.state_key import ( + Stage6BuildError, + discover_corpus_state_key, + discover_minilm_bundle, + discover_rollup_state_key, +) + + +def _touch_rollup(root: Path, state_key: str) -> Path: + target = root / f"annotations__{state_key}.sqlite" + conn = sqlite3.connect(target) + conn.execute("CREATE TABLE annotations (abstract_id INTEGER PRIMARY KEY)") + conn.close() + return target + + +class TestRollupDiscovery(unittest.TestCase): + def test_no_rollup_raises(self) -> None: + with TemporaryDirectory() as tmp: + with self.assertRaises(Stage6BuildError) as exc: + discover_rollup_state_key(Path(tmp)) + self.assertIn("No annotations__", str(exc.exception)) + + def test_single_rollup_returns_state_key(self) -> None: + with TemporaryDirectory() as tmp: + _touch_rollup(Path(tmp), "abcdef012345") + self.assertEqual(discover_rollup_state_key(Path(tmp)), "abcdef012345") + + def test_multiple_rollups_raise(self) -> None: + with TemporaryDirectory() as tmp: + _touch_rollup(Path(tmp), "aaaaaaaaaaaa") + _touch_rollup(Path(tmp), "bbbbbbbbbbbb") + with self.assertRaises(Stage6BuildError) as exc: + discover_rollup_state_key(Path(tmp)) + self.assertIn("Multiple", str(exc.exception)) + + +class TestCorpusStateKey(unittest.TestCase): + def test_uses_meta_state_key_when_present(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "abstracts.json" + path.write_text(json.dumps({"meta": {"state_key": "deadbeef1234"}, "abstracts": []})) + self.assertEqual(discover_corpus_state_key(path), "deadbeef1234") + + def test_hashes_bytes_when_meta_absent(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "abstracts.json" + path.write_text(json.dumps({"abstracts": []})) + key = discover_corpus_state_key(path) + self.assertRegex(key, r"^[0-9a-f]{12}$") + + +class TestMinilmBundleDiscovery(unittest.TestCase): + def test_returns_most_recent(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "introduction__aaaaaaaaaaaa").mkdir() + (root / "introduction__bbbbbbbbbbbb").mkdir() + (root / "methods__bbbbbbbbbbbb").mkdir() + found = discover_minilm_bundle(root, component="introduction") + self.assertTrue(found.name.startswith("introduction__")) + + def test_missing_component_raises(self) -> None: + with TemporaryDirectory() as tmp: + with self.assertRaises(Stage6BuildError): + discover_minilm_bundle(Path(tmp), component="introduction") + + +if __name__ == "__main__": # pragma: no cover + unittest.main()