diff --git a/docs/swarm/review-classifier.md b/docs/swarm/review-classifier.md
index edb83bea..d7de8f52 100644
--- a/docs/swarm/review-classifier.md
+++ b/docs/swarm/review-classifier.md
@@ -483,7 +483,10 @@ rows as the review table.
A page for writing classification prompts without touching the repo. Pick a
repo and a PR, write or edit a classification, and run it against that PR;
change the prompt and run it again until it answers the way you want.
-Nothing reaches the forge while you iterate.
+Nothing reaches the forge while you iterate. A button the page disables
+points at a line that says why, such as the 10-classification cap on **add a
+classification**, and a screen reader announces the run's progress once, with
+the elapsed seconds outside the announcement.
The page posts the run to `swarm-controller` (`POST
/api/review-classifier/try-out`), which reads the PR with its own forge token
diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx
index 2445ee00..ec5425f6 100644
--- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx
+++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx
@@ -33,6 +33,7 @@ import { TextField } from "../../ui/text-field/TextField.js";
import { ClassificationEditor } from "./ClassificationEditor.js";
import { TryOutResult } from "./TryOutResult.js";
import {
+ addProblem,
draftFromRule,
hasTypedText,
prNumber,
@@ -57,10 +58,9 @@ const PULLS = "/api/pulls";
// does not ask for the PRs of each prefix of it.
const PICK_DELAY_MS = 300;
const POLL_MS = 2000;
-// More classifications than this are not offered a row. It is the try-out
-// endpoint's own cap, which answers a longer list with a 400 that the page
-// shows as it is.
-const MAX_CLASSIFICATIONS = 10;
+// Ids the buttons' `aria-describedby` points at.
+const HINT_ID = "tryout-hint";
+const CAP_ID = "tryout-cap";
let nextKey = 0;
function blankDraft(): ClassificationDraft {
@@ -221,6 +221,7 @@ export function ReviewTryOut() {
const openPrProblem = rowsProblem(repo, drafts);
const problem = runProblem(repo, pr, drafts);
+ const capProblem = addProblem(drafts.length);
async function run(e: Event) {
e.preventDefault();
@@ -398,7 +399,8 @@ export function ReviewTryOut() {
- Running — a run takes a minute or more, and up to half an hour for
- a long list ({elapsed}s so far).
-
+ <>
+
+ Running — a run takes a minute or more, and up to half an hour
+ for a long list.
+
+
{elapsed}s so far
+ >
) : null}
{error ? (
diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutTypes.ts b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutTypes.ts
index 7143ac3c..2ade456b 100644
--- a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutTypes.ts
+++ b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutTypes.ts
@@ -150,6 +150,17 @@ export function duplicateName(drafts: ClassificationDraft[]): string | null {
return null;
}
+/** More classifications than this are not offered a row. It is the try-out
+ * endpoint's own cap, which answers a longer list with a 400. */
+export const MAX_CLASSIFICATIONS = 10;
+
+/** Why another row cannot be added, or null while there is room. */
+export function addProblem(count: number): string | null {
+ return count >= MAX_CLASSIFICATIONS
+ ? `a run takes at most ${MAX_CLASSIFICATIONS} classifications`
+ : null;
+}
+
/** What keeps the rows from going to the repo as a PR, or null: the repo, then
* each row, then a shared name. Unlike a run, it needs no PR number. */
export function rowsProblem(
diff --git a/frontend/packages/swarm-ui/src/ui/button/Button.tsx b/frontend/packages/swarm-ui/src/ui/button/Button.tsx
index 006f4c7d..cdf77e4b 100644
--- a/frontend/packages/swarm-ui/src/ui/button/Button.tsx
+++ b/frontend/packages/swarm-ui/src/ui/button/Button.tsx
@@ -14,12 +14,15 @@ export function Button({
type = "button",
disabled,
onClick,
+ describedBy,
children,
}: {
variant?: ButtonVariant;
type?: "button" | "submit";
disabled?: boolean;
onClick?: (e: Event) => void;
+ /** Id of the element that says why the button is disabled. */
+ describedBy?: string;
children: ComponentChildren;
}) {
return (
@@ -28,6 +31,7 @@ export function Button({
type={type}
disabled={disabled}
onClick={onClick}
+ aria-describedby={describedBy}
>
{children}
diff --git a/frontend/packages/swarm-ui/test/tryout-types.test.mjs b/frontend/packages/swarm-ui/test/tryout-types.test.mjs
index c64d036f..e90eda79 100644
--- a/frontend/packages/swarm-ui/test/tryout-types.test.mjs
+++ b/frontend/packages/swarm-ui/test/tryout-types.test.mjs
@@ -5,6 +5,8 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
+ MAX_CLASSIFICATIONS,
+ addProblem,
draftFromRule,
draftProblem,
duplicateName,
@@ -17,6 +19,12 @@ import {
toBody,
} from "../src/pages/review-tryout/TryOutTypes.ts";
+test("a row is offered until the endpoint's cap is reached", () => {
+ assert.equal(addProblem(1), null);
+ assert.equal(addProblem(MAX_CLASSIFICATIONS - 1), null);
+ assert.match(addProblem(MAX_CLASSIFICATIONS), /at most 10 classifications/);
+});
+
// A row ready to send, with `over` replacing any of its fields.
function row(over = {}) {
return {