From 165b4aad0b68071a8a32edf6e460082a7d4c88fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:44:03 +0000 Subject: [PATCH 1/4] fix(ui): keep analysis-run digests audible and warn on live posts aria-label on the digest paragraph hid the prefixes from assistive technology. Move the label to a group, keep prefixes as visible text, and put the full digest on hover. Tell the operator that a cutoff title opens the live body so they compare it with the run clock. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 6 +- .../0.84.1-analysis-run-digest-a11y.md | 5 ++ CHANGELOG.md | 13 ++++ CLAUDE.md | 3 + ...016-analysis-run-knowledge-cutoff-posts.md | 20 ++++- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 6 +- frontend/package.json | 2 +- frontend/src/App.css | 21 ++++- frontend/src/App.test.tsx | 31 +++++++- frontend/src/App.tsx | 76 +++++++++++++++---- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 13 files changed, 161 insertions(+), 28 deletions(-) create mode 100644 CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2492ee50d..063b7a196 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -471,8 +471,10 @@ revision and configuration digest prefixes. `tepp_client` on that same snapshot; the default transport is 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, counts, status history) -without exposing a DSN or raw record. Status history is detail-only +labeled detail (cutoff, requested date, 12-character digest prefixes +with full digests on hover, 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 its machine `failure_code` rather than an invented caption. Failed TEPP list rows add a next-action line (open the run, then connect the diff --git a/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md new file mode 100644 index 000000000..213eb5451 --- /dev/null +++ b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md @@ -0,0 +1,5 @@ +# 0.84.1 Analysis-run digest a11y and live-body warning + +Detail prefixes stay audible and hoverable. Open a cutoff title only +after reading that the live body may have changed since the run. +The list stays aggregates-only. diff --git a/CHANGELOG.md b/CHANGELOG.md index c36b2666d..22b372c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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.84.1] - 2026-08-16 + +### Fixed + +- Analysis-run detail keeps 12-character digest prefixes as visible + text (so assistive technology hears `Code` / `Config` values) and + puts the full digest on hover. Open the Demo Corp lineage run, hover + a prefix, and match it to the API payload. The home list still hides + digests even when the list JSON includes them. +- Opening a cutoff title now says the live body may have changed after + that run. Compare the opened post with the cutoff date before you + treat it as reconstructed evidence (ADR 0016). + ## [0.84.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0a2950e91..7c588daa6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,3 +14,6 @@ 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. +Opening a cutoff title shows the live post -- compare it with the +cutoff before treating the body as reconstructed evidence (ADR 0016). diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index d6ac70db8..f9c82a86d 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -25,7 +25,15 @@ every scope branch (corporate entity, process unit, thread group, and all-visible). ABAC visibility is applied after that temporal gate. Click-through still opens the live post body -- post versioning is a later slice -- but the run list itself must not advertise a post the -run was not allowed to know. +run was not allowed to know. The detail must say that next action +plainly: compare the opened body with this cutoff before treating it +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. 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 @@ -36,8 +44,10 @@ run. - After `make seed`, the Demo Corp lineage run lists Demo public post and other in-cutoff Demo Corp titles. The later fixture account-review post (2026-02-10) does not appear. -- Open the run, then open a listed post, to inspect what that cutoff - actually reconstructed. +- 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. - Post-body versioning at the cutoff remains future work. ## References @@ -48,3 +58,7 @@ rules* (confirmed 2024; Amendment 1:2022). World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C 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/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index a1dc73957..b41b31c17 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -8,7 +8,8 @@ | Source | Product implication | Implemented evidence | |---|---|---| | 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). | +| 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. | | 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. | @@ -98,5 +99,8 @@ PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C Recommendation). https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). https://www.w3.org/TR/accname-1.1/ + World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/frontend/package.json b/frontend/package.json index c21ed209f..8ce5f334b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.84.0", + "version": "0.84.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index dfd0f2e81..8f38b4dd0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -85,9 +85,26 @@ cursor: pointer; } +:root { + --lw-opacity-meta: 0.7; + --lw-font-size-meta: 0.85rem; +} + .post-meta { - opacity: 0.7; - font-size: 0.85rem; + opacity: var(--lw-opacity-meta); + font-size: var(--lw-font-size-meta); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; } .post-body { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d8d82c8d7..c77d965b5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -293,6 +293,9 @@ describe("App, authenticated", () => { count_value: 3, }, ], + code_revision_sha: "abcdef0123456789deadbeefcafebabe", + configuration_sha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }, { analysis_run_id: "run-demo-tepp", @@ -1460,6 +1463,12 @@ describe("App, authenticated", () => { expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); + expect(list).not.toHaveTextContent("Code abcdef012345"); + expect(list).not.toHaveTextContent("Config 0123456789ab"); + expect(list).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); + expect(list).not.toHaveTextContent( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); await userEvent.click( screen.getByRole("button", { @@ -1470,21 +1479,39 @@ 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(screen.getByTitle("abcdef0123456789deadbeefcafebabe")).toHaveTextContent("Code abcdef012345"); + expect( + screen.getByTitle("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + ).toHaveTextContent("Config 0123456789ab"); 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"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Open run post: Public post" })).toBeInTheDocument(); + expect( + screen.getByText( + "Opening a title shows the live post. Compare it with cutoff 2026-01-12 before you treat the body as reconstructed evidence — it may have changed after this run.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open live post (may have changed after cutoff): Public post", + }), + ).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post (may have changed after cutoff): Public post", + }), + ); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); await userEvent.click( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 65af9596c..948035430 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1406,6 +1406,62 @@ 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). + * + * Post-body versioning is a later slice. Until then the operator must + * compare the opened body with this run's cutoff instead of treating + * today's text as reconstructed evidence. + */ +function analysisRunLivePostWarning(cutoffIso: string): string { + const cutoffDate = cutoffIso.slice(0, 10); + return ( + `Opening a title shows the live post. Compare it with cutoff ${cutoffDate} ` + + "before you treat the body as reconstructed evidence — it may have changed after this run." + ); +} + +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, @@ -1488,19 +1544,10 @@ function AnalysisRunsPanel({ {" · "} Requested {selected.requested_at.slice(0, 10)}

- {(selected.code_revision_sha || selected.configuration_sha256) && ( -

- {selected.code_revision_sha - ? `Code ${selected.code_revision_sha.slice(0, 12)}` - : ""} - {selected.code_revision_sha && selected.configuration_sha256 - ? " · " - : ""} - {selected.configuration_sha256 - ? `Config ${selected.configuration_sha256.slice(0, 12)}` - : ""} -

- )} +
    {selected.source_counts.map((count) => (
  • @@ -1521,12 +1568,13 @@ function AnalysisRunsPanel({ {selected.visible_posts && selected.visible_posts.length > 0 ? ( <> {corpusHint &&

    {corpusHint}

    } +

    {analysisRunLivePostWarning(selected.knowledge_cutoff)}

      {selected.visible_posts.map((post) => (
    • + {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 c77d965b5..0fa305b65 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1479,17 +1479,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"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 948035430..49fadc402 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -55,6 +55,7 @@ import { type RelatedNode, type VocEvidence, } from "./api"; +import { AnalysisRunReproducibilityDigests } from "./AnalysisRunReproducibilityDigests"; import { LineageDag } from "./LineageDag"; import { subgraphForPost } from "./lineageLayout"; import "./App.css"; @@ -1406,13 +1407,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). * @@ -1432,36 +1426,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..71b2c01ee --- /dev/null +++ b/frontend/src/analysisRunDigests.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + ANALYSIS_RUN_DIGEST_PREFIX_LENGTH, + analysisRunDigestButtonLabel, + analysisRunDigestKindLabel, + analysisRunDigestNextAction, + analysisRunDigestPrefix, +} 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); + }); +}); 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 65fa5d182..abe2036e7 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.84.1" +__version__ = "0.84.3" diff --git a/pyproject.toml b/pyproject.toml index b27655c2b..736e0fb70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.84.1" +version = "0.84.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 156b0813d..c6f887bdf 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.84.1" +version = "0.84.3" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 4eb87f6c76789dfe08d7c4121ecb4b9076019d9c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:05:53 +0000 Subject: [PATCH 4/4] fix(ui): keep digest disclosure panels and 24px targets Closed APG disclosure panels left the document, so aria-controls pointed at nothing. Keep each panel hidden in place, size the prefix to the WCAG 2.5.8 24px minimum, and tell the operator to match the revealed digest to the API payload. Co-authored-by: Seongho Bae --- .../0.85.1-analysis-run-digest-disclosure.md | 5 ++- CHANGELOG.md | 9 ++-- ...016-analysis-run-knowledge-cutoff-posts.md | 8 ++-- docs/goals/analysis-run-operator-loop.md | 6 ++- docs/storybook-inventory.md | 2 +- ...AnalysisRunReproducibilityDigests.test.tsx | 45 ++++++++++++++++--- .../src/AnalysisRunReproducibilityDigests.tsx | 41 ++++++++++++----- frontend/src/App.css | 9 +++- frontend/src/App.test.tsx | 23 ++++++---- frontend/src/analysisRunDigests.test.ts | 16 +++++++ frontend/src/analysisRunDigests.ts | 11 +++++ 11 files changed, 137 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.d/0.85.1-analysis-run-digest-disclosure.md b/CHANGELOG.d/0.85.1-analysis-run-digest-disclosure.md index 2891fb393..b91a0f40d 100644 --- a/CHANGELOG.d/0.85.1-analysis-run-digest-disclosure.md +++ b/CHANGELOG.d/0.85.1-analysis-run-digest-disclosure.md @@ -1,4 +1,5 @@ # 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. +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 c14ab17f9..8f43a9dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,11 @@ All notable changes to this project are documented here. Format follows - 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. + 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.85.0] - 2026-08-16 diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index 82c9adb9b..2f42c117a 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -33,9 +33,11 @@ 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). 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. +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 diff --git a/docs/goals/analysis-run-operator-loop.md b/docs/goals/analysis-run-operator-loop.md index 7a23b311a..cbe2cc6cf 100644 --- a/docs/goals/analysis-run-operator-loop.md +++ b/docs/goals/analysis-run-operator-loop.md @@ -14,8 +14,10 @@ evidence. 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. +2. Land the digest-disclosure successor of #135 (v0.85.1) 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. 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 diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index db2b7ccbd..4a6960099 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -7,7 +7,7 @@ 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 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. | | Meta caption (`.post-meta`) | `--lw-opacity-meta`, `--lw-font-size-meta` | Read the clock or count, then take the control beside it. | diff --git a/frontend/src/AnalysisRunReproducibilityDigests.test.tsx b/frontend/src/AnalysisRunReproducibilityDigests.test.tsx index 8d31987e6..04e8ff355 100644 --- a/frontend/src/AnalysisRunReproducibilityDigests.test.tsx +++ b/frontend/src/AnalysisRunReproducibilityDigests.test.tsx @@ -1,6 +1,7 @@ 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"; @@ -27,8 +28,18 @@ describe("AnalysisRunReproducibilityDigests", () => { 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); + 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 () => { @@ -46,14 +57,34 @@ describe("AnalysisRunReproducibilityDigests", () => { "aria-expanded", "true", ); - expect(screen.getByText(CODE_REVISION_SHA)).toBeInTheDocument(); - expect(screen.queryByText(CONFIGURATION_SHA256)).not.toBeInTheDocument(); + 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.queryByText(CODE_REVISION_SHA)).not.toBeInTheDocument(); + 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 () => { @@ -68,7 +99,7 @@ describe("AnalysisRunReproducibilityDigests", () => { 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(); + 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 index 416870243..2ac77d3c0 100644 --- a/frontend/src/AnalysisRunReproducibilityDigests.tsx +++ b/frontend/src/AnalysisRunReproducibilityDigests.tsx @@ -1,24 +1,30 @@ 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 is shown only after Enter, Space, or click (APG Disclosure). + * 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 [open, setOpen] = useState(false); const label = analysisRunDigestButtonLabel(kind, digest); return ( @@ -27,15 +33,17 @@ function AnalysisRunDigestDisclosure({ className="analysis-run-digest-toggle" aria-expanded={open} aria-controls={panelId} - onClick={() => setOpen((current) => !current)} + style={{ + minHeight: ANALYSIS_RUN_DIGEST_TARGET_MIN_PX, + minWidth: ANALYSIS_RUN_DIGEST_TARGET_MIN_PX, + }} + onClick={() => onOpenChange(!open)} > {label} - {open ? ( - - {digest} - - ) : null} + ); } @@ -44,7 +52,7 @@ function AnalysisRunDigestDisclosure({ * 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 + * stay off the home list and stay `hidden` on the detail until the * operator activates a prefix. */ export function AnalysisRunReproducibilityDigests({ @@ -55,18 +63,29 @@ export function AnalysisRunReproducibilityDigests({ 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 (
      -

      {analysisRunDigestNextAction()}

      +

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

      {codeRevisionSha ? ( ) : null} {codeRevisionSha && configurationSha256 ? " · " : null} @@ -75,6 +94,8 @@ export function AnalysisRunReproducibilityDigests({ kind="config" digest={configurationSha256} panelId={`${id}-config`} + open={openConfig} + onOpenChange={setOpenConfig} /> ) : null}

      diff --git a/frontend/src/App.css b/frontend/src/App.css index f587b0582..82a86e7af 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -92,6 +92,7 @@ --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 { @@ -116,7 +117,9 @@ .analysis-run-digest-toggle { background: none; border: none; - padding: 0; + min-height: var(--lw-target-min); + min-width: var(--lw-target-min); + padding: 0.15rem 0.35rem; color: inherit; cursor: pointer; font: inherit; @@ -136,6 +139,10 @@ overflow-wrap: anywhere; } +.analysis-run-digest-full[hidden] { + display: none; +} + .post-body { white-space: pre-wrap; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 75b45b512..481993e09 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1516,15 +1516,22 @@ describe("App, authenticated", () => { "aria-expanded", "false", ); - expect(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); - expect(digests).not.toHaveTextContent( - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - ); + 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")).toBeInTheDocument(); - expect(digests).not.toHaveTextContent( - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - ); + expect(screen.getByText("abcdef0123456789deadbeefcafebabe")).toBeVisible(); + expect( + screen.getByText("Match the revealed digest to the API payload."), + ).toBeInTheDocument(); + expect( + 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/analysisRunDigests.test.ts b/frontend/src/analysisRunDigests.test.ts index 71b2c01ee..befc61cdc 100644 --- a/frontend/src/analysisRunDigests.test.ts +++ b/frontend/src/analysisRunDigests.test.ts @@ -1,10 +1,12 @@ 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"; @@ -50,4 +52,18 @@ describe("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 index d1e15a64d..7fc150478 100644 --- a/frontend/src/analysisRunDigests.ts +++ b/frontend/src/analysisRunDigests.ts @@ -11,6 +11,9 @@ /** 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"; @@ -66,3 +69,11 @@ export function analysisRunDigestButtonLabel( 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."; +}