diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b66c7cde7..2dc22d994 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -481,7 +481,7 @@ list, then open the Pending row to confirm the cutoff corpus. unavailable, so that run is Failed rather than a fabricated score. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, 12-character digest prefixes -with full digests on hover, counts, status history) +that disclose the full digest on activation, counts, status history) without exposing a DSN or raw record. Opening a cutoff title warns that the live body may have changed after the run. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps diff --git a/CHANGELOG.d/0.86.3-analysis-run-digest-disclosure.md b/CHANGELOG.d/0.86.3-analysis-run-digest-disclosure.md new file mode 100644 index 000000000..f2427d30c --- /dev/null +++ b/CHANGELOG.d/0.86.3-analysis-run-digest-disclosure.md @@ -0,0 +1,5 @@ +# 0.86.3 Analysis-run digest disclosure + +Activate a prefix on the run detail to read the full digest. The +closed panel stays in the document with `hidden`. Hover is not the +only verification path. The list stays aggregates-only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 13aeb02f9..a72ba611b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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). +## [0.86.3] - 2026-08-16 + +### Fixed + +- Analysis-run digest prefixes are disclosure buttons. Open the Demo + Corp lineage run, activate `Code` or `Config`, and match the revealed + digest to the API payload. The closed panel stays in the document + with `hidden` so `aria-controls` has a target, and each prefix meets + the 24px pointer target (WCAG 2.2 SC 1.4.13 and 2.5.8; WAI-ARIA APG + Disclosure). A hover `title` is no longer the only path. The home + list still hides digests even when the list JSON includes them. + Requesting a lineage reconstruction now keeps the pending next action + on that opened detail: reconstruction has not started yet. + ## [0.86.2] - 2026-08-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index a11127584..764d03fe2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the ADRs under `docs/adr/`. Do not fork those rules here. -## Analysis-run seed (v0.85.0) +## Analysis-run seed (v0.86.3) `make seed` writes a Demo Corp lineage run and a TEPP run on the same snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing @@ -15,7 +15,7 @@ theta or a local psychometric substitute. The home list caption stays 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. -Digest prefixes stay audible; hover a prefix to read the full digest. +Digest prefixes stay audible; activate 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). `POST /api/analysis-runs` records Pending on an authorized diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index 089443374..60b4d838b 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -31,9 +31,13 @@ as reconstructed evidence. Reproducibility digests on the same detail use a labeled group whose accessible name does not replace the visible prefixes (W3C Accessible -Name and Description Computation 1.1). Full digests stay on `title` -for hover verification and on the API payload; the home list stays -aggregates-only. +Name and Description Computation 1.1). Each prefix is a disclosure +button (WAI-ARIA APG Disclosure; WCAG 2.2 Success Criterion 1.4.13). +The full digest stays in the document with `hidden` until activation +so `aria-controls` has a target and keyboard and assistive technology +can verify it the same way a pointer can. Each prefix button meets the +WCAG 2.2 SC 2.5.8 24px minimum target. The home list stays +aggregates-only; the API payload still carries the full values. Seed and API fixtures backdate in-cutoff posts. A late own-corp private post remains on the live post list and stays out of the January 2026 @@ -46,8 +50,8 @@ run. post (2026-02-10) does not appear. - Open the run, read the live-body warning, then open a listed post and compare it with the cutoff date. -- Hover a digest prefix to read the full code or configuration digest - when you need to match the API payload. +- Activate a digest prefix (Enter, Space, or click) to read the full + code or configuration digest when you need to match the API payload. - Post-body versioning at the cutoff remains future work. - Thread-group *run list* visibility now uses the same cutoff (ADR 0018). A later public post cannot surface a previously hidden @@ -65,3 +69,10 @@ Recommendation). https://www.w3.org/TR/owl-time/ 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. (2024). *Web content accessibility guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (n.d.). *Disclosure (show/hide) pattern*. +ARIA Authoring Practices Guide. +https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index b41b31c17..a95dc4362 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -9,7 +9,10 @@ |---|---|---| | 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 of disclosure buttons. | +| WCAG 2.2 Success Criterion 1.4.13 | Additional content that appears only on hover or focus must not be the only way to complete a task. | Full digests are hidden until the operator activates a prefix button (Enter, Space, or click). Native `title` tooltips are not the verification path. | +| WCAG 2.2 Success Criterion 2.5.8 | Pointer targets must be at least 24 by 24 CSS pixels. | Each digest prefix button uses `--lw-target-min: 24px` and an inline 24px floor. | +| WAI-ARIA APG Disclosure | Use a button with `aria-expanded` to show and hide the controlled digest. | `AnalysisRunReproducibilityDigests` keeps the closed `` panel in the document with `hidden` so `aria-controls` always has a target. | | 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, and exclusion of raw source/provider payloads. | @@ -104,3 +107,10 @@ computation 1.1* (W3C Recommendation). https://www.w3.org/TR/accname-1.1/ World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ + +World Wide Web Consortium. (2024). *Web content accessibility guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (n.d.). *Disclosure (show/hide) pattern*. +ARIA Authoring Practices Guide. +https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ diff --git a/docs/goals/analysis-run-operator-loop.md b/docs/goals/analysis-run-operator-loop.md new file mode 100644 index 000000000..14b864923 --- /dev/null +++ b/docs/goals/analysis-run-operator-loop.md @@ -0,0 +1,34 @@ +# Goal — analysis-run operator loop + +**Status:** Active +**Date:** 2026-08-16 + +## Goal + +A buyer can open a seeded analysis run, hear the digest prefixes, reveal +the full digests without a pointer, and compare any opened live post +with that run's cutoff before treating the body as reconstructed +evidence. + +## Current loop + +1. #127 is on `feat/role-responsibility-agent-ontology` (`44912a6`). + Prefixes are audible; the live-body warning is present. +2. Land #155 (v0.86.3) so keyboard and AT operators can match a digest + to the API payload. Closed panels stay in the document with `hidden`; + each prefix is a 24px target. Prefer #155 over #139 `cf8c2e8` and + over #135. Do not self-approve or merge from this automation. +3. Write-clock landing is #150. Prefer it over #131. Do not open a + second write-clock PR. Kind-specific pending copy is #149 — do not + open a second pending-copy PR. Retention purge + Storybook tokens + landing is #154. Prefer it over #145/#134/#137. +4. #125 (`POST /api/analysis-runs`) is on the same base. Do not open a + second create PR. +5. Post-body versioning at the cutoff remains later work (ADR 0016). + +## Out of this loop + +Retention purge and the Storybook runner belong to the approved +frontend-toolchain PR. Failed-run next-action copy already landed with +#124. Embedded `data:image` rendering landed as 0.86.1. R&R catalog-id +walks landed as 0.86.2 (#141). diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md new file mode 100644 index 000000000..09169104d --- /dev/null +++ b/docs/storybook-inventory.md @@ -0,0 +1,22 @@ +# Storybook inventory + +Repeating web objects that must stay tokenized and independently +composable. The Storybook runner itself lands with the approved +frontend toolchain PR; this inventory is the product list those +stories must cover. + +| Object | Tokens | Next action the story must teach | +|---|---|---| +| Analysis-run digest disclosure | `--lw-opacity-meta`, `--lw-font-size-meta`, `--lw-space-digest-gap`, `--lw-font-family-mono`, `--lw-focus-ring`, `--lw-focus-offset`, `--lw-target-min` | Activate a prefix, then match the revealed digest to the API payload. | +| Analysis-run live-post warning | `--lw-opacity-meta`, `--lw-font-size-meta` | Compare the opened body with the run cutoff before treating it as reconstructed evidence. | +| Embedded post image | `--post-body-gap`, `--post-image-padding`, `--post-image-border`, `--post-image-radius`, `--post-image-bg` | Read the picture in document order, then run Extract or Ask if you need the OCR text. | +| Meta caption (`.post-meta`) | `--lw-opacity-meta`, `--lw-font-size-meta` | Read the clock or count, then take the control beside it. | + +## References + +World Wide Web Consortium. (2024). *Web content accessibility guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (n.d.). *Disclosure (show/hide) pattern*. +ARIA Authoring Practices Guide. +https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ diff --git a/frontend/package.json b/frontend/package.json index fb52f7948..b5209226e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.2", + "version": "0.86.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/AnalysisRunReproducibilityDigests.test.tsx b/frontend/src/AnalysisRunReproducibilityDigests.test.tsx new file mode 100644 index 000000000..04e8ff355 --- /dev/null +++ b/frontend/src/AnalysisRunReproducibilityDigests.test.tsx @@ -0,0 +1,105 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { ANALYSIS_RUN_DIGEST_TARGET_MIN_PX } from "./analysisRunDigests"; +import { AnalysisRunReproducibilityDigests } from "./AnalysisRunReproducibilityDigests"; + +const CODE_REVISION_SHA = "abcdef0123456789deadbeefcafebabe"; +const CONFIGURATION_SHA256 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("AnalysisRunReproducibilityDigests", () => { + it("renders nothing when the run has no digests", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("keeps prefixes audible and hides full digests until activation", () => { + render( + , + ); + const group = screen.getByLabelText("Analysis run reproducibility digests"); + expect(group).toHaveTextContent("Activate a prefix to read the full digest and match the API payload."); + expect(group).not.toHaveTextContent("Hover"); + const codeButton = screen.getByRole("button", { name: "Code abcdef012345" }); + const configButton = screen.getByRole("button", { name: "Config 0123456789ab" }); + expect(codeButton).toHaveAttribute("aria-expanded", "false"); + expect(configButton).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByText(CODE_REVISION_SHA)).not.toBeVisible(); + expect(screen.getByText(CONFIGURATION_SHA256)).not.toBeVisible(); + const codePanelId = codeButton.getAttribute("aria-controls"); + const configPanelId = configButton.getAttribute("aria-controls"); + expect(codePanelId).toBeTruthy(); + expect(configPanelId).toBeTruthy(); + expect(document.getElementById(codePanelId ?? "")).toHaveAttribute("hidden"); + expect(document.getElementById(configPanelId ?? "")).toHaveAttribute("hidden"); + expect(codeButton).toHaveStyle({ + minHeight: `${ANALYSIS_RUN_DIGEST_TARGET_MIN_PX}px`, + minWidth: `${ANALYSIS_RUN_DIGEST_TARGET_MIN_PX}px`, + }); + }); + + it("reveals the full code digest with Enter and hides it on the next activation", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.tab(); + expect(screen.getByRole("button", { name: "Code abcdef012345" })).toHaveFocus(); + await user.keyboard("{Enter}"); + expect(screen.getByRole("button", { name: "Code abcdef012345" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(screen.getByText(CODE_REVISION_SHA)).toBeVisible(); + expect(screen.getByText(CONFIGURATION_SHA256)).not.toBeVisible(); + await user.keyboard("{Enter}"); + expect(screen.getByRole("button", { name: "Code abcdef012345" })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.getByText(CODE_REVISION_SHA)).not.toBeVisible(); + }); + + it("tells the operator to match the revealed digest after activation", async () => { + const user = userEvent.setup(); + render( + , + ); + expect( + screen.getByText("Activate a prefix to read the full digest and match the API payload."), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Code abcdef012345" })); + expect( + screen.getByText("Match the revealed digest to the API payload."), + ).toBeInTheDocument(); + expect( + screen.queryByText("Activate a prefix to read the full digest and match the API payload."), + ).not.toBeInTheDocument(); + }); + + it("reveals the full configuration digest with Space", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.tab(); + await user.tab(); + expect(screen.getByRole("button", { name: "Config 0123456789ab" })).toHaveFocus(); + await user.keyboard(" "); + expect(screen.getByText(CONFIGURATION_SHA256)).toBeVisible(); + expect(screen.getByText(CODE_REVISION_SHA)).not.toBeVisible(); + }); +}); diff --git a/frontend/src/AnalysisRunReproducibilityDigests.tsx b/frontend/src/AnalysisRunReproducibilityDigests.tsx new file mode 100644 index 000000000..2ac77d3c0 --- /dev/null +++ b/frontend/src/AnalysisRunReproducibilityDigests.tsx @@ -0,0 +1,104 @@ +import { useId, useState } from "react"; +import { + ANALYSIS_RUN_DIGEST_TARGET_MIN_PX, + analysisRunDigestButtonLabel, + analysisRunDigestNextAction, + analysisRunDigestRevealedNextAction, + type AnalysisRunDigestKind, +} from "./analysisRunDigests"; + +/** + * One digest disclosure. The button name stays the audible prefix; the + * full value stays in the document with `hidden` until Enter, Space, or + * click so `aria-controls` always has a target (APG Disclosure). + */ +function AnalysisRunDigestDisclosure({ + kind, + digest, + panelId, + open, + onOpenChange, +}: { + kind: AnalysisRunDigestKind; + digest: string; + panelId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const label = analysisRunDigestButtonLabel(kind, digest); + return ( + + + + + ); +} + +/** + * Labeled group of analysis-run reproducibility digests. + * + * Prefixes remain the accessible contents of the group. Full digests + * stay off the home list and stay `hidden` on the detail until the + * operator activates a prefix. + */ +export function AnalysisRunReproducibilityDigests({ + codeRevisionSha, + configurationSha256, +}: { + codeRevisionSha?: string; + configurationSha256?: string; +}) { + const id = useId(); + const [openCode, setOpenCode] = useState(false); + const [openConfig, setOpenConfig] = useState(false); + if (!codeRevisionSha && !configurationSha256) { + return null; + } + const anyOpen = + (Boolean(codeRevisionSha) && openCode) || + (Boolean(configurationSha256) && openConfig); + return ( +
+

+ {anyOpen + ? analysisRunDigestRevealedNextAction() + : analysisRunDigestNextAction()} +

+

+ {codeRevisionSha ? ( + + ) : null} + {codeRevisionSha && configurationSha256 ? " · " : null} + {configurationSha256 ? ( + + ) : null} +

+
+ ); +} diff --git a/frontend/src/App.css b/frontend/src/App.css index b3fab25d1..f260cd258 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -88,6 +88,11 @@ :root { --lw-opacity-meta: 0.7; --lw-font-size-meta: 0.85rem; + --lw-space-digest-gap: 0.35rem; + --lw-font-family-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --lw-focus-ring: 2px solid currentColor; + --lw-focus-offset: 2px; + --lw-target-min: 24px; } .post-meta { @@ -95,6 +100,49 @@ font-size: var(--lw-font-size-meta); } +.analysis-run-digest-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--lw-space-digest-gap); +} + +.analysis-run-digest { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: var(--lw-space-digest-gap); +} + +.analysis-run-digest-toggle { + background: none; + border: none; + min-height: var(--lw-target-min); + min-width: var(--lw-target-min); + padding: 0.15rem 0.35rem; + color: inherit; + cursor: pointer; + font: inherit; + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.analysis-run-digest-toggle:focus-visible { + outline: var(--lw-focus-ring); + outline-offset: var(--lw-focus-offset); +} + +.analysis-run-digest-full { + display: block; + font-family: var(--lw-font-family-mono); + font-size: var(--lw-font-size-meta); + overflow-wrap: anywhere; +} + +.analysis-run-digest-full[hidden] { + display: none; +} + .visually-hidden { position: absolute; width: 1px; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 65d25259b..091a82cee 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1667,17 +1667,34 @@ describe("App, authenticated", () => { expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); const digests = screen.getByLabelText("Analysis run reproducibility digests"); - expect(digests).toHaveTextContent("Hover a prefix to read the full digest for verification."); - expect(digests).toHaveTextContent("Code abcdef012345"); - expect(digests).toHaveTextContent("Config 0123456789ab"); - expect(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); - expect(digests).not.toHaveTextContent( - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + expect(digests).toHaveTextContent( + "Activate a prefix to read the full digest and match the API payload.", + ); + expect(digests).not.toHaveTextContent("Hover"); + expect(screen.getByRole("button", { name: "Code abcdef012345" })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.getByRole("button", { name: "Config 0123456789ab" })).toHaveAttribute( + "aria-expanded", + "false", ); - expect(screen.getByTitle("abcdef0123456789deadbeefcafebabe")).toHaveTextContent("Code abcdef012345"); + expect(screen.getByText("abcdef0123456789deadbeefcafebabe")).not.toBeVisible(); + expect( + screen.getByText( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + ).not.toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Code abcdef012345" })); + expect(screen.getByText("abcdef0123456789deadbeefcafebabe")).toBeVisible(); + expect( + screen.getByText("Match the revealed digest to the API payload."), + ).toBeInTheDocument(); expect( - screen.getByTitle("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), - ).toHaveTextContent("Config 0123456789ab"); + screen.getByText( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + ).not.toBeVisible(); const history = screen.getByRole("list", { name: "Analysis run status history" }); expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a511f713d..a390f23dc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,6 +58,7 @@ import { type RelatedNodeType, type VocEvidence, } from "./api"; +import { AnalysisRunReproducibilityDigests } from "./AnalysisRunReproducibilityDigests"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { subgraphForPost } from "./lineageLayout"; @@ -1538,13 +1539,6 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { } } -/** Git-style prefix. The full digest stays on `title` for verification. */ -const ANALYSIS_RUN_DIGEST_PREFIX_LENGTH = 12; - -function analysisRunDigestPrefix(digest: string): string { - return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH); -} - /** * Next action when a cutoff title opens the live post (ADR 0016). * @@ -1564,36 +1558,6 @@ function analysisRunLivePostButtonLabel(postTitle: string): string { return `Open live post (may have changed after cutoff): ${postTitle}`; } -function AnalysisRunReproducibilityDigests({ - codeRevisionSha, - configurationSha256, -}: { - codeRevisionSha?: string; - configurationSha256?: string; -}) { - if (!codeRevisionSha && !configurationSha256) { - return null; - } - return ( -
-

- - Hover a prefix to read the full digest for verification.{" "} - - {codeRevisionSha ? ( - {`Code ${analysisRunDigestPrefix(codeRevisionSha)}`} - ) : null} - {codeRevisionSha && configurationSha256 ? " · " : null} - {configurationSha256 ? ( - - {`Config ${analysisRunDigestPrefix(configurationSha256)}`} - - ) : null} -

-
- ); -} - function AnalysisRunsPanel({ accessToken, onSelectPost, @@ -1704,6 +1668,9 @@ function AnalysisRunsPanel({ {" · "} Requested {selected.requested_at.slice(0, 10)}

+ {analysisRunNextAction(selected) && ( +

{analysisRunNextAction(selected)}

+ )} { + it("keeps a 12-character prefix so the operator can match git-style short SHAs", () => { + expect(ANALYSIS_RUN_DIGEST_PREFIX_LENGTH).toBe(12); + expect(analysisRunDigestPrefix(CODE_REVISION_SHA)).toBe("abcdef012345"); + expect(analysisRunDigestPrefix(CONFIGURATION_SHA256)).toBe("0123456789ab"); + }); + + it("returns the whole digest when it is shorter than the prefix length", () => { + expect(analysisRunDigestPrefix("abc")).toBe("abc"); + }); +}); + +describe("analysisRunDigestKindLabel", () => { + it("names the two registered digest kinds without inventing a third", () => { + expect(analysisRunDigestKindLabel("code")).toBe("Code"); + expect(analysisRunDigestKindLabel("config")).toBe("Config"); + }); +}); + +describe("analysisRunDigestButtonLabel", () => { + it("puts the kind and prefix in the accessible name, not the full digest", () => { + expect(analysisRunDigestButtonLabel("code", CODE_REVISION_SHA)).toBe( + "Code abcdef012345", + ); + expect(analysisRunDigestButtonLabel("config", CONFIGURATION_SHA256)).toBe( + "Config 0123456789ab", + ); + expect(analysisRunDigestButtonLabel("code", CODE_REVISION_SHA)).not.toContain( + CODE_REVISION_SHA, + ); + }); +}); + +describe("analysisRunDigestNextAction", () => { + it("tells every operator to activate a prefix, not to hover", () => { + const nextAction = analysisRunDigestNextAction(); + expect(nextAction).toMatch(/Activate a prefix/); + expect(nextAction).not.toMatch(/Hover/i); + }); + + it("tells the operator to match the revealed digest to the API payload", () => { + const nextAction = analysisRunDigestRevealedNextAction(); + expect(nextAction).toMatch(/Match the revealed digest/); + expect(nextAction).toMatch(/API payload/); + expect(nextAction).not.toMatch(/Hover/i); + expect(nextAction).not.toMatch(/Activate a prefix/); + }); +}); + +describe("ANALYSIS_RUN_DIGEST_TARGET_MIN_PX", () => { + it("keeps the WCAG 2.5.8 24px minimum pointer target", () => { + expect(ANALYSIS_RUN_DIGEST_TARGET_MIN_PX).toBe(24); + }); +}); diff --git a/frontend/src/analysisRunDigests.ts b/frontend/src/analysisRunDigests.ts new file mode 100644 index 000000000..7fc150478 --- /dev/null +++ b/frontend/src/analysisRunDigests.ts @@ -0,0 +1,79 @@ +/** + * Reproducibility-digest helpers for an analysis-run detail. + * + * The home list stays aggregates-only. Detail shows a 12-character + * prefix so assistive technology hears `Code` / `Config` values, then + * a disclosure button reveals the full digest (WCAG 2.2 SC 1.4.13; + * WAI-ARIA APG Disclosure). Native `title` tooltips are pointer-only + * and must not be the only way to verify a digest against the API. + */ + +/** Git-style prefix length shown before the operator opens the full digest. */ +export const ANALYSIS_RUN_DIGEST_PREFIX_LENGTH = 12; + +/** WCAG 2.2 SC 2.5.8 minimum pointer target for each prefix button. */ +export const ANALYSIS_RUN_DIGEST_TARGET_MIN_PX = 24; + +/** Which digest the disclosure button reveals. */ +export type AnalysisRunDigestKind = "code" | "config"; + +/** + * Visible prefix used on the disclosure button. + * + * @param digest - Full code revision SHA or configuration SHA-256. + * @returns The first 12 characters, or the whole string when shorter. + */ +export function analysisRunDigestPrefix(digest: string): string { + return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH); +} + +/** + * Visible label for a digest kind. Keep this as the button contents so + * `aria-label` does not replace the prefix (AccName 1.1). + * + * @param kind - Code revision or configuration digest. + * @returns `Code` or `Config`. + */ +export function analysisRunDigestKindLabel(kind: AnalysisRunDigestKind): string { + switch (kind) { + case "code": + return "Code"; + case "config": + return "Config"; + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +/** + * Button text the operator hears and sees. The full digest is not part + * of the name; it appears only after activation. + * + * @param kind - Code revision or configuration digest. + * @param digest - Full digest string from the run payload. + * @returns For example `Code abcdef012345`. + */ +export function analysisRunDigestButtonLabel( + kind: AnalysisRunDigestKind, + digest: string, +): string { + return `${analysisRunDigestKindLabel(kind)} ${analysisRunDigestPrefix(digest)}`; +} + +/** + * Next action shown above the prefixes. True for keyboard, pointer, and + * assistive technology — unlike “Hover a prefix”. + */ +export function analysisRunDigestNextAction(): string { + return "Activate a prefix to read the full digest and match the API payload."; +} + +/** + * Next action after a prefix is open. The operator already revealed the + * digest; the remaining step is to match it to the API payload. + */ +export function analysisRunDigestRevealedNextAction(): string { + return "Match the revealed digest to the API payload."; +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index efe84890b..b84afb519 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.2" +__version__ = "0.86.3" diff --git a/pyproject.toml b/pyproject.toml index 6e41c7ca7..12222bdad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.2" +version = "0.86.3" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index e1d2860cf..179628fdd 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.2" +version = "0.86.3" source = { virtual = "." } dependencies = [ { name = "certifi" },