From 165b4aad0b68071a8a32edf6e460082a7d4c88fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:44:03 +0000 Subject: [PATCH 1/3] 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" },