diff --git a/pkg-py/src/commons/_provenance.py b/pkg-py/src/commons/_provenance.py index e69e1574..9ebf0e20 100644 --- a/pkg-py/src/commons/_provenance.py +++ b/pkg-py/src/commons/_provenance.py @@ -32,12 +32,12 @@ class Tag(enum.StrEnum): @dataclass(frozen=True) class ProvenanceDisplay: - """The words and styling one tag's pill renders with.""" + """The words and styling one tag's provenance marker renders with.""" label: str icon: str | None body: str - pill_class: str + dot_class: str | None PROVENANCE_DISPLAY: dict[Tag, ProvenanceDisplay] = { @@ -47,13 +47,13 @@ class ProvenanceDisplay: body=( "This answer comes from a governed calculation defined by your data team." ), - pill_class="trusted", + dot_class="verified", ), Tag.B: ProvenanceDisplay( label="Cited", icon=None, body="This answer includes supporting text verified against a trusted source.", - pill_class="cited", + dot_class=None, ), Tag.C: ProvenanceDisplay( label="Untrusted", @@ -62,7 +62,7 @@ class ProvenanceDisplay: "This answer was not produced by a governed calculation and has " "no verified supporting citation. AI can be wrong." ), - pill_class="caution", + dot_class="untrusted", ), } @@ -72,7 +72,7 @@ def derive_provenance_tag(tags: Sequence[Tag], verified: bool) -> Tag | None: A fallback claim remains fallback even when its answer also uses a governed calculation, so its citation verdict takes precedence ("B beats A"). - Returns ``None`` when no data tool ran, which shows no pill at all. + Returns ``None`` when no data tool ran, which shows no provenance marker. """ if Tag.B in tags: return Tag.B if verified else Tag.C diff --git a/pkg-py/tests/test_provenance.py b/pkg-py/tests/test_provenance.py index f7b87878..86c78018 100644 --- a/pkg-py/tests/test_provenance.py +++ b/pkg-py/tests/test_provenance.py @@ -44,11 +44,11 @@ def test_display_copy_matches_the_shared_fixture(name: str) -> None: assert entry.label == expected["label"] assert entry.body == expected["body"] assert entry.icon == expected["icon"] - assert entry.pill_class == expected["pill_class"] + assert entry.dot_class == expected["dot_class"] def test_every_tag_has_display_copy() -> None: - # A new Tag member without copy would render a pill with no words in it. + # A new Tag member without copy would render a marker with no words in it. assert set(PROVENANCE_DISPLAY) == set(Tag) diff --git a/pkg-r/R/provenance.R b/pkg-r/R/provenance.R index 3257f33f..ecb6634c 100644 --- a/pkg-r/R/provenance.R +++ b/pkg-r/R/provenance.R @@ -6,13 +6,13 @@ provenance_display <- list( "This answer comes from a governed calculation defined by", "your data team." ), - pill_class = "trusted" + dot_class = "verified" ), B = list( label = "Cited", icon = NULL, body = "This answer includes supporting text verified against a trusted source.", - pill_class = "cited" + dot_class = NULL ), C = list( label = "Untrusted", @@ -21,7 +21,7 @@ provenance_display <- list( "This answer was not produced by a governed calculation and has", "no verified supporting citation. AI can be wrong." ), - pill_class = "caution" + dot_class = "untrusted" ) ) diff --git a/pkg-r/R/trajectory-review.R b/pkg-r/R/trajectory-review.R index 41650509..36c43d1c 100644 --- a/pkg-r/R/trajectory-review.R +++ b/pkg-r/R/trajectory-review.R @@ -295,8 +295,8 @@ add_message_provenance <- function(messages, provenance) { } else { record$provenance_tag %||% NA_character_ } - pill <- commons_answer_pill(tag) - if (is.null(pill)) { + dot <- commons_answer_dot(tag) + if (is.null(dot)) { next } candidates <- which(vapply( @@ -311,48 +311,41 @@ add_message_provenance <- function(messages, provenance) { next } index <- candidates[[length(candidates)]] - messages[[index]]$content <- append_provenance_pill( + messages[[index]]$content <- append_provenance_dot( messages[[index]]$content, - pill + dot ) } messages } -append_provenance_pill <- function(content, pill) { +append_provenance_dot <- function(content, dot) { if (is.character(content)) { - return(list(content, pill)) + return(list(content, dot)) } - c(content, list(pill)) + c(content, list(dot)) } -commons_answer_pill <- function(tag) { +commons_answer_dot <- function(tag) { entry <- provenance_display[[tag]] - if (is.null(entry)) { + if (is.null(entry) || is.null(entry$dot_class)) { return(NULL) } - htmltools::tags$span( + htmltools::tags$sup( class = paste0( - "commons-answer-pill commons-answer-pill-", - entry$pill_class + "commons-answer-dot commons-answer-dot-", + entry$dot_class ), title = entry$body, `aria-label` = paste0(entry$label, ". ", entry$body), + role = "img", tabindex = "0", - commons_pill_icon(entry$icon, entry$label), - htmltools::tags$span(entry$label), - commons_pill_tooltip(entry$body) + commons_dot_icon(entry$icon), + commons_dot_tooltip(entry$body) ) } -commons_pill_tooltip <- function(text) { - htmltools::tags$span(class = "commons-tooltip", role = "tooltip", text) -} - -commons_pill_icon <- function(file, alt) { - if (is.null(file)) { - return(NULL) - } +commons_dot_icon <- function(file) { src <- commons_icon_url(file) if (is.null(src)) { return(NULL) @@ -360,11 +353,15 @@ commons_pill_icon <- function(file, alt) { htmltools::tags$img( src = src, - alt = alt, - class = "commons-answer-pill-icon" + alt = "", + class = "commons-answer-dot-mark" ) } +commons_dot_tooltip <- function(text) { + htmltools::tags$span(class = "commons-tooltip", role = "tooltip", text) +} + seed_transcript_decorations <- function( session, id, @@ -734,7 +731,7 @@ viewer_server <- function( ) }) - # A fresh id prevents stale pill timers from targeting a new transcript. + # A fresh id prevents stale decoration timers from targeting a new transcript. output$transcript <- shiny::renderUI({ conversation <- selected_conversation() if (is.null(conversation)) { @@ -908,7 +905,7 @@ question_entry <- function( htmltools::div( class = "commons-viewer-entry-meta", flag_marker(flagged), - commons_answer_pill(record$tag) + commons_answer_dot(record$tag) ) ) ) diff --git a/pkg-r/inst/figs/warning-icon.svg b/pkg-r/inst/figs/warning-icon.svg index fa175077..9f25e7fe 100644 --- a/pkg-r/inst/figs/warning-icon.svg +++ b/pkg-r/inst/figs/warning-icon.svg @@ -7,7 +7,7 @@ } .cls-2 { - fill: #965b25; + fill: #fff; } @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/pkg-r/inst/www/commons-chat/citation-mark.svg b/pkg-r/inst/www/commons-chat/citation-mark.svg new file mode 100644 index 00000000..c997a8b9 --- /dev/null +++ b/pkg-r/inst/www/commons-chat/citation-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/pkg-r/inst/www/commons-chat/commons-chat.css b/pkg-r/inst/www/commons-chat/commons-chat.css index 5b734354..c2874ca1 100644 --- a/pkg-r/inst/www/commons-chat/commons-chat.css +++ b/pkg-r/inst/www/commons-chat/commons-chat.css @@ -35,42 +35,39 @@ shiny-chat-container { /* ---- Answer provenance ----------------------------------------------- */ -.commons-answer-pill { +.commons-answer-dot { + --commons-answer-dot-color: currentColor; align-items: center; - border-radius: 999px; + border-radius: 50%; + color: var(--commons-answer-dot-color); display: inline-flex; - font-size: 0.74rem; - font-weight: 400; - gap: 0.3rem; - line-height: 1.2; - max-width: 100%; - padding: 0.18rem 0.45rem; + height: 1rem; + justify-content: center; + line-height: 0; + margin-inline-start: 0.125em; position: relative; - vertical-align: text-bottom; + vertical-align: middle; + width: 1rem; } -.commons-answer-pill-icon { - flex: 0 0 auto; - height: 0.82rem; - width: 0.82rem; +.commons-answer-dot:focus-visible { + outline: 0.125rem solid var(--bs-focus-ring-color, rgba(0, 123, 194, 0.5)); + outline-offset: 0; } -.commons-answer-pill-trusted { - background: #f2fbf5; - border: 1px solid #cfeedd; - color: #286144; +.commons-answer-dot-mark { + animation: commons-answer-dot-pulse 1.6s ease-in-out 1; + display: block; + height: 0.75rem; + width: 0.75rem; } -.commons-answer-pill-cited { - background: #eef7fa; - border: 1px solid #c9e2e9; - color: #285867; +.commons-answer-dot-verified { + --commons-answer-dot-color: #28c840; } -.commons-answer-pill-caution { - background: #fff8ec; - border: 1px solid #f2ddbb; - color: #6b4b1b; +.commons-answer-dot-untrusted { + --commons-answer-dot-color: #febc2e; } /* ---- Provenance tooltips ---------------------------------------------- */ @@ -99,15 +96,15 @@ shiny-chat-container { z-index: 1000; } -.commons-answer-pill:hover > .commons-tooltip, -.commons-answer-pill:focus > .commons-tooltip, -.commons-answer-pill:focus-within > .commons-tooltip { +.commons-answer-dot:hover > .commons-tooltip, +.commons-answer-dot:focus > .commons-tooltip, +.commons-answer-dot:focus-within > .commons-tooltip { display: block; } -.commons-answer-pill:hover::before, -.commons-answer-pill:focus::before, -.commons-answer-pill:focus-within::before { +.commons-answer-dot:hover::before, +.commons-answer-dot:focus::before, +.commons-answer-dot:focus-within::before { background: var(--bs-body-bg, #fff); border-bottom: 1px solid var(--bs-border-color, #dee2e6); border-right: 1px solid var(--bs-border-color, #dee2e6); @@ -128,7 +125,7 @@ shiny-chat-container { top: calc(100% + 0.45rem); } -.commons-answer-pill.commons-tooltip-below::before { +.commons-answer-dot.commons-tooltip-below::before { bottom: auto; top: calc(100% + 0.28rem); transform: translateX(-50%) rotate(225deg); @@ -143,47 +140,157 @@ shiny-chat-container [data-shinychat-aside-display="compact"] { --shiny-chat-aside-marker-font-family: var(--bs-font-monospace); } -/* Temporary bridge until Shinychat provides a public per-aside tone API. */ +/* Temporary bridge until Shinychat provides a public per-aside marker API. */ +shiny-chat-container + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + ) { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + box-shadow: none; + color: transparent; + display: inline-flex; + font-size: 0; + height: 1rem; + justify-content: center; + margin-inline-start: 0.125em; + max-inline-size: none; + min-height: 1rem; + min-inline-size: 1rem; + padding: 0; + vertical-align: middle; + width: 1rem; +} + +shiny-chat-container + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + )::after { + animation: commons-answer-dot-pulse 1.6s ease-in-out 1; + background: var(--commons-answer-dot-color); + border-radius: 50%; + box-sizing: border-box; + color: var(--commons-answer-dot-color); + content: ""; + height: 0.625rem; + width: 0.625rem; +} + shiny-chat-container .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]) { - background: #f2fbf5; - box-shadow: inset 0 0 0 1px #cfeedd; - color: #286144; + --commons-answer-dot-color: #28c840; } shiny-chat-container - .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]):hover, + .shiny-aside-pill:has(img[src$="/warning-icon.svg"]) { + --commons-answer-dot-color: #febc2e; +} + shiny-chat-container - .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]):focus-visible, + .shiny-aside-pill--number { + --commons-answer-dot-color: #007bc2; +} + shiny-chat-container - .shiny-aside-pill:has(img[src$="/trusted-icon.svg"])[aria-expanded="true"] { - background: #e4f6ea; - color: #204f38; + button.shiny-aside-pill.shiny-aside-pill--number::after { + animation: commons-citation-mark-pulse 1.6s ease-in-out 1; + background-image: url("citation-mark.svg"); + background-position: center; + background-repeat: no-repeat; + background-size: 0.75rem; + border-radius: 0.1875rem; + box-shadow: none; + height: 0.75rem; + width: 0.75rem; } shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"]) { - background: #fff8ec; - box-shadow: inset 0 0 0 1px #f2ddbb; - color: #6b4b1b; + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + ):hover, +shiny-chat-container + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + ):focus-visible, +shiny-chat-container + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + )[aria-expanded="true"] { + background: transparent; + color: transparent; } shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"]):hover, + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + ):hover::after, shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"]):focus-visible, + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + )[aria-expanded="true"]::after { + filter: brightness(0.9); +} + shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"])[aria-expanded="true"] { - background: #fcefd8; - color: #583b14; + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + ):focus-visible { + outline: 0.125rem solid var(--bs-focus-ring-color, rgba(0, 123, 194, 0.5)); + outline-offset: 0; +} + +shiny-chat-container + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + ) + > * { + display: none; } shiny-chat-container .shiny-aside-pill:has( img[src$="/trusted-icon.svg"], img[src$="/warning-icon.svg"] - ) { - font-weight: 400; + )::after { + display: none; } shiny-chat-container @@ -191,8 +298,70 @@ shiny-chat-container img[src$="/trusted-icon.svg"], img[src$="/warning-icon.svg"] ) - img { - opacity: 1; + > img { + animation: commons-answer-dot-pulse 1.6s ease-in-out 1; + display: block; + height: 0.75rem; + width: 0.75rem; +} + +shiny-chat-container + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ):hover + > img, +shiny-chat-container + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + )[aria-expanded="true"] + > img { + filter: brightness(0.9); +} + +@keyframes commons-answer-dot-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 18%, transparent); + transform: scale(1); + } + + 35% { + box-shadow: 0 0 0 0.3rem color-mix(in srgb, currentColor 18%, transparent); + transform: scale(1.18); + } +} + +@keyframes commons-citation-mark-pulse { + 0%, + 100% { + transform: scale(1); + } + + 35% { + transform: scale(1.18); + } +} + +@media (prefers-reduced-motion: reduce) { + .commons-answer-dot-mark, + shiny-chat-container + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + > img, + shiny-chat-container + :is( + .shiny-aside-pill--number, + .shiny-aside-pill:has( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"] + ) + )::after { + animation: none; + } } /* ---- Measure display ------------------------------------------------- */ diff --git a/pkg-r/inst/www/commons-chat/commons-chat.js b/pkg-r/inst/www/commons-chat/commons-chat.js index a0f8a237..ac541561 100644 --- a/pkg-r/inst/www/commons-chat/commons-chat.js +++ b/pkg-r/inst/www/commons-chat/commons-chat.js @@ -5,8 +5,8 @@ return; } - if (window.commonsAnswerPillTooltipInitialized) return; - window.commonsAnswerPillTooltipInitialized = true; + if (window.commonsAnswerDotTooltipInitialized) return; + window.commonsAnswerDotTooltipInitialized = true; // Keep the viewport still when a tool card is expanded or collapsed; // otherwise shinychat's stick-to-bottom scrolling chases the height @@ -96,7 +96,7 @@ var onMarker = function(event) { if (!event.target || !event.target.closest) return; - var marker = event.target.closest(".commons-answer-pill"); + var marker = event.target.closest(".commons-answer-dot"); if (marker) placeTooltip(marker); }; diff --git a/pkg-r/inst/www/commons-viewer/commons-viewer.css b/pkg-r/inst/www/commons-viewer/commons-viewer.css index 1d0d0e21..88a494c8 100644 --- a/pkg-r/inst/www/commons-viewer/commons-viewer.css +++ b/pkg-r/inst/www/commons-viewer/commons-viewer.css @@ -430,16 +430,6 @@ margin-top: 0.125rem; } -.commons-viewer-entry-meta .commons-answer-pill { - font-size: 0.68rem; - padding: 0.12rem 0.4rem; -} - -.commons-viewer-entry-meta .commons-answer-pill-icon { - height: 0.75rem; - width: 0.75rem; -} - .commons-viewer-empty { color: var(--bs-secondary-color, #6c757d); padding: 1rem 0; diff --git a/pkg-r/tests/testthat/fixtures/shared/provenance.json b/pkg-r/tests/testthat/fixtures/shared/provenance.json index 94c517fc..b7302d17 100644 --- a/pkg-r/tests/testthat/fixtures/shared/provenance.json +++ b/pkg-r/tests/testthat/fixtures/shared/provenance.json @@ -1,17 +1,17 @@ { "description": "Provenance contract shared by pkg-r and pkg-py. The source is tests/shared/provenance.json; the copy under pkg-r/tests/testthat/fixtures/shared/ is generated by scripts/sync-shared-fixtures.sh. Edit the source and re-run that script.", "derive_provenance_tag": { - "description": "Truth table for derive_provenance_tag(tags, verified). `tags` holds the commons_tag values appended by tools during one exchange; only A and B are ever emitted as inputs, so C appears only as a derived result. `expected` is null when no pill is shown.", - "rule": "B beats A: any B in the exchange disqualifies an A unless a citation verified, in which case B. No data tags at all means no pill.", + "description": "Truth table for derive_provenance_tag(tags, verified). `tags` holds the commons_tag values appended by tools during one exchange; only A and B are ever emitted as inputs, so C appears only as a derived result. `expected` is null when no provenance marker is shown.", + "rule": "B beats A: any B in the exchange disqualifies an A unless a citation verified, in which case B. No data tags at all means no provenance marker.", "cases": [ { - "name": "no data tools means no pill", + "name": "no data tools means no provenance marker", "tags": [], "verified": false, "expected": null }, { - "name": "no data tools means no pill even when something verified", + "name": "no data tools means no provenance marker even when something verified", "tags": [], "verified": true, "expected": null @@ -73,25 +73,25 @@ ] }, "provenance_display": { - "description": "Word-for-word copy both UIs render. `icon` is null when the pill has no icon.", + "description": "Word-for-word copy both UIs render. `icon` and `dot_class` are null when a tag has no standalone provenance marker.", "tags": { "A": { "label": "Verified answer", "icon": "trusted-icon.svg", "body": "This answer comes from a governed calculation defined by your data team.", - "pill_class": "trusted" + "dot_class": "verified" }, "B": { "label": "Cited", "icon": null, "body": "This answer includes supporting text verified against a trusted source.", - "pill_class": "cited" + "dot_class": null }, "C": { "label": "Untrusted", "icon": "warning-icon.svg", "body": "This answer was not produced by a governed calculation and has no verified supporting citation. AI can be wrong.", - "pill_class": "caution" + "dot_class": "untrusted" } } } diff --git a/pkg-r/tests/testthat/test-citation-browser.R b/pkg-r/tests/testthat/test-citation-browser.R index b9026f30..aaf774f6 100644 --- a/pkg-r/tests/testthat/test-citation-browser.R +++ b/pkg-r/tests/testthat/test-citation-browser.R @@ -7,7 +7,7 @@ test_that("normal package checks can disable browser tests explicitly", { ) }) -test_that("Shiny Chat renders native numbered streamed citations", { +test_that("Shiny Chat renders streamed citation markers", { skip_on_cran() skip_if_not_installed("shinytest2") skip_if_not_installed("chromote") @@ -59,6 +59,26 @@ test_that("Shiny Chat renders native numbered streamed citations", { ), "Aside 1: documentation|Aside 2: sales table" ) + expect_identical( + app$get_js( + paste0( + "Array.from(document.querySelectorAll('", + marker_selector, + "')).every((node) => {", + "const marker = getComputedStyle(node);", + "const dot = getComputedStyle(node, '::after');", + "return marker.width === '16px' && marker.height === '16px' && ", + "marker.verticalAlign === 'middle' && marker.fontSize === '0px' && ", + "dot.width === '12px' && dot.height === '12px' && ", + "dot.borderRadius === '3px' && ", + "dot.backgroundColor === 'rgb(0, 123, 194)' && ", + "dot.backgroundImage !== 'none' && dot.boxShadow === 'none' && ", + "dot.animationName === 'commons-citation-mark-pulse';", + "});" + ) + ), + TRUE + ) expect_identical( app$get_js( paste0( @@ -88,8 +108,8 @@ test_that("Shiny Chat renders native numbered streamed citations", { ) ), paste( - "Canopy weighting follows the documentation.\n[1]", - "Revenue timing follows the table definition.\n[2]", + "Canopy weighting follows the documentation.", + "Revenue timing follows the table definition.", sep = "|" ) ) @@ -197,7 +217,7 @@ test_that("Shiny Chat renders native numbered streamed citations", { expect_no_match(answer_html, "commons-citation", fixed = TRUE) }) -test_that("Shiny Chat distinguishes verified, cited, and untrusted asides", { +test_that("Shiny Chat renders distinct provenance markers", { skip_on_cran() skip_if_not_installed("shinytest2") skip_if_not_installed("chromote") @@ -228,21 +248,55 @@ test_that("Shiny Chat distinguishes verified, cited, and untrusted asides", { "['Verified answer', 'Untrusted']", ".map((label) => document.querySelector(", "`button[aria-label=\"${label}\"]`", - ").textContent).join('|');" + ").textContent.trim()).join('|');" ) ), "Verified answer|Untrusted" ) + expect_identical( + app$get_js( + paste0( + "(() => {", + "const nodes = ['Verified answer', 'Untrusted'].map((label) => ", + "document.querySelector(`button[aria-label=\"${label}\"]`));", + "return nodes.every((node) => {", + "const marker = getComputedStyle(node);", + "const icon = node.querySelector('img');", + "const iconStyle = getComputedStyle(icon);", + "return marker.width === '16px' && marker.height === '16px' && ", + "marker.verticalAlign === 'middle' && iconStyle.width === '12px' && ", + "iconStyle.height === '12px' && iconStyle.display === 'block' && ", + "iconStyle.animationName === 'commons-answer-dot-pulse' && ", + "iconStyle.animationDuration === '1.6s' && ", + "iconStyle.animationTimingFunction === 'ease-in-out' && ", + "iconStyle.animationIterationCount === '1';", + "});", + "})()" + ) + ), + TRUE + ) expect_identical( app$get_js( paste0( "['Verified answer', 'Untrusted']", - ".map((label) => getComputedStyle(document.querySelector(", + ".map((label) => document.querySelector(", + "`button[aria-label=\"${label}\"] img`", + ").getAttribute('src').split('/').pop()).join('|');" + ) + ), + "trusted-icon.svg|warning-icon.svg" + ) + expect_identical( + app$get_js( + paste0( + "['Verified answer', 'Untrusted']", + ".map((label) => document.querySelector(", "`button[aria-label=\"${label}\"]`", - ")).fontWeight).join('|');" + ").closest('p')?.innerText.trim()).join('|');" ) ), - "400|400" + "Governed result.|Fallback result." ) expect_identical( app$get_js( @@ -267,22 +321,6 @@ test_that("Shiny Chat distinguishes verified, cited, and untrusted asides", { ), TRUE ) - expect_identical( - app$get_js( - paste0( - "(() => {", - "const trusted = document.querySelector(", - "'button[aria-label=\"Verified answer\"]');", - "const warning = document.querySelector(", - "'button[aria-label=\"Untrusted\"]');", - "return trusted && warning && ", - "getComputedStyle(trusted).color !== getComputedStyle(warning).color;", - "})()" - ) - ), - TRUE - ) - app$get_js( 'document.querySelector(\'button[aria-label="Verified answer"]\').click();' ) @@ -300,6 +338,23 @@ test_that("Shiny Chat distinguishes verified, cited, and untrusted asides", { "This answer comes from a governed calculation defined by your data team.", fixed = TRUE ) + + app$get_chromote_session()$Emulation$setEmulatedMedia( + features = list(list( + name = "prefers-reduced-motion", + value = "reduce" + )) + ) + expect_identical( + app$get_js( + paste0( + "getComputedStyle(", + "document.querySelector('button[aria-label=\"Untrusted\"] img')", + ").animationName;" + ) + ), + "none" + ) }) test_that("Shiny Chat preserves Markdown blocks around citations", { diff --git a/pkg-r/tests/testthat/test-provenance.R b/pkg-r/tests/testthat/test-provenance.R index b770cf7d..da2bb644 100644 --- a/pkg-r/tests/testthat/test-provenance.R +++ b/pkg-r/tests/testthat/test-provenance.R @@ -24,7 +24,7 @@ test_that("provenance_display matches the shared copy word for word", { expect_identical(entry$label, expected$label, info = tag) expect_identical(entry$body, expected$body, info = tag) expect_identical(entry$icon, expected$icon, info = tag) - expect_identical(entry$pill_class, expected$pill_class, info = tag) + expect_identical(entry$dot_class, expected$dot_class, info = tag) } }) diff --git a/pkg-r/tests/testthat/test-trajectory-review.R b/pkg-r/tests/testthat/test-trajectory-review.R index 15b7f440..ca1fee49 100644 --- a/pkg-r/tests/testthat/test-trajectory-review.R +++ b/pkg-r/tests/testthat/test-trajectory-review.R @@ -80,31 +80,35 @@ test_that("hit_rate counts exchange tags across conversations", { expect_equal(rate$counts, c(A = 1, B = 1, C = 1, none = 1)) }) -test_that("answer pills describe trusted, cited, and uncited answers", { +test_that("answer markers describe verified and untrusted answers", { skip_if_not_installed("htmltools") - trusted <- htmltools::renderTags(commons_answer_pill("A"))$html - cited <- htmltools::renderTags(commons_answer_pill("B"))$html - uncited <- htmltools::renderTags(commons_answer_pill("C"))$html - - expect_match(trusted, "Verified answer") - expect_match(trusted, "governed calculation") - expect_match(trusted, "commons-tooltip") - expect_match(trusted, "commons-answer-pill-icon") - expect_match(trusted, "commons-answer-pill-trusted") - expect_match(trusted, "commons-icons/trusted-icon.svg", fixed = TRUE) - - expect_match(cited, "Cited") - expect_match(cited, "verified against a trusted source") - expect_match(cited, "commons-tooltip") - expect_match(cited, "commons-answer-pill-cited") - - expect_match(uncited, "Untrusted") - expect_match(uncited, "AI can be wrong") - expect_match(uncited, "not produced by a governed calculation") - expect_match(uncited, "commons-tooltip") - expect_match(uncited, "commons-answer-pill-icon") - expect_match(uncited, "commons-answer-pill-caution") + verified <- htmltools::renderTags(commons_answer_dot("A"))$html + untrusted <- htmltools::renderTags(commons_answer_dot("C"))$html + + expect_match(verified, " How many total animals were observed at Oak Bluff? -The agent then searches for a trusted calculation that can answer the question. If it finds one, it runs that code and then reports the result along with a "Verified answer" tag. +The agent then searches for a trusted calculation that can answer the question. If it finds one, it runs that code and then reports the result with a green verified dot. > At Oak Bluff, 59 individual animals were observed across 5 species, based on 28 hours of survey effort. Note this reflects observed individuals during surveys, not necessarily a full census of every animal present at the site. > -> `r commons:::commons_answer_pill("A")` +> `r commons:::commons_answer_dot("A")` Although the agent had to decide which trusted calculation to run, it did not have to decide _what code to write_, reducing degrees of freedom and allowing it to take advantage of pre-vetted code. @@ -69,7 +69,7 @@ file.copy(skill, ".claude/skills", recursive = TRUE) ## Trust flow -commons agents will use trusted calculations whenever possible. When the user asks a question, the agent first searches the semantic layer for a trusted calculation. If it finds one, it then calls that calculation and the resulting answer is tagged with a green verified answer pill. +commons agents will use trusted calculations whenever possible. When the user asks a question, the agent first searches the semantic layer for a trusted calculation. If it finds one, it then calls that calculation and the resulting answer is marked with a green verified dot. If a relevant trusted calculation is not found, the agent proceeds down the lower trust path. It searches through the context for additional information, then uses that information to write custom SQL or R code to answer the user's question. These answers either include a verified citation in a footnote or are tagged as `Untrusted`. @@ -92,7 +92,7 @@ If a relevant trusted calculation is not found, the agent proceeds down the lowe Run trusted
calculation
-`r commons:::commons_answer_pill("A")` +`r commons:::commons_answer_dot("A")`
@@ -109,7 +109,7 @@ If a relevant trusted calculation is not found, the agent proceeds down the lowe Cited answer1 or -`r commons:::commons_answer_pill("C")` +`r commons:::commons_answer_dot("C")`
@@ -123,11 +123,11 @@ The following table details the various ways each trust outcome can occur: | How the answer is produced | Outcome | |---|---| -| A trusted R [measure](#semantic-layer) | `r commons:::commons_answer_pill("A")` | -| A [data-dictionary metric](#definitions), possibly grouped or filtered with [definitions](#data-dictionaries) | `r commons:::commons_answer_pill("A")` | -| A [Snowflake semantic-view or Databricks metric-view metric](#warehouse-semantic-layers) | `r commons:::commons_answer_pill("A")` | -| Custom SQL, including SQL that uses [data-dictionary definitions](#definitions) | Cited or `r commons:::commons_answer_pill("C")` | -| Custom R | Cited or `r commons:::commons_answer_pill("C")` | +| A trusted R [measure](#semantic-layer) | `r commons:::commons_answer_dot("A")` | +| A [data-dictionary metric](#definitions), possibly grouped or filtered with [definitions](#data-dictionaries) | `r commons:::commons_answer_dot("A")` | +| A [Snowflake semantic-view or Databricks metric-view metric](#warehouse-semantic-layers) | `r commons:::commons_answer_dot("A")` | +| Custom SQL, including SQL that uses [data-dictionary definitions](#definitions) | Cited or `r commons:::commons_answer_dot("C")` | +| Custom R | Cited or `r commons:::commons_answer_dot("C")` | | No data tool used (e.g., because the agent already had sufficient information or the question could not be answered from accessible information) | No label | The agent itself does not tag the answer as verified, cited, or untrusted. This process is instead handled deterministically by the commons package based on the agent's behavior. diff --git a/pkg-r/vignettes/commons.css b/pkg-r/vignettes/commons.css index bab7eb98..c8e0a671 100644 --- a/pkg-r/vignettes/commons.css +++ b/pkg-r/vignettes/commons.css @@ -1,11 +1,3 @@ -.commons-answer-pill-icon { - background: transparent; - border: 0; - border-radius: 0; - margin: 0; - padding: 0; -} - .commons-flow { margin: 1.5rem 0; overflow-x: auto; diff --git a/tests/shared/provenance.json b/tests/shared/provenance.json index 94c517fc..b7302d17 100644 --- a/tests/shared/provenance.json +++ b/tests/shared/provenance.json @@ -1,17 +1,17 @@ { "description": "Provenance contract shared by pkg-r and pkg-py. The source is tests/shared/provenance.json; the copy under pkg-r/tests/testthat/fixtures/shared/ is generated by scripts/sync-shared-fixtures.sh. Edit the source and re-run that script.", "derive_provenance_tag": { - "description": "Truth table for derive_provenance_tag(tags, verified). `tags` holds the commons_tag values appended by tools during one exchange; only A and B are ever emitted as inputs, so C appears only as a derived result. `expected` is null when no pill is shown.", - "rule": "B beats A: any B in the exchange disqualifies an A unless a citation verified, in which case B. No data tags at all means no pill.", + "description": "Truth table for derive_provenance_tag(tags, verified). `tags` holds the commons_tag values appended by tools during one exchange; only A and B are ever emitted as inputs, so C appears only as a derived result. `expected` is null when no provenance marker is shown.", + "rule": "B beats A: any B in the exchange disqualifies an A unless a citation verified, in which case B. No data tags at all means no provenance marker.", "cases": [ { - "name": "no data tools means no pill", + "name": "no data tools means no provenance marker", "tags": [], "verified": false, "expected": null }, { - "name": "no data tools means no pill even when something verified", + "name": "no data tools means no provenance marker even when something verified", "tags": [], "verified": true, "expected": null @@ -73,25 +73,25 @@ ] }, "provenance_display": { - "description": "Word-for-word copy both UIs render. `icon` is null when the pill has no icon.", + "description": "Word-for-word copy both UIs render. `icon` and `dot_class` are null when a tag has no standalone provenance marker.", "tags": { "A": { "label": "Verified answer", "icon": "trusted-icon.svg", "body": "This answer comes from a governed calculation defined by your data team.", - "pill_class": "trusted" + "dot_class": "verified" }, "B": { "label": "Cited", "icon": null, "body": "This answer includes supporting text verified against a trusted source.", - "pill_class": "cited" + "dot_class": null }, "C": { "label": "Untrusted", "icon": "warning-icon.svg", "body": "This answer was not produced by a governed calculation and has no verified supporting citation. AI can be wrong.", - "pill_class": "caution" + "dot_class": "untrusted" } } }