diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da35cc779..c009378e9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -477,7 +477,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.85.1-analysis-run-digest-disclosure.md b/CHANGELOG.d/0.85.1-analysis-run-digest-disclosure.md new file mode 100644 index 000000000..2891fb393 --- /dev/null +++ b/CHANGELOG.d/0.85.1-analysis-run-digest-disclosure.md @@ -0,0 +1,4 @@ +# 0.85.1 Analysis-run digest disclosure + +Activate a prefix on the run detail to read the full digest. Hover is +not the only verification path. The list stays aggregates-only. diff --git a/CHANGELOG.md b/CHANGELOG.md index fec2c509b..c14ab17f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.85.1] - 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. Keyboard and assistive technology can + complete that check; a hover `title` is no longer the only path + (WCAG 2.2 SC 1.4.13; WAI-ARIA APG Disclosure). The home list still + hides digests even when the list JSON includes them. + ## [0.85.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2f89bada7..ec6404b1d 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.85.1) `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 @@ -14,7 +14,7 @@ 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. -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 f9c82a86d..82c9adb9b 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -31,9 +31,11 @@ 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 is hidden until activation so keyboard and assistive +technology can verify it the same way a pointer can. 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 +48,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. ## References @@ -62,3 +64,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..383cade0b 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -9,7 +9,9 @@ |---|---|---| | 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. | +| WAI-ARIA APG Disclosure | Use a button with `aria-expanded` to show and hide the controlled digest. | `AnalysisRunReproducibilityDigests` toggles a `` panel per prefix. | | 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 +106,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..7a23b311a --- /dev/null +++ b/docs/goals/analysis-run-operator-loop.md @@ -0,0 +1,29 @@ +# 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 #135 (v0.85.1) so keyboard and AT operators can match a digest + to the API payload. Do not self-approve or merge from this automation. +3. Keep #131 as the write-clock comparison slice. Do not open a second + write-clock PR. +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. diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md new file mode 100644 index 000000000..db2b7ccbd --- /dev/null +++ b/docs/storybook-inventory.md @@ -0,0 +1,21 @@ +# 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` | 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. | +| 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 c8f67bc8d..5fff0beb6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.85.0", + "version": "0.85.1", "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..8d31987e6 --- /dev/null +++ b/frontend/src/AnalysisRunReproducibilityDigests.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +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(group).not.toHaveTextContent(CODE_REVISION_SHA); + expect(group).not.toHaveTextContent(CONFIGURATION_SHA256); + }); + + 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)).toBeInTheDocument(); + expect(screen.queryByText(CONFIGURATION_SHA256)).not.toBeInTheDocument(); + await user.keyboard("{Enter}"); + expect(screen.getByRole("button", { name: "Code abcdef012345" })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.queryByText(CODE_REVISION_SHA)).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)).toBeInTheDocument(); + expect(screen.queryByText(CODE_REVISION_SHA)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/AnalysisRunReproducibilityDigests.tsx b/frontend/src/AnalysisRunReproducibilityDigests.tsx new file mode 100644 index 000000000..416870243 --- /dev/null +++ b/frontend/src/AnalysisRunReproducibilityDigests.tsx @@ -0,0 +1,83 @@ +import { useId, useState } from "react"; +import { + analysisRunDigestButtonLabel, + analysisRunDigestNextAction, + type AnalysisRunDigestKind, +} from "./analysisRunDigests"; + +/** + * One digest disclosure. The button name stays the audible prefix; the + * full value is shown only after Enter, Space, or click (APG Disclosure). + */ +function AnalysisRunDigestDisclosure({ + kind, + digest, + panelId, +}: { + kind: AnalysisRunDigestKind; + digest: string; + panelId: string; +}) { + const [open, setOpen] = useState(false); + const label = analysisRunDigestButtonLabel(kind, digest); + return ( + + + {open ? ( + + {digest} + + ) : null} + + ); +} + +/** + * Labeled group of analysis-run reproducibility digests. + * + * Prefixes remain the accessible contents of the group. Full digests + * stay off the home list and off the default detail text until the + * operator activates a prefix. + */ +export function AnalysisRunReproducibilityDigests({ + codeRevisionSha, + configurationSha256, +}: { + codeRevisionSha?: string; + configurationSha256?: string; +}) { + const id = useId(); + if (!codeRevisionSha && !configurationSha256) { + return null; + } + return ( +
+

{analysisRunDigestNextAction()}

+

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

+
+ ); +} diff --git a/frontend/src/App.css b/frontend/src/App.css index 8f38b4dd0..f587b0582 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -88,6 +88,10 @@ :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; } .post-meta { @@ -95,16 +99,41 @@ font-size: var(--lw-font-size-meta); } -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; +.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; padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; + 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; } .post-body { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index dae8e1674..75b45b512 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1504,17 +1504,27 @@ 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).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(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); expect(digests).not.toHaveTextContent( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); - expect(screen.getByTitle("abcdef0123456789deadbeefcafebabe")).toHaveTextContent("Code abcdef012345"); - expect( - screen.getByTitle("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), - ).toHaveTextContent("Config 0123456789ab"); + await userEvent.click(screen.getByRole("button", { name: "Code abcdef012345" })); + expect(screen.getByText("abcdef0123456789deadbeefcafebabe")).toBeInTheDocument(); + expect(digests).not.toHaveTextContent( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); 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"); @@ -1596,7 +1606,7 @@ describe("App, authenticated", () => { expect( await screen.findByRole("heading", { name: "Lineage reconstruction · Pending · Demo Corp" }), ).toBeInTheDocument(); - expect(screen.getByText(/has not started yet/)).toBeInTheDocument(); + expect(screen.getAllByText(/has not started yet/).length).toBeGreaterThanOrEqual(1); const postCall = fetchMock.mock.calls.find( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3ddce1ac..a69e30603 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,6 +56,7 @@ import { type RelatedNode, type VocEvidence, } from "./api"; +import { AnalysisRunReproducibilityDigests } from "./AnalysisRunReproducibilityDigests"; import { LineageDag } from "./LineageDag"; import { subgraphForPost } from "./lineageLayout"; import "./App.css"; @@ -1410,13 +1411,6 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { return "These posts are the cutoff corpus this TEPP run measured."; } -/** 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). * @@ -1436,36 +1430,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, @@ -1493,7 +1457,10 @@ function AnalysisRunsPanel({ idempotency_key: crypto.randomUUID(), }); const listed = await fetchAnalysisRuns(accessToken); - setRuns(listed.analysis_runs); + const withoutCreated = listed.analysis_runs.filter( + (run) => run.analysis_run_id !== created.analysis_run_id, + ); + setRuns([created, ...withoutCreated]); setSelected(created); } catch (err) { setError(err instanceof BackendError ? err.message : String(err)); @@ -1576,6 +1543,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); + }); +}); diff --git a/frontend/src/analysisRunDigests.ts b/frontend/src/analysisRunDigests.ts new file mode 100644 index 000000000..d1e15a64d --- /dev/null +++ b/frontend/src/analysisRunDigests.ts @@ -0,0 +1,68 @@ +/** + * 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; + +/** 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."; +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5e05ef4ff..aea0dbd4e 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.85.0" +__version__ = "0.85.1" diff --git a/pyproject.toml b/pyproject.toml index 8750ae3e3..c5aeef25b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.85.0" +version = "0.85.1" 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 20f9a8793..080ed5d10 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.85.0" +version = "0.85.1" source = { virtual = "." } dependencies = [ { name = "certifi" },