From 3866bf2776393346017618654e7893ea061fc831 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 16 Aug 2026 16:21:17 +0000
Subject: [PATCH 1/3] fix(ui): keep digest disclosure panels and 24px targets
(v0.86.3)
Rebase the APG disclosure onto #141 so closed panels stay in the
document with hidden, each prefix meets WCAG 2.5.8, and 0.86.1 image
rendering plus 0.86.2 catalog-id walks stay on the base. Prefer this
head over #139 cf8c2e8.
Co-authored-by: Seongho Bae
---
ARCHITECTURE.md | 2 +-
.../0.86.3-analysis-run-digest-disclosure.md | 5 +
CHANGELOG.md | 12 ++
CLAUDE.md | 4 +-
...016-analysis-run-knowledge-cutoff-posts.md | 21 +++-
.../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 12 +-
docs/goals/analysis-run-operator-loop.md | 34 ++++++
docs/storybook-inventory.md | 22 ++++
frontend/package.json | 2 +-
...AnalysisRunReproducibilityDigests.test.tsx | 105 ++++++++++++++++++
.../src/AnalysisRunReproducibilityDigests.tsx | 104 +++++++++++++++++
frontend/src/App.css | 48 ++++++++
frontend/src/App.test.tsx | 35 ++++--
frontend/src/App.tsx | 38 +------
frontend/src/analysisRunDigests.test.ts | 69 ++++++++++++
frontend/src/analysisRunDigests.ts | 79 +++++++++++++
lineageweave/__init__.py | 2 +-
pyproject.toml | 2 +-
uv.lock | 2 +-
19 files changed, 539 insertions(+), 59 deletions(-)
create mode 100644 CHANGELOG.d/0.86.3-analysis-run-digest-disclosure.md
create mode 100644 docs/goals/analysis-run-operator-loop.md
create mode 100644 docs/storybook-inventory.md
create mode 100644 frontend/src/AnalysisRunReproducibilityDigests.test.tsx
create mode 100644 frontend/src/AnalysisRunReproducibilityDigests.tsx
create mode 100644 frontend/src/analysisRunDigests.test.ts
create mode 100644 frontend/src/analysisRunDigests.ts
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..a908d9515 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ 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.
+
## [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..e9a484a43
--- /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 the v0.86.3 digest-disclosure successor of #139 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 that successor over #135 and over #139 `cf8c2e8` (stale
+ 0.86.1 on `3c17fd3`). 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. 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 (
+
+
+
+ {digest}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ );
+}
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..31c6d5ea0 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,
diff --git a/frontend/src/analysisRunDigests.test.ts b/frontend/src/analysisRunDigests.test.ts
new file mode 100644
index 000000000..befc61cdc
--- /dev/null
+++ b/frontend/src/analysisRunDigests.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import {
+ ANALYSIS_RUN_DIGEST_PREFIX_LENGTH,
+ ANALYSIS_RUN_DIGEST_TARGET_MIN_PX,
+ analysisRunDigestButtonLabel,
+ analysisRunDigestKindLabel,
+ analysisRunDigestNextAction,
+ analysisRunDigestPrefix,
+ analysisRunDigestRevealedNextAction,
+} from "./analysisRunDigests";
+
+const CODE_REVISION_SHA = "abcdef0123456789deadbeefcafebabe";
+const CONFIGURATION_SHA256 =
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+describe("analysisRunDigestPrefix", () => {
+ 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" },
From df0869016da93a8077fe66904dd6a0cbf84eb5ac Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 16 Aug 2026 16:23:13 +0000
Subject: [PATCH 2/3] fix(ui): keep pending reconstruction next action on
detail
After Request a lineage reconstruction, the opened Pending detail now
repeats that reconstruction has not started yet so the operator can
confirm the cutoff corpus before waiting for a tree.
Co-authored-by: Seongho Bae
---
CHANGELOG.md | 2 ++
frontend/src/App.tsx | 3 +++
2 files changed, 5 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a908d9515..a72ba611b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@ All notable changes to this project are documented here. Format follows
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
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 31c6d5ea0..a390f23dc 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1668,6 +1668,9 @@ function AnalysisRunsPanel({
{" · "}
Requested {selected.requested_at.slice(0, 10)}
+ {analysisRunNextAction(selected) && (
+
{analysisRunNextAction(selected)}
+ )}
Date: Sun, 16 Aug 2026 16:24:50 +0000
Subject: [PATCH 3/3] docs: point the operator-loop goal at #155
Prefer the v0.86.3 disclosure successor over #139. Write-clock stays
#150; pending-copy stays #149; retention/Storybook stays #154.
Co-authored-by: Seongho Bae
---
docs/goals/analysis-run-operator-loop.md | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/docs/goals/analysis-run-operator-loop.md b/docs/goals/analysis-run-operator-loop.md
index e9a484a43..14b864923 100644
--- a/docs/goals/analysis-run-operator-loop.md
+++ b/docs/goals/analysis-run-operator-loop.md
@@ -14,14 +14,14 @@ evidence.
1. #127 is on `feat/role-responsibility-agent-ontology` (`44912a6`).
Prefixes are audible; the live-body warning is present.
-2. Land the v0.86.3 digest-disclosure successor of #139 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 that successor over #135 and over #139 `cf8c2e8` (stale
- 0.86.1 on `3c17fd3`). 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.
+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).