diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf19c5f77..341da661b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -491,7 +491,9 @@ measurement service) so `tepp_not_available` is not mistaken for a calibrated negative result. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report. A pending TEPP row -does not claim a calibrated measurement. A pending lineage row +does not claim a calibrated measurement and does not say +reconstruction. The list button accessible name includes the +next-action sentence; detail repeats it (ADR 0014). A pending lineage row says reconstruction has not started yet. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, diff --git a/CHANGELOG.d/unreleased-audible-pending-next-actions.md b/CHANGELOG.d/unreleased-audible-pending-next-actions.md new file mode 100644 index 000000000..d5670e860 --- /dev/null +++ b/CHANGELOG.d/unreleased-audible-pending-next-actions.md @@ -0,0 +1,3 @@ +Pending TEPP next-action copy does not say reconstruction. List +accessible names include the next-action sentence. Detail repeats it +(ADR 0014). This fragment does not take v0.86.3 or ADR 0021. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a19fe92..cf67ecca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Pending analysis-run next actions stay kind-specific, and the list + button accessible name includes that sentence (WCAG 2.2 SC 4.1.2). + A pending TEPP row tells the operator measurement has not started — + it does not say reconstruction. Open the row: detail repeats it + (ADR 0014). This slice does not take v0.86.3 or ADR 0021. + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f87..2a7ad2f3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,10 @@ theta or a local psychometric substitute. The home list caption stays (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not mention TEPP. A failed period-report row rebuilds the report. A -pending TEPP row does not claim a calibrated measurement. A pending -lineage row says reconstruction has not started yet. +pending TEPP row does not claim a calibrated measurement and does +not say reconstruction. The list button name includes the +next-action sentence. A pending lineage row says reconstruction has +not started yet. Digest prefixes stay audible; hover a prefix to read the full digest. Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 500c2bc2a..72db05249 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -48,8 +48,11 @@ service. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report from a current snapshot. A pending or running TEPP row must not claim a calibrated -measurement. A pending lineage row says reconstruction has not -started yet. The detail now shows the legal +measurement and must not say reconstruction. The list +button accessible name is `Open analysis run: {caption}. {nextAction}` +when a next action exists (WCAG 2.2 SC 4.1.2); otherwise the caption +alone. Detail repeats that sentence. A pending +lineage row says reconstruction has not started yet. The detail now shows the legal lifecycle the registry already stored. `POST /api/analysis-runs` now records a Pending run on an authorized cutoff capture (ADR 0017). Reconstruction, a live TEPP transport, and a fuller Analysis Run @@ -65,3 +68,11 @@ Educational Research Association. Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/2013/REC-prov-o-20130430/ + +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). +https://www.w3.org/TR/accname-1.1/ + +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). +https://www.w3.org/TR/WCAG22/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index c776053b1..af2b0531f 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -9,7 +9,7 @@ |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | | W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Opening a listed title warns that the live body may have changed after that cutoff. | -| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | +| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. List-button names include the kind-specific next-action sentence (ADR 0014; WCAG 2.2 SC 4.1.2). | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | | NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, `invoking_session_role` on each retention event, and exclusion of raw source/provider payloads. | @@ -117,5 +117,8 @@ Recommendation). https://www.w3.org/TR/prov-o/ World Wide Web Consortium. (2018). *Accessible name and description computation 1.1* (W3C Recommendation). https://www.w3.org/TR/accname-1.1/ +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fd8a15146..36a5eb9da 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -61,8 +61,11 @@ describe("App, authenticated", () => { verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; failedReportRun?: boolean; + pendingReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + runningTeppRun?: boolean; + cancelledTeppRun?: boolean; postBody?: string; }) { const statusLabel: Record = { @@ -176,6 +179,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-report")) { + const reportPending = Boolean(options?.pendingReportRun); return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-report", @@ -184,8 +188,8 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: reportPending ? "analysis_status_pending" : "analysis_status_failed", + status_label: reportPending ? "Pending" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -196,21 +200,30 @@ describe("App, authenticated", () => { }, ], visible_posts: [], - status_history: [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:39:00Z", - }, - { - status_ordinal: 2, - status_code: "analysis_status_failed", - status_label: "Failed", - occurred_at: "2026-01-12T12:40:00Z", - failure_code: "period_report_rebuild_failed", - }, - ], + status_history: reportPending + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:40:00Z", + failure_code: "period_report_rebuild_failed", + }, + ], }), ); } @@ -219,66 +232,101 @@ describe("App, authenticated", () => { ? "analysis_status_succeeded" : options?.pendingTeppRun ? "analysis_status_pending" - : "analysis_status_failed"; + : options?.runningTeppRun + ? "analysis_status_running" + : options?.cancelledTeppRun + ? "analysis_status_cancelled" + : "analysis_status_failed"; const teppLabel = options?.succeededTeppRun ? "Succeeded" : options?.pendingTeppRun ? "Pending" - : "Failed"; - return Promise.resolve( - jsonResponse({ - analysis_run_id: "run-demo-tepp", - run_kind_code: "analysis_run_tepp", - run_kind_label: "TEPP measurement", - scope_kind_code: "analysis_scope_corporate_entity", - scope_kind_label: "Corporate entity", - scope_entity_name: "Demo Corp", - status_code: teppStatus, - status_label: teppLabel, - knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:34:00Z", - source_counts: [ + : options?.runningTeppRun + ? "Running" + : options?.cancelledTeppRun + ? "Cancelled" + : "Failed"; + const teppHistory = options?.pendingTeppRun + ? [ { - count_type_code: "analysis_count_document", - count_type_label: "Documents", - count_value: 3, + status_ordinal: 1, + status_code: "analysis_status_pending" as const, + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", }, - ], - visible_posts: [{ post_id: "post-1", post_title: "Public post" }], - status_history: options?.pendingTeppRun + ] + : options?.runningTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending" as const, + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running" as const, + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + ] + : options?.cancelledTeppRun ? [ { status_ordinal: 1, - status_code: "analysis_status_pending", + status_code: "analysis_status_pending" as const, status_label: "Pending", occurred_at: "2026-01-12T12:35:00Z", }, + { + status_ordinal: 2, + status_code: "analysis_status_cancelled" as const, + status_label: "Cancelled", + occurred_at: "2026-01-12T12:36:00Z", + }, ] : [ { status_ordinal: 1, - status_code: "analysis_status_pending", + status_code: "analysis_status_pending" as const, status_label: "Pending", occurred_at: "2026-01-12T12:35:00Z", }, { status_ordinal: 2, - status_code: "analysis_status_running", + status_code: "analysis_status_running" as const, status_label: "Running", occurred_at: "2026-01-12T12:36:00Z", }, { status_ordinal: 3, - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + status_code: teppStatus, + status_label: teppLabel, occurred_at: "2026-01-12T12:37:00Z", - ...(options?.succeededTeppRun - ? {} - : { failure_code: "tepp_not_available" }), + ...(options?.succeededTeppRun ? {} : { failure_code: "tepp_not_available" }), }, - ], + ]; + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: teppStatus, + status_label: teppLabel, + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: teppHistory, }), ); } @@ -291,8 +339,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", source_counts: [ @@ -321,9 +371,14 @@ describe("App, authenticated", () => { }, { status_ordinal: 3, - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", occurred_at: "2026-01-12T12:33:00Z", + ...(options?.failedLineageRun + ? { failure_code: "lineage_reconstruction_failed" } + : {}), }, ], }), @@ -395,12 +450,20 @@ describe("App, authenticated", () => { ? "analysis_status_succeeded" : options?.pendingTeppRun ? "analysis_status_pending" - : "analysis_status_failed", + : options?.runningTeppRun + ? "analysis_status_running" + : options?.cancelledTeppRun + ? "analysis_status_cancelled" + : "analysis_status_failed", status_label: options?.succeededTeppRun ? "Succeeded" : options?.pendingTeppRun ? "Pending" - : "Failed", + : options?.runningTeppRun + ? "Running" + : options?.cancelledTeppRun + ? "Cancelled" + : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -411,7 +474,7 @@ describe("App, authenticated", () => { }, ], }, - ...(options?.failedReportRun + ...(options?.failedReportRun || options?.pendingReportRun ? [ { analysis_run_id: "run-demo-report", @@ -420,8 +483,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed" as const, - status_label: "Failed", + status_code: options?.pendingReportRun + ? ("analysis_status_pending" as const) + : ("analysis_status_failed" as const), + status_label: options?.pendingReportRun ? "Pending" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -1707,7 +1772,7 @@ describe("App, authenticated", () => { await userEvent.click( screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: TEPP measurement · Failed · Demo Corp. Open this run to see why it failed, then connect the measurement service and re-run.", }), ); expect( @@ -1725,10 +1790,10 @@ describe("App, authenticated", () => { await screen.findByRole("list", { name: "Analysis runs" }); const lineageButton = screen.getByRole("button", { - name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp. Open this run to see why it failed, then retry reconstruction from a current snapshot.", }); const teppButton = screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: TEPP measurement · Failed · Demo Corp. Open this run to see why it failed, then connect the measurement service and re-run.", }); expect(lineageButton).toHaveTextContent( "Open this run to see why it failed, then retry reconstruction from a current snapshot.", @@ -1738,6 +1803,18 @@ describe("App, authenticated", () => { "Open this run to see why it failed, then connect the measurement service and re-run.", ); expect(teppButton).not.toHaveTextContent("reconstruction"); + + await userEvent.click(lineageButton); + expect( + await screen.findByRole("heading", { + name: "Lineage reconstruction · Failed · Demo Corp", + }), + ).toBeInTheDocument(); + expect( + screen.getAllByText( + "Open this run to see why it failed, then retry reconstruction from a current snapshot.", + ).length, + ).toBeGreaterThanOrEqual(2); }); it("does not tell a failed period report to connect the measurement service", async () => { @@ -1745,7 +1822,7 @@ describe("App, authenticated", () => { render(); const reportButton = await screen.findByRole("button", { - name: "Open analysis run: Period report · Failed · Demo Corp", + name: "Open analysis run: Period report · Failed · Demo Corp. Open this run to see why it failed, then rebuild the period report from a current snapshot.", }); expect(reportButton).toHaveTextContent( "Open this run to see why it failed, then rebuild the period report from a current snapshot.", @@ -1761,21 +1838,76 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); }); - it("does not tell a pending TEPP run that it already measured", async () => { + it("does not tell a pending TEPP run that reconstruction or measurement already finished", async () => { stubBackend({ pendingTeppRun: true }); render(); + const teppButton = await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Pending · Demo Corp. Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.", + }); + expect(teppButton).not.toHaveTextContent("Reconstruction has not started yet"); + expect(teppButton).not.toHaveTextContent("measured"); + + await userEvent.click(teppButton); + expect( + await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + }); + + it("does not tell a pending period report that reconstruction or measurement started", async () => { + stubBackend({ pendingReportRun: true }); + render(); + + const reportButton = await screen.findByRole("button", { + name: "Open analysis run: Period report · Pending · Demo Corp. Open this run to confirm which posts the period report will use. The report has not been built yet.", + }); + expect(reportButton).not.toHaveTextContent("Reconstruction"); + expect(reportButton).not.toHaveTextContent("measurement"); + + await userEvent.click(reportButton); + expect( + screen.getAllByText( + "Open this run to confirm which posts the period report will use. The report has not been built yet.", + ).length, + ).toBeGreaterThanOrEqual(2); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + }); + + it("does not tell a running TEPP run that it already measured", async () => { + stubBackend({ runningTeppRun: true }); + render(); + await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + name: "Open analysis run: TEPP measurement · Running · Demo Corp", }), ); expect( await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), ).toBeInTheDocument(); - expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + }); + + it("does not tell a cancelled TEPP run that it produced a calibrated result", async () => { + stubBackend({ cancelledTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Cancelled · Demo Corp", + }), + ); + expect( + await screen.findByText( + "These posts are the cutoff corpus this TEPP run would have measured. The run was cancelled before a calibrated result.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { @@ -1805,7 +1937,7 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); expect( screen.getByRole("button", { - name: "Open analysis run: Lineage reconstruction · Pending · Demo Corp", + name: "Open analysis run: Lineage reconstruction · Pending · Demo Corp. Open this run to confirm which posts it will use. Reconstruction has not started yet.", }), ).toBeInTheDocument(); expect( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07088e9d4..f109ab7a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1447,12 +1447,13 @@ function analysisRunCaption(run: AnalysisRun): string { } /** - * Next action for a pending or failed run on the home list and detail. + * Next action for a pending or failed run on the home list and detail + * (ADR 0014). * * The machine `failure_code` stays on detail history (ADR 0014). Copy * is pinned to registered kinds so a pending TEPP row is not mistaken * for reconstruction, and a failed lineage row is not mistaken for a - * missing TEPP transport. + * missing TEPP transport. Unknown wire codes stay off the sentence. */ function analysisRunNextAction(run: AnalysisRun): string | null { switch (run.status_code) { @@ -1466,7 +1467,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return "Open this run to confirm which posts the period report will use. The report has not been built yet."; default: { const unexpected: never = run.run_kind_code; - return unexpected; + void unexpected; + return "Open this run to confirm its next step. The registered kind is not lineage, TEPP, or a period report."; } } case "analysis_status_failed": @@ -1479,7 +1481,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; default: { const unexpected: never = run.run_kind_code; - return unexpected; + void unexpected; + return "Open this run to see why it failed, then retry from a current snapshot."; } } case "analysis_status_running": @@ -1489,11 +1492,24 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return null; default: { const unexpected: never = run.status_code; - return unexpected; + void unexpected; + return "Open this run to confirm its current status before acting."; } } } +/** + * List-button accessible name (WCAG 2.2 SC 4.1.2 / AccName 1.1). + * + * `aria-label` replaces the button contents, so the next-action sentence + * must be in the name or a screen reader only hears the caption. + */ +function analysisRunAccessibleName(run: AnalysisRun): string { + const caption = analysisRunCaption(run); + const nextAction = analysisRunNextAction(run); + return nextAction ? `Open analysis run: ${caption}. ${nextAction}` : `Open analysis run: ${caption}`; +} + /** * Empty-corpus copy that tells the operator what to do next. */ @@ -1516,7 +1532,11 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { ); default: { const unexpected: never = run.run_kind_code; - return unexpected; + void unexpected; + return ( + "No posts were available at this cutoff. Open a later run, or ask an " + + "administrator to capture a newer snapshot." + ); } } } @@ -1549,7 +1569,8 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { return "These posts are the cutoff corpus attached to this TEPP run."; default: { const unexpected: never = run.status_code; - return unexpected; + void unexpected; + return "These posts are the cutoff corpus attached to this TEPP run."; } } } @@ -1697,7 +1718,7 @@ function AnalysisRunsPanel({