diff --git a/pkg-r/R/chat.R b/pkg-r/R/chat.R index dbe01753..0230a959 100644 --- a/pkg-r/R/chat.R +++ b/pkg-r/R/chat.R @@ -13,9 +13,9 @@ #' @section Citations and provenance: #' The server verifies each `` the model writes against its #' own context, measure definitions, and data documentation as the answer -#' streams, and rewrites verified citations inline as numbered, -#' server-authored `` elements. Citation details name the -#' trusted source. A provenance marker in a compact `` follows +#' streams, and rewrites verified citations inline as server-authored +#' `` elements. Citation details name the +#' trusted source. A provenance marker `` follows #' the answer when it was produced by a governed calculation, or when a #' fallback answer cites nothing verified. #' diff --git a/pkg-r/R/citations.R b/pkg-r/R/citations.R index 8b43d4d1..539bbbd1 100644 --- a/pkg-r/R/citations.R +++ b/pkg-r/R/citations.R @@ -90,16 +90,36 @@ render_citation_aside <- function(quote, explanation, corpus) { } citation_aside_html <- function(quote, explanation, label, kind) { - icon <- citation_icon_url(kind) + # The pill renders the uniform quote mark; the per-kind icon goes in + # the body title (commons-chat.css hides shinychat's popover title row, + # so the aside's icon attribute is only a styling hook for the pill). + icon <- commons_icon_url("citation-mark.svg") + kind_icon <- citation_icon_url(kind) reason <- if (nzchar(explanation)) paste0(explanation, "\n\n") else "" blockquote <- paste0("> ", gsub("\n", "\n> ", trimws(quote), fixed = TRUE)) + # shinychat only renders the popover's title row for grouped asides, so + # the body carries its own title (icon + label) to keep the source named + # for singleton citations; commons-chat.css hides shinychat's row. + title <- sprintf( + paste0( + '', + '%s%s\n\n' + ), + if (is.null(kind_icon)) { + "" + } else { + sprintf('', escape_attr(kind_icon)) + }, + htmltools::htmlEscape(label) + ) sprintf( paste0( - '', - "%s%s" + '', + "%s%s%s" ), escape_attr(label), if (is.null(icon)) "" else sprintf(' icon="%s"', escape_attr(icon)), + title, reason, blockquote ) @@ -227,7 +247,9 @@ non_citable_tool_output_text <- function(tools) { paste(items, collapse = "\n") } -# These SVGs need a fixed stroke because images cannot inherit currentColor. +# The per-kind icon appears in the aside body's title; the pill renders +# the uniform citation-mark.svg quote mark instead. These SVGs need a +# fixed stroke because images cannot inherit currentColor. citation_icon_url <- function(kind) { file <- switch( kind, diff --git a/pkg-r/inst/www/commons-chat/commons-chat.css b/pkg-r/inst/www/commons-chat/commons-chat.css index 4921df20..3cd75809 100644 --- a/pkg-r/inst/www/commons-chat/commons-chat.css +++ b/pkg-r/inst/www/commons-chat/commons-chat.css @@ -130,67 +130,152 @@ shiny-chat-container .shiny-chat-input .tiptap { transform: translateX(-50%) rotate(225deg); } -/* Shinychat owns compact marker structure and placement through this public API. */ -shiny-chat-container [data-shinychat-aside-display="compact"] { - --shiny-chat-aside-marker-color: var(--bs-link-color); - --shiny-chat-aside-marker-hover-color: var(--bs-link-hover-color); +/* Citations are labeled identity asides: shinychat accumulates those + * sharing a paragraph into one pill with a "+N" overflow and a carousel + * popover. They render icon-only like the provenance markers, in a quieter + * mid navy — verified support, short of a governed calculation. */ +shiny-chat-container + .shiny-aside-pill:has(img[src$="/citation-mark.svg"]) { + --shiny-chat-aside-marker-color: #2e4a6b; --shiny-chat-aside-marker-bg: transparent; - --shiny-chat-aside-marker-hover-bg: transparent; - --shiny-chat-aside-marker-font-family: var(--bs-font-monospace); + --shiny-chat-aside-marker-hover-color: #223851; + --shiny-chat-aside-marker-hover-bg: #e7edf4; } -/* Temporary bridge until Shinychat provides a public per-aside tone API. */ +/* Temporary bridge until shinychat forwards a per-aside tone hook to the + * pill: the icon src is the only per-aside signal that survives to the + * rendered button, so it selects the tone — but styling goes only through + * shinychat's public --shiny-chat-aside-marker-* variables, so its own + * hover, focus, expanded, and compact-number rules keep working. */ shiny-chat-container .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]) { - background: #f2fbf5; - box-shadow: inset 0 0 0 1px #cfeedd; - color: #286144; + --shiny-chat-aside-marker-color: #286144; + --shiny-chat-aside-marker-bg: transparent; + --shiny-chat-aside-marker-hover-color: #204f38; + --shiny-chat-aside-marker-hover-bg: #e4f6ea; } shiny-chat-container - .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]):hover, -shiny-chat-container - .shiny-aside-pill:has(img[src$="/trusted-icon.svg"]):focus-visible, -shiny-chat-container - .shiny-aside-pill:has(img[src$="/trusted-icon.svg"])[aria-expanded="true"] { - background: #e4f6ea; - color: #204f38; + .shiny-aside-pill:has(img[src$="/warning-icon.svg"]) { + --shiny-chat-aside-marker-color: #6b4b1b; + --shiny-chat-aside-marker-bg: transparent; + --shiny-chat-aside-marker-hover-color: #583b14; + --shiny-chat-aside-marker-hover-bg: #fcefd8; } +/* Provenance and citation markers render as bare icons: the label stays in + * the DOM (the button's aria-label announces it) but doesn't render, and a + * grouped "+N" overflow count still shows. */ shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"]) { - background: #fff8ec; - box-shadow: inset 0 0 0 1px #f2ddbb; - color: #6b4b1b; + .shiny-aside-pill:has( + :is( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"], + img[src$="/citation-mark.svg"] + ) + ) { + border-radius: 50%; + font-weight: 400; + gap: 0.15em; + padding: 0.2em; } +/* A grouped marker carries a "+N more" count, which widens the pill: give + * it a stadium shape and roomier padding instead of the bare-icon circle. */ shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"]):hover, -shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"]):focus-visible, + .shiny-aside-pill:has( + :is( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"], + img[src$="/citation-mark.svg"] + ) + ):has(.shiny-aside-pill__overflow) { + border-radius: 999px; + gap: 0.3em; + padding: 0.2em 0.55em 0.2em 0.25em; +} + shiny-chat-container - .shiny-aside-pill:has(img[src$="/warning-icon.svg"])[aria-expanded="true"] { - background: #fcefd8; - color: #583b14; + .shiny-aside-pill:has( + :is( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"], + img[src$="/citation-mark.svg"] + ) + ) + .shiny-aside-pill__label { + display: none; } shiny-chat-container .shiny-aside-pill:has( - img[src$="/trusted-icon.svg"], - img[src$="/warning-icon.svg"] - ) { - font-weight: 400; + :is( + img[src$="/trusted-icon.svg"], + img[src$="/warning-icon.svg"], + img[src$="/citation-mark.svg"] + ) + ) + img { + height: 1.5em; + width: 1.5em; } shiny-chat-container .shiny-aside-pill:has( - img[src$="/trusted-icon.svg"], - img[src$="/warning-icon.svg"] + :is(img[src$="/trusted-icon.svg"], img[src$="/warning-icon.svg"]) ) img { + animation: commons-provenance-marker-pulse 1.6s ease-in-out 1; opacity: 1; } +@keyframes commons-provenance-marker-pulse { + 0% { + transform: scale(1); + } + 35% { + transform: scale(1.4); + } + 70% { + transform: scale(0.92); + } + 100% { + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + shiny-chat-container + .shiny-aside-pill:has( + :is(img[src$="/trusted-icon.svg"], img[src$="/warning-icon.svg"]) + ) + img { + animation: none; + } +} + +/* The citation aside body carries its own title (icon + label) because + * shinychat only renders its popover title row for grouped asides; hide + * shinychat's row so grouped popovers don't name the source twice. The + * popover is portaled to , so these rules must stay unscoped. */ +.commons-citation-title { + align-items: center; + display: flex; + font-weight: 600; + gap: 0.3em; + margin-bottom: 0.25rem; +} + +.commons-citation-title img { + height: 1.1em; + width: 1.1em; +} + +.shiny-aside-popover:has(.commons-citation-title) + .shiny-aside-popover__label { + display: none; +} + /* ---- Measure display ------------------------------------------------- */ .commons-measure-display { diff --git a/pkg-r/inst/www/commons-chat/figs/citation-definition.svg b/pkg-r/inst/www/commons-chat/figs/citation-definition.svg index 5c59acea..8e321d7b 100644 --- a/pkg-r/inst/www/commons-chat/figs/citation-definition.svg +++ b/pkg-r/inst/www/commons-chat/figs/citation-definition.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/pkg-r/inst/www/commons-chat/figs/citation-mark.svg b/pkg-r/inst/www/commons-chat/figs/citation-mark.svg new file mode 100644 index 00000000..0ffe1a42 --- /dev/null +++ b/pkg-r/inst/www/commons-chat/figs/citation-mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/pkg-r/inst/www/commons-chat/figs/citation-prose.svg b/pkg-r/inst/www/commons-chat/figs/citation-prose.svg index 22cc2597..1c154982 100644 --- a/pkg-r/inst/www/commons-chat/figs/citation-prose.svg +++ b/pkg-r/inst/www/commons-chat/figs/citation-prose.svg @@ -1,5 +1,4 @@ - - - - + + + diff --git a/pkg-r/inst/www/commons-chat/figs/citation-schema.svg b/pkg-r/inst/www/commons-chat/figs/citation-schema.svg index 80e196e7..41ebf3a7 100644 --- a/pkg-r/inst/www/commons-chat/figs/citation-schema.svg +++ b/pkg-r/inst/www/commons-chat/figs/citation-schema.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/pkg-r/inst/www/commons-chat/figs/trusted-icon.svg b/pkg-r/inst/www/commons-chat/figs/trusted-icon.svg index 5671efce..1aff24a7 100644 --- a/pkg-r/inst/www/commons-chat/figs/trusted-icon.svg +++ b/pkg-r/inst/www/commons-chat/figs/trusted-icon.svg @@ -1,20 +1,6 @@ - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/pkg-r/inst/www/commons-chat/figs/untrusted-icon.svg b/pkg-r/inst/www/commons-chat/figs/untrusted-icon.svg deleted file mode 100644 index bc4af8b9..00000000 --- a/pkg-r/inst/www/commons-chat/figs/untrusted-icon.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/pkg-r/inst/www/commons-chat/figs/warning-icon.svg b/pkg-r/inst/www/commons-chat/figs/warning-icon.svg index fa175077..bb3d1924 100644 --- a/pkg-r/inst/www/commons-chat/figs/warning-icon.svg +++ b/pkg-r/inst/www/commons-chat/figs/warning-icon.svg @@ -1,20 +1,7 @@ - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/pkg-r/tests/testthat/test-citation-browser.R b/pkg-r/tests/testthat/test-citation-browser.R index b9026f30..4cc2abff 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 citations as icon markers", { skip_on_cran() skip_if_not_installed("shinytest2") skip_if_not_installed("chromote") @@ -27,10 +27,8 @@ test_that("Shiny Chat renders native numbered streamed citations", { timeout = 30 * 1000 ) marker_selector <- paste0( - 'button[data-shinychat-aside-display="compact"]', - '[aria-label="Aside 1: documentation"],', - 'button[data-shinychat-aside-display="compact"]', - '[aria-label="Aside 2: sales table"]' + 'button.shiny-aside-pill[aria-label="documentation"],', + 'button.shiny-aside-pill[aria-label="sales table"]' ) app$wait_for_js( paste0("document.querySelectorAll('", marker_selector, "').length === 2;"), @@ -46,31 +44,19 @@ test_that("Shiny Chat renders native numbered streamed citations", { ".map((node) => node.textContent).join('|');" ) ), - "[1]|[2]" - ) - expect_identical( - app$get_js( - paste0( - "Array.from(document.querySelectorAll('", - marker_selector, - "'))", - ".map((node) => node.getAttribute('aria-label')).join('|');" - ) - ), - "Aside 1: documentation|Aside 2: sales table" + "documentation|sales table" ) expect_identical( app$get_js( paste0( "(() => {", "const style = getComputedStyle(document.querySelector(", - "'button[data-shinychat-aside-display=\"compact\"]'));", + "'button.shiny-aside-pill[aria-label=\"documentation\"]'));", "return [", "'--shiny-chat-aside-marker-color',", "'--shiny-chat-aside-marker-hover-color',", "'--shiny-chat-aside-marker-bg',", - "'--shiny-chat-aside-marker-hover-bg',", - "'--shiny-chat-aside-marker-font-family'", + "'--shiny-chat-aside-marker-hover-bg'", "].every((name) => style.getPropertyValue(name).trim() !== '');", "})()" ) @@ -88,16 +74,13 @@ 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 = "|" ) ) - first_marker <- paste0( - 'button[data-shinychat-aside-display="compact"]', - '[aria-label="Aside 1: documentation"]' - ) - first_dialog <- '[role="dialog"][aria-label="Aside 1: documentation"]' + first_marker <- 'button.shiny-aside-pill[aria-label="documentation"]' + first_dialog <- '[role="dialog"][aria-label="documentation"]' app$get_js( paste0("document.querySelector('", first_marker, "').click();") ) @@ -143,11 +126,8 @@ test_that("Shiny Chat renders native numbered streamed citations", { timeout = 30 * 1000 ) - second_marker <- paste0( - 'button[data-shinychat-aside-display="compact"]', - '[aria-label="Aside 2: sales table"]' - ) - second_dialog <- '[role="dialog"][aria-label="Aside 2: sales table"]' + second_marker <- 'button.shiny-aside-pill[aria-label="sales table"]' + second_dialog <- '[role="dialog"][aria-label="sales table"]' app$get_js( paste0("document.querySelector('", second_marker, "').click();") ) @@ -215,8 +195,7 @@ test_that("Shiny Chat distinguishes verified, cited, and untrusted asides", { paste0( "document.querySelector('button[aria-label=\"Verified answer\"]') && ", "document.querySelector(", - "'button[data-shinychat-aside-display=\"compact\"]", - "[aria-label=\"Aside 1: documentation\"]') && ", + "'button.shiny-aside-pill[aria-label=\"documentation\"]') && ", "document.querySelector('button[aria-label=\"Untrusted\"]');" ), timeout = 30 * 1000 @@ -248,21 +227,8 @@ test_that("Shiny Chat distinguishes verified, cited, and untrusted asides", { app$get_js( paste0( "document.querySelector(", - "'button[data-shinychat-aside-display=\"compact\"]", - "[aria-label=\"Aside 1: documentation\"]'", - ").getAttribute('aria-label');" - ) - ), - "Aside 1: documentation" - ) - - expect_identical( - app$get_js( - paste0( - "document.querySelector(", - "'button[data-shinychat-aside-display=\"compact\"]", - "[aria-label=\"Aside 1: documentation\"]'", - ").closest('p')?.innerText.includes('Supported claim.');" + "'button.shiny-aside-pill[aria-label=\"documentation\"]')", + ".closest('p')?.innerText.includes('Supported claim.');" ) ), TRUE @@ -319,9 +285,8 @@ test_that("Shiny Chat preserves Markdown blocks around citations", { app$wait_for_js( paste0( "document.querySelectorAll(", - "'button[data-shinychat-aside-display=\"compact\"]", - "[aria-label=\"Aside 1: documentation\"]'", - ").length === 2;" + "'button.shiny-aside-pill[aria-label=\"documentation\"]')", + ".length === 2;" ), timeout = 30 * 1000 ) @@ -338,9 +303,8 @@ test_that("Shiny Chat preserves Markdown blocks around citations", { app$get_js( paste0( "document.querySelectorAll(", - "'button[data-shinychat-aside-display=\"compact\"]", - "[aria-label=\"Aside 1: documentation\"]'", - ").length;" + "'button.shiny-aside-pill[aria-label=\"documentation\"]')", + ".length;" ) ), 2L diff --git a/pkg-r/tests/testthat/test-citations.R b/pkg-r/tests/testthat/test-citations.R index c856be9c..8a0108ee 100644 --- a/pkg-r/tests/testthat/test-citations.R +++ b/pkg-r/tests/testthat/test-citations.R @@ -393,7 +393,7 @@ test_that("user messages reset citation requests but tool results do not", { expect_true(tracker$requested) }) -test_that("render_citation_aside emits a numbered aside with source details", { +test_that("render_citation_aside emits a labeled aside with source details", { corpus <- list(list( label = "sales table", kind = "schema", @@ -404,14 +404,25 @@ test_that("render_citation_aside emits a numbered aside with source details", { "The computation follows the documented recognition rule.", corpus ) + # The pill renders the uniform quote mark; the per-kind icon and the + # source label appear in the body title instead. expect_match( out$html, paste0( - '^' + ), + fixed = TRUE + ) expect_no_match(out$html, "data:image", fixed = TRUE) expect_match( out$html,