diff --git a/site/src/help.ts b/site/src/help.ts
new file mode 100644
index 0000000..88086aa
--- /dev/null
+++ b/site/src/help.ts
@@ -0,0 +1,30 @@
+import { keysOf } from "./exhaustive";
+import { MAX_LIST_LIMIT } from "./limits";
+
+/** The parts of the Run section that carry a plain-language description. Each is the `data-help` of a paragraph in index.html, which the controls it describes name in `aria-describedby`. */
+export type HelpKey = "count" | "list" | "list-limit" | "diagnose" | "covering" | "strength" | "one" | "explain";
+
+/**
+ * Every description the page shows, in one place. They are short, in plain words, and the first thing a reader meets under an operation's heading or beside its field, so none depends on a tooltip, and a screen reader reads each when its control gets focus.
+ */
+export const HELP: Readonly> = {
+ count: "Counts the combinations that satisfy every rule, without listing them. It works on models far too large to list.",
+ list: "Lists valid combinations in order, the first dimension changing slowest. It stops at the number you set, so a very large model cannot hang the page.",
+ "list-limit": `The most combinations to show, from 0 to ${String(MAX_LIST_LIMIT)}. When there are more, the page says so.`,
+ diagnose: "A dead value is one that no valid combination uses, because the rules rule it out wherever it could appear. Finding them shows where a rule is stricter than you meant.",
+ covering:
+ "A covering array is a short list of valid combinations that tests every interaction you care about without listing them all. Each row is a valid combination. The result is checked against the full set of valid combinations, when the model is small enough to do that, and the page reports whether it passed.",
+ strength:
+ "How many options at a time must be covered together. 1 means every single value appears in some row. 2, pairwise and the default, means every pair of values from any two dimensions appears together in some row. 3 means every triple, and so on. Combinations the rules make impossible are not required.",
+ one: "Every valid combination has a fixed position, its index, counting from 0 in the order that List shows. Enter an index to see the combination at that position.",
+ explain: "Pick a value for each dimension to describe one combination. Explain says whether it is valid and gives its index, or names each reason it is ruled out.",
+};
+
+/** Writes each description into the paragraph made for it. Throws for a paragraph that names a description there is none of, which is a defect in the page. */
+export function fillHelp(root: ParentNode): void {
+ for (const paragraph of root.querySelectorAll("[data-help]")) {
+ const key = keysOf(HELP).find((known) => known === paragraph.dataset["help"]);
+ if (key === undefined) throw new Error(`no description for ${String(paragraph.dataset["help"])}`);
+ paragraph.textContent = HELP[key];
+ }
+}
diff --git a/site/src/help.unit.test.ts b/site/src/help.unit.test.ts
new file mode 100644
index 0000000..ef2f1ae
--- /dev/null
+++ b/site/src/help.unit.test.ts
@@ -0,0 +1,70 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+
+import indexHtml from "../index.html?raw";
+import { fillHelp, HELP } from "./help";
+import { keysOf } from "./exhaustive";
+
+/** The page as served, with the descriptions filled in as the page does when it starts. */
+function page(): Document {
+ const document = new DOMParser().parseFromString(indexHtml, "text/html");
+ fillHelp(document);
+
+ return document;
+}
+
+/** The controls of an operation that a person can focus: buttons, fields and the fieldset of pickers, but not the menus the page draws into that fieldset later. */
+function controlsOf(document: Document): HTMLElement[] {
+ return [...document.querySelectorAll(".operation button, .operation input, .operation select:not(.pickers *), .operation fieldset")];
+}
+
+describe("operation descriptions", () => {
+ it("has a non-empty paragraph on the page for every description, and a description for every paragraph", () => {
+ const document = page();
+ const paragraphs = [...document.querySelectorAll("[data-help]")];
+
+ expect(paragraphs.map((paragraph) => String(paragraph.dataset["help"])).toSorted((left, right) => left.localeCompare(right))).toEqual(keysOf(HELP).toSorted((left, right) => left.localeCompare(right)));
+ for (const paragraph of paragraphs) expect(paragraph.textContent.trim().length, `the paragraph for ${paragraph.dataset["help"] ?? ""}`).toBeGreaterThan(0);
+ });
+
+ it("names, on every control of an operation, a description that exists and is not empty", () => {
+ const document = page();
+ const controls = controlsOf(document);
+
+ expect(controls.length).toBeGreaterThan(0);
+ for (const control of controls) {
+ const names = (control.getAttribute("aria-describedby") ?? "").split(/\s+/).filter((name) => name !== "");
+ expect(names.length, `${control.id} names a description`).toBeGreaterThan(0);
+ for (const name of names) expect((document.getElementById(name)?.textContent ?? "").trim().length, `${control.id} is described by #${name}`).toBeGreaterThan(0);
+ }
+ });
+
+ it("puts each description inside the operation it describes, as a paragraph that is not hidden", () => {
+ const document = page();
+
+ for (const paragraph of document.querySelectorAll("[data-help]")) {
+ expect(paragraph.closest(".operation"), `${paragraph.id} is in an operation`).not.toBeNull();
+ expect(paragraph.tagName).toBe("P");
+ expect(paragraph.closest("[hidden]")).toBeNull();
+ }
+ });
+
+ it("explains the covering array and its strength in the plain words asked for", () => {
+ expect(HELP.covering).toContain("short list of valid combinations");
+ expect(HELP.covering).toContain("without listing them all");
+ expect(HELP.covering).toContain("checked");
+ expect(HELP.strength).toContain("How many options at a time must be covered together");
+ expect(HELP.strength).toContain("1 means every single value");
+ expect(HELP.strength).toContain("pairwise");
+ expect(HELP.strength).toContain("default");
+ expect(HELP.strength).toContain("3 means every triple");
+ expect(HELP.strength).toContain("rules make impossible are not required");
+ });
+
+ it("is written in plain sentences: no spaced double hyphen, no dash, no doubled spaces, and each ends with a full stop", () => {
+ for (const [key, text] of Object.entries(HELP)) {
+ expect(text, key).not.toMatch(/ -- |—|–| {2}/);
+ expect(text.endsWith("."), key).toBe(true);
+ }
+ });
+});
diff --git a/site/src/main.ts b/site/src/main.ts
index 8aa7b43..d47a303 100644
--- a/site/src/main.ts
+++ b/site/src/main.ts
@@ -9,6 +9,7 @@ import { unreachable } from "./exhaustive";
import type { SharedState } from "./fragment";
import { shareUrl } from "./fragment";
import { ModelForm } from "./form";
+import { fillHelp } from "./help";
import type { JsonObject } from "./json";
import { DEFAULT_LIST_LIMIT, DEFAULT_STRENGTH, MAX_LIST_LIMIT } from "./limits";
import type { DimensionSummary, Failure, Request, Response, Result, Row, Verification } from "./protocol";
@@ -570,6 +571,7 @@ async function loadFromLocation(): Promise {
}
function start(): void {
+ fillHelp(document);
for (const [position, example] of EXAMPLES.entries()) exampleSelect.append(new Option(example.name, String(position)));
listLimit.value = String(DEFAULT_LIST_LIMIT);
listLimit.max = String(MAX_LIST_LIMIT);
diff --git a/site/src/raw.d.ts b/site/src/raw.d.ts
new file mode 100644
index 0000000..192e8c8
--- /dev/null
+++ b/site/src/raw.d.ts
@@ -0,0 +1,6 @@
+/** Vite's `?raw` import of a file as its text, which the help test uses to read index.html. */
+declare module "*?raw" {
+ const text: string;
+
+ export default text;
+}
diff --git a/site/src/style.css b/site/src/style.css
index 30426c6..0daf563 100644
--- a/site/src/style.css
+++ b/site/src/style.css
@@ -548,3 +548,8 @@ fieldset legend {
.entry > * {
min-width: 0;
}
+
+.operation .hint {
+ margin: 4px 0 8px;
+ font-size: 0.9rem;
+}