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 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; 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. 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 The page posts the run to `swarm-controller` (`POST
/api/review-classifier/try-out`), which reads the PR with its own forge token /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 { ClassificationEditor } from "./ClassificationEditor.js";
import { TryOutResult } from "./TryOutResult.js"; import { TryOutResult } from "./TryOutResult.js";
import { import {
addProblem,
draftFromRule, draftFromRule,
hasTypedText, hasTypedText,
prNumber, prNumber,
@ -57,10 +58,9 @@ const PULLS = "/api/pulls";
// does not ask for the PRs of each prefix of it. // does not ask for the PRs of each prefix of it.
const PICK_DELAY_MS = 300; const PICK_DELAY_MS = 300;
const POLL_MS = 2000; const POLL_MS = 2000;
// More classifications than this are not offered a row. It is the try-out // Ids the buttons' `aria-describedby` points at.
// endpoint's own cap, which answers a longer list with a 400 that the page const HINT_ID = "tryout-hint";
// shows as it is. const CAP_ID = "tryout-cap";
const MAX_CLASSIFICATIONS = 10;
let nextKey = 0; let nextKey = 0;
function blankDraft(): ClassificationDraft { function blankDraft(): ClassificationDraft {
@ -221,6 +221,7 @@ export function ReviewTryOut() {
const openPrProblem = rowsProblem(repo, drafts); const openPrProblem = rowsProblem(repo, drafts);
const problem = runProblem(repo, pr, drafts); const problem = runProblem(repo, pr, drafts);
const capProblem = addProblem(drafts.length);
async function run(e: Event) { async function run(e: Event) {
e.preventDefault(); e.preventDefault();
@ -398,7 +399,8 @@ export function ReviewTryOut() {
<div class="ui-tryout-actions"> <div class="ui-tryout-actions">
<Button <Button
onClick={() => setDrafts((ds) => [...ds, blankDraft()])} onClick={() => setDrafts((ds) => [...ds, blankDraft()])}
disabled={drafts.length >= MAX_CLASSIFICATIONS} disabled={capProblem !== null}
describedBy={capProblem !== null ? CAP_ID : undefined}
> >
add a classification add a classification
</Button> </Button>
@ -406,17 +408,28 @@ export function ReviewTryOut() {
variant="primary" variant="primary"
type="submit" type="submit"
disabled={running || problem !== null} disabled={running || problem !== null}
describedBy={problem !== null ? HINT_ID : undefined}
> >
{running ? "running…" : "run"} {running ? "running…" : "run"}
</Button> </Button>
<Button <Button
onClick={() => void openPr()} onClick={() => void openPr()}
disabled={opening || openPrProblem !== null} disabled={opening || openPrProblem !== null}
describedBy={openPrProblem !== null ? HINT_ID : undefined}
> >
{opening ? "pushing…" : "open a PR"} {opening ? "pushing…" : "open a PR"}
</Button> </Button>
</div> </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 ? ( {openError ? (
<ApiErrorPanel <ApiErrorPanel
context="the branch for the PR could not be pushed" context="the branch for the PR could not be pushed"
@ -450,10 +463,13 @@ export function ReviewTryOut() {
{running || result || error ? ( {running || result || error ? (
<Panel title="result" icon="📋"> <Panel title="result" icon="📋">
{running ? ( {running ? (
<>
<p class="ui-tryout-running" role="status"> <p class="ui-tryout-running" role="status">
Running — a run takes a minute or more, and up to half an hour for Running — a run takes a minute or more, and up to half an hour
a long list ({elapsed}s so far). for a long list.
</p> </p>
<p class="ui-tryout-running">{elapsed}s so far</p>
</>
) : null} ) : null}
{error ? ( {error ? (
<ApiErrorPanel context="the try-out failed" problem={error} /> <ApiErrorPanel context="the try-out failed" problem={error} />

View file

@ -150,6 +150,17 @@ export function duplicateName(drafts: ClassificationDraft[]): string | null {
return 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 /** 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. */ * each row, then a shared name. Unlike a run, it needs no PR number. */
export function rowsProblem( export function rowsProblem(

View file

@ -14,12 +14,15 @@ export function Button({
type = "button", type = "button",
disabled, disabled,
onClick, onClick,
describedBy,
children, children,
}: { }: {
variant?: ButtonVariant; variant?: ButtonVariant;
type?: "button" | "submit"; type?: "button" | "submit";
disabled?: boolean; disabled?: boolean;
onClick?: (e: Event) => void; onClick?: (e: Event) => void;
/** Id of the element that says why the button is disabled. */
describedBy?: string;
children: ComponentChildren; children: ComponentChildren;
}) { }) {
return ( return (
@ -28,6 +31,7 @@ export function Button({
type={type} type={type}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
aria-describedby={describedBy}
> >
{children} {children}
</button> </button>

View file

@ -5,6 +5,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
MAX_CLASSIFICATIONS,
addProblem,
draftFromRule, draftFromRule,
draftProblem, draftProblem,
duplicateName, duplicateName,
@ -17,6 +19,12 @@ import {
toBody, toBody,
} from "../src/pages/review-tryout/TryOutTypes.ts"; } 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. // A row ready to send, with `over` replacing any of its fields.
function row(over = {}) { function row(over = {}) {
return { return {