diff --git a/apps/web/app/[locale]/benchmark/page.tsx b/apps/web/app/[locale]/benchmark/page.tsx
index 5c33b92..b7f4aa9 100644
--- a/apps/web/app/[locale]/benchmark/page.tsx
+++ b/apps/web/app/[locale]/benchmark/page.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Skeleton } from "@/components/Skeleton";
+import { useUrlNumber, useUrlParam } from "@/lib/useUrlParam";
import {
getBenchmarkEmbeddings,
getBenchmarkMatrix,
@@ -143,9 +144,12 @@ export default function BenchmarkPage() {
const [embeddings, setEmbeddings] = useState
|
{/* Desktop table */}
-
-
+
+ {t("configsTab.tableHeaders.description")}
{t("configsTab.tableHeaders.model")}
{t("configsTab.tableHeaders.backend")}
diff --git a/apps/web/app/[locale]/jobs/page.tsx b/apps/web/app/[locale]/jobs/page.tsx
index 14d6d93..876faee 100644
--- a/apps/web/app/[locale]/jobs/page.tsx
+++ b/apps/web/app/[locale]/jobs/page.tsx
@@ -7,6 +7,7 @@ import { StatusBadge } from "@/components/StatusBadge";
import { SkeletonTableRow } from "@/components/Skeleton";
import { useToast } from "@/components/Toast";
import { useTranslations } from "next-intl";
+import { useUrlParam } from "@/lib/useUrlParam";
const STATUS_OPTIONS = ["", "queued", "running", "succeeded", "failed", "cancelled"];
@@ -51,7 +52,9 @@ export default function JobsPage() {
const toast = useToast();
const [jobs, setJobs] = useState
-
+
+ {t("browseTab.tableHeaders.accession")}
{t("browseTab.tableHeaders.entryName")}
{t("browseTab.tableHeaders.gene")}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 0bdeefe..31050d3 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -96,6 +96,34 @@ h1, h2, h3, h4 {
color: transparent;
}
+/* Sticky table header — sits below the sticky h-16 chrome header.
+ Apply on the of any long data table. */
+.protea-thead-sticky {
+ position: sticky;
+ top: 4rem; /* 64px = h-16 */
+ z-index: 10;
+ background: white;
+ box-shadow: 0 1px 0 rgba(15, 23, 42, 0.08);
+}
+
+/* Scroll-shadow on horizontally scrollable wrappers. The trick is
+ four stacked layers in `background`:
+ 1+2: solid white "covers" anchored to the start/end (background-attachment: local)
+ so they hide as the user scrolls into the content.
+ 3+4: faint shadows anchored to the scroll container (background-attachment: scroll)
+ revealed once the white covers move out of the way.
+ Apply on a ``. */
+.protea-scroll-shadow {
+ background-image:
+ linear-gradient(to right, white 30%, rgba(255, 255, 255, 0)),
+ linear-gradient(to right, rgba(255, 255, 255, 0), white 70%) 100% 0,
+ radial-gradient(farthest-side at 0 50%, rgba(15, 23, 42, 0.12), rgba(0, 0, 0, 0)),
+ radial-gradient(farthest-side at 100% 50%, rgba(15, 23, 42, 0.12), rgba(0, 0, 0, 0)) 100% 0;
+ background-repeat: no-repeat;
+ background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%;
+ background-attachment: local, local, scroll, scroll;
+}
+
.protea-grid-bg {
background-image:
radial-gradient(circle at 1px 1px, rgba(15, 23, 42, 0.06) 1px, transparent 0);
diff --git a/apps/web/lib/useUrlParam.ts b/apps/web/lib/useUrlParam.ts
new file mode 100644
index 0000000..6952196
--- /dev/null
+++ b/apps/web/lib/useUrlParam.ts
@@ -0,0 +1,67 @@
+"use client";
+
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useMemo } from "react";
+
+/**
+ * Two-way binding between a single query-string key and a piece of UI
+ * state. Refreshing the page or copying the URL preserves the filter,
+ * which is the whole point — without this, every chip / dropdown
+ * forgets state on reload.
+ *
+ * Returns ``[value, setValue]`` where ``setValue(null)`` removes the
+ * key entirely (cleanest URL when the filter is at its default).
+ *
+ * Uses ``router.replace`` + ``scroll: false`` so changing a chip
+ * doesn't scroll the page or pollute the back-button stack.
+ */
+export function useUrlParam(
+ key: string,
+ defaultValue: string | null = null,
+): readonly [string | null, (next: string | null) => void] {
+ const router = useRouter();
+ const pathname = usePathname();
+ const params = useSearchParams();
+
+ const value = useMemo(() => {
+ const v = params.get(key);
+ return v == null ? defaultValue : v;
+ }, [params, key, defaultValue]);
+
+ const setValue = useCallback(
+ (next: string | null) => {
+ const merged = new URLSearchParams(params.toString());
+ if (next == null || next === "" || next === defaultValue) {
+ merged.delete(key);
+ } else {
+ merged.set(key, next);
+ }
+ const qs = merged.toString();
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
+ },
+ [router, pathname, params, key, defaultValue],
+ );
+
+ return [value, setValue] as const;
+}
+
+/** Convenience: number-typed variant. Returns null when missing or invalid. */
+export function useUrlNumber(
+ key: string,
+ defaultValue: number | null = null,
+): readonly [number | null, (next: number | null) => void] {
+ const [raw, setRaw] = useUrlParam(
+ key,
+ defaultValue == null ? null : String(defaultValue),
+ );
+ const value = useMemo(() => {
+ if (raw == null) return null;
+ const n = Number(raw);
+ return Number.isFinite(n) ? n : null;
+ }, [raw]);
+ const setValue = useCallback(
+ (next: number | null) => setRaw(next == null ? null : String(next)),
+ [setRaw],
+ );
+ return [value, setValue] as const;
+}
|
|---|