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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions evalboard/app/_components/__tests__/harness-selector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,57 @@ describe("HarnessSelector", () => {
).toHaveAttribute("aria-pressed", "true");
});

test("a known harness absent from the discovery window still shows", () => {
// Delegate runs weekly, so it falls out of the 12-run discovery window
// between firings. The segment must survive that, or the filter reads as
// "delegate was removed" rather than "delegate hasn't run lately".
render(
<HarnessSelector
current={null}
harnesses={["claude-code"]}
includeAll
/>,
);
for (const h of KNOWN_HARNESSES) {
expect(
screen.getByRole("button", { name: harnessShortLabel(h) }),
).toBeInTheDocument();
}
});

test("a discovered newcomer is kept, and sorts after the known set", () => {
render(
<HarnessSelector
current={null}
harnesses={["zzz-new-harness"]}
includeAll
/>,
);
const names = screen
.getAllByRole("button")
.map((b) => b.getAttribute("aria-label"));
expect(names).toContain(harnessShortLabel("zzz-new-harness"));
// Known harnesses hold display order; the newcomer lands last.
expect(names[names.length - 1]).toBe(
harnessShortLabel("zzz-new-harness"),
);
});

test("no segment is duplicated when discovery and the known set overlap", () => {
render(
<HarnessSelector
current="codex"
harnesses={[...KNOWN_HARNESSES]}
includeAll
/>,
);
const labels = screen
.getAllByRole("button")
.map((b) => b.getAttribute("aria-label"))
.filter((n): n is string => n != null);
expect(new Set(labels).size).toBe(labels.length);
});

test("every segment is named for screen readers, not color-only", () => {
render(
<HarnessSelector
Expand Down
97 changes: 97 additions & 0 deletions evalboard/app/_components/__tests__/skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, test } from "vitest";
import { render } from "@testing-library/react";
import RootLoading from "@/app/loading";
import RunLoading from "@/app/runs/[id]/loading";
import TaskLoading from "@/app/runs/[id]/[...task]/loading";

const CASES = [
["root", RootLoading, "Loading page"],
["run", RunLoading, "Loading run"],
["task", TaskLoading, "Loading task"],
] as const;

// Every class on every rendered node, flattened into tokens.
function classTokens(root: HTMLElement): string[] {
const out: string[] = [];
for (const el of root.querySelectorAll<HTMLElement>("*")) {
out.push(...el.className.split(/\s+/).filter(Boolean));
}
return out;
}

// Tailwind's numeric width scale is quarter-rem, so `w-24` is 96px. Arbitrary
// values carry their own unit.
function fixedWidthPx(token: string): number | null {
const scale = /^(?:min-)?w-(\d+(?:\.\d+)?)$/.exec(token);
if (scale) return Number(scale[1]) * 4;
const arb = /^(?:min-)?w-\[(\d+(?:\.\d+)?)px\]$/.exec(token);
if (arb) return Number(arb[1]);
return null;
}

// The narrowest phone the rest of the app is built to survive. A skeleton that
// overflows it would introduce a horizontal scrollbar that disappears the
// moment the real content arrives.
const MIN_VIEWPORT_PX = 320;
// Room for the page gutters (`px-4` each side in the root layout).
const CONTENT_BUDGET_PX = MIN_VIEWPORT_PX - 32;

describe.each(CASES)("%s loading skeleton", (_name, Loading, label) => {
test("announces itself to a screen reader", () => {
const { getByRole } = render(<Loading />);
const status = getByRole("status");
expect(status).toHaveAttribute("aria-busy", "true");
expect(status).toHaveAttribute("aria-label", label);
// The blocks are decorative, so the label has to carry the meaning.
expect(status.textContent).toContain(label);
});

test("the pulse is dropped under prefers-reduced-motion", () => {
const { getByRole } = render(<Loading />);
const cls = getByRole("status").className;
expect(cls).toContain("animate-pulse");
expect(cls).toContain("motion-reduce:animate-none");
});

test("no fixed width can overflow a 320px phone", () => {
const { container } = render(<Loading />);
const offenders = classTokens(container)
.map((t) => [t, fixedWidthPx(t)] as const)
.filter(([, px]) => px != null && px > CONTENT_BUDGET_PX);
expect(offenders.map(([t]) => t)).toEqual([]);
});

test("every wide block is capped rather than left to run", () => {
const { container } = render(<Loading />);
// `w-full` on its own is fine (it fills the parent); paired with a
// `max-w-*` it is also fine. What must not appear is a fixed width
// wider than the phone, which the test above covers, or a horizontal
// overflow container, which would scroll instead of wrap.
expect(classTokens(container)).not.toContain("overflow-x-auto");
});

test("the skeleton is small enough to be free", () => {
const { container } = render(<Loading />);
// The pages these stand in for render thousands of nodes; the point of
// the skeleton is to appear instantly, so it must stay tiny.
expect(container.querySelectorAll("*").length).toBeLessThan(200);
});
});

describe("run skeleton", () => {
test("mirrors the grid's own md breakpoint instead of picking one layout", () => {
// task-grid renders a table at md and up and a card list below it. If
// the skeleton showed only one, a phone (or a desktop) would watch the
// layout jump the moment the real content landed.
const { container } = render(<RunLoading />);
expect(container.querySelector(".hidden.md\\:block")).not.toBeNull();
expect(container.querySelector(".md\\:hidden")).not.toBeNull();
});

test("the stat tiles match the real two-column phone grid", () => {
const { container } = render(<RunLoading />);
const grid = container.querySelector(".grid");
expect(grid?.className).toContain("grid-cols-2");
expect(grid?.className).toContain("md:grid-cols-5");
});
});
19 changes: 13 additions & 6 deletions evalboard/app/_components/harness-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { KNOWN_HARNESSES, orderHarnesses } from "@/lib/harness";
import { HarnessBadge, harnessShortLabel } from "./harness-badge";

// Segmented control for a page's harness scope. Sets `?h=<harness>` while
Expand Down Expand Up @@ -41,12 +42,18 @@ export function HarnessSelector({
const qs = p.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
};
// Always show the active harness, even if it has aged out of the recent
// window (so a deep-linked `?h=` still reads as selected rather than absent).
const opts =
current == null || harnesses.includes(current)
? harnesses
: [current, ...harnesses];
// Every known harness gets a segment whether or not it turned up in the
// discovery window. A weekly harness (delegate) drops out of that window
// between firings, and a control that quietly loses an option reads as
// "removed" rather than "hasn't run lately"; an empty result is honest and
// one click from recoverable. `current` is unioned in so a deep-linked `?h=`
// outside the known set still reads as selected, and orderHarnesses fixes
// the order so segments never reshuffle.
const opts = orderHarnesses([
...KNOWN_HARNESSES,
...harnesses,
...(current == null ? [] : [current]),
]);
// shrink-0 + whitespace-nowrap: a segment must keep its label on one line
// and never compress, so on a narrow screen the control scrolls (see the
// wrapper) instead of wrapping "Claude Code" onto two lines and stretching
Expand Down
171 changes: 171 additions & 0 deletions evalboard/app/_components/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Shared skeleton primitives for the route-level `loading.tsx` files.
//
// These exist because every heavy route here is force-dynamic: on navigation
// Next has to run the server component before it can render anything, and with
// no Suspense boundary the browser sits on the PREVIOUS page for the whole of
// it. Measured warm, that is 1.4-1.6s on the front page and 1.7-2.2s on a
// 1,296-task run, with no indication the click registered.
//
// Blocks are sized to the element they stand in for and mirror the same
// breakpoints, so the page doesn't jump when the content lands. Nothing here is
// pixel-exact; it only has to read as "this shape, still loading".
//
// Widths are fractional or capped so a phone never scrolls sideways, and the
// pulse is on the wrapper rather than each block: one animated element instead
// of dozens, all in phase. `motion-reduce` drops it entirely.

// One grey block. `className` carries the size, so callers read as a layout.
export function SkelBar({ className = "" }: { className?: string }) {
return <div className={`rounded bg-gray-100 ${className}`} />;
}

// A bordered card matching the stat tiles and panels: same border, radius and
// padding, so the skeleton occupies the height the real tile will.
export function SkelTile({
className = "",
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
return (
<div
className={`bg-white border border-gray-200 rounded-lg p-4 ${className}`}
>
{children ?? (
<div className="space-y-2">
<SkelBar className="h-3 w-1/2" />
<SkelBar className="h-6 w-2/3" />
</div>
)}
</div>
);
}

// A run of chip-shaped blocks, for the filter rails. Widths vary so it reads as
// text of different lengths rather than a progress bar. `flex-wrap` keeps it on
// screen at any width.
export function SkelChips({ count = 8 }: { count?: number }) {
// Deterministic, so the server and client markup agree during hydration.
const widths = ["w-16", "w-24", "w-20", "w-28", "w-14", "w-24", "w-20", "w-32"];
return (
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: count }, (_, i) => (
<SkelBar
key={i}
className={`h-5 ${widths[i % widths.length]}`}
/>
))}
</div>
);
}

// Table stand-in for the md-and-up layout: a header strip plus n body rows,
// inside the same bordered, clipped box the real tables use.
export function SkelTable({
rows = 8,
className = "",
}: {
rows?: number;
className?: string;
}) {
return (
<div
className={`border border-gray-200 rounded-lg bg-white overflow-hidden ${className}`}
>
<div className="bg-gray-50 border-b border-gray-200 px-4 py-2.5">
<SkelBar className="h-3 w-1/3" />
</div>
<div className="divide-y divide-gray-100">
{Array.from({ length: rows }, (_, i) => (
<div
key={i}
className="px-4 py-3 flex items-center gap-3"
>
<SkelBar className="h-3.5 flex-1" />
<SkelBar className="hidden sm:block h-3.5 w-16 shrink-0" />
<SkelBar className="h-3.5 w-10 shrink-0" />
</div>
))}
</div>
</div>
);
}

// Card stand-in for the below-md layout the grid switches to on a phone.
export function SkelCards({
count = 5,
className = "",
}: {
count?: number;
className?: string;
}) {
return (
<div className={`space-y-2 ${className}`}>
{Array.from({ length: count }, (_, i) => (
<div
key={i}
className="border border-gray-200 rounded-lg bg-white p-3 space-y-2.5"
>
<SkelBar className="h-4 w-3/4" />
<div className="grid grid-cols-3 gap-2">
<SkelBar className="h-3" />
<SkelBar className="h-3" />
<SkelBar className="h-3" />
</div>
</div>
))}
</div>
);
}

// The body of a run page: stat tiles, filter rail, then the task listing. Used
// twice, which is why it lives here rather than in a loading.tsx: once as the
// route fallback (below a skeleton header) and once as the fallback for the
// Suspense boundary the run page wraps its grid in, where the real header has
// already streamed and only this hole is left to fill.
//
// The tile row and the listing mirror the real breakpoints exactly: two tile
// columns on a phone with the pass rate spanning both, five from md; a table at
// md and up, cards below it. Matching them is the point, so the layout doesn't
// move when the content lands.
export function RunBodySkeleton() {
return (
<div className="space-y-5">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<SkelTile className="col-span-2" />
<SkelTile />
<SkelTile />
<SkelTile />
</div>
<div className="space-y-2">
<SkelBar className="h-3 w-24" />
<SkelChips count={8} />
</div>
<SkelTable className="hidden md:block" rows={10} />
<SkelCards className="md:hidden" count={6} />
</div>
);
}

// Wrapper every loading.tsx returns. Carries the pulse, and announces itself
// once to a screen reader instead of leaving the blocks as unlabelled noise.
export function SkeletonPage({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div
role="status"
aria-busy="true"
aria-label={label}
className="animate-pulse motion-reduce:animate-none"
>
<span className="sr-only">{label}</span>
{children}
</div>
);
}
Loading
Loading