Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui try-out: stable live region, reasons for disabled buttons

The running line is a live region whose text changed every second, so a
screen reader announced the counter again each time. The sentence is now
stable and the elapsed seconds sit outside it.

The run and open-a-PR buttons point at the hint that says why they are
off, and add-a-classification says why it is off at the cap.
This commit is contained in:
iris 2026-10-21 21:30:01 +02:00 • committed by mara
commit e03bd4caa4
5 changed files with 53 additions and 11 deletions

View file

@ -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

View file

@ -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() {
<div class="ui-tryout-actions">
<Button
onClick={() => setDrafts((ds) => [...ds, blankDraft()])}
disabled={drafts.length >= MAX_CLASSIFICATIONS}
disabled={capProblem !== null}
describedBy={capProblem !== null ? CAP_ID : undefined}
>
add a classification
</Button>
@ -406,17 +408,28 @@ export function ReviewTryOut() {
variant="primary"
type="submit"
disabled={running || problem !== null}
describedBy={problem !== null ? HINT_ID : undefined}
>
{running ? "running…" : "run"}
</Button>
<Button
onClick={() => void openPr()}
disabled={opening || openPrProblem !== null}
describedBy={openPrProblem !== null ? HINT_ID : undefined}
>
{opening ? "pushing…" : "open a PR"}
</Button>
</div>
{problem !== null ? <p class="ui-tryout-hint">{problem}</p> : null}
{capProblem !== null ? (
<p id={CAP_ID} class="ui-tryout-hint">
{capProblem}
</p>
) : null}
{problem !== null ? (
<p id={HINT_ID} class="ui-tryout-hint">
{problem}
</p>
) : null}
{openError ? (
<ApiErrorPanel
context="the branch for the PR could not be pushed"
@ -450,10 +463,13 @@ export function ReviewTryOut() {
{running || result || error ? (
<Panel title="result" icon="📋">
{running ? (
<>
<p class="ui-tryout-running" role="status">
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.
</p>
<p class="ui-tryout-running">{elapsed}s so far</p>
</>
) : null}
{error ? (
<ApiErrorPanel context="the try-out failed" problem={error} />

View file

@ -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(

View file

@ -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}
</button>

View file

@ -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 {