# User interface: the page, its parts, and small helpers that build the
# dynamic inputs and outputs. The look is in www/theme.css, the behaviour in
# the browser (tabs, Help switch, menu, video, Copy) in www/app.js.
# The address of the video ("" for none). The page reads it through this
# function, so that a test can replace it.
video_url <- function() VIDEO_URL
# The embed address of a YouTube video, on youtube-nocookie.com ("" if the
# address holds no video id)
video_embed <- function(url) {
id <- stringr::str_match(url, "(?:v=|youtu\\.be/|/embed/)([A-Za-z0-9_-]{11})")[, 2]
if (is.na(id)) "" else paste0("https://www.youtube-nocookie.com/embed/", id)
}
# The data sources of the sidebar
SOURCE_CHOICES <- c(
"Sample \u00b7 woven textiles (images, labelled)" = "textiles",
"Sample \u00b7 injury reports (texts, labelled)" = "injuries",
"Your own data" = "upload"
)
# The sources of the data that go with a mode
source_choices <- function(mode) {
SOURCE_CHOICES[SOURCE_CHOICES %in% c(if (mode == "image") "textiles" else "injuries", "upload")]
}
# The file types of the image uploads
IMAGE_ACCEPT <- paste0(".", IMAGE_EXTENSIONS)
# --- Small parts ------------------------------------------------------------
# Text for screen readers only
sr <- function(text) shiny::span(class = "sr-only", text)
# One help line of the sidebar, and one Help note of a card. The Help switch
# hides both (class "help").
help_line <- function(text) shiny::p(class = "help help-line", text)
note_box <- function(...) shiny::div(class = "howto help", shiny::strong("Help."), " ", ...)
# Radio buttons that look like a segmented control
seg_radio <- function(id, label, choices, selected = NULL, class = "seg") {
shiny::div(
class = class,
shiny::radioButtons(id, sr(label), choices, selected = selected, inline = TRUE)
)
}
# A file input. The read-only box that shows the chosen file gets a name for
# screen readers (Shiny gives it none) and no tab stop (it does nothing).
upload_input <- function(id, label, accept, multiple = FALSE, button = "Browse") {
input <- shiny::fileInput(id, label, multiple = multiple, accept = accept, buttonLabel = button)
htmltools::tagQuery(input)$
find("input.form-control")$
addAttrs(`aria-label` = "Chosen file", tabindex = "-1")$
allTags()
}
# Small icons for the buttons of the R code card
icon_svg <- function(path) {
htmltools::HTML(sprintf(
'',
path
))
}
ICON_COPY <- "M5 5h8v8H5zM3 11V3h8"
ICON_DOWNLOAD <- "M8 2v8M4.5 6.5 8 10l3.5-3.5M3 13h10"
# The links of the page open in a new tab: the app runs in a frame on
# Hugging Face, and the visitor keeps the session
ext_link <- function(text, href) shiny::a(href = href, target = "_blank", rel = "noopener", text)
# --- Top bar ----------------------------------------------------------------
tab_button <- function(id, label, selected = FALSE) {
shiny::tags$button(
type = "button", role = "tab", id = paste0("tab-", id),
`aria-controls` = paste0("panel-", id), `aria-selected` = tolower(selected),
tabindex = if (selected) "0" else "-1", label
)
}
help_switch <- function() {
shiny::tags$label(
class = "switch", `for` = "help_on",
shiny::tags$input(id = "help_on", type = "checkbox", role = "switch", checked = NA),
shiny::span(class = "track", `aria-hidden` = "true"),
"Help"
)
}
samples_menu <- function() {
shiny::tags$details(
id = "samples_menu", class = "menu",
shiny::tags$summary(class = "btn btn-red", "Samples"),
shiny::div(
class = "menu-list",
shiny::actionButton("sample_textiles", "Woven textiles (images)", class = "menu-item"),
shiny::actionButton("sample_injuries", "Injury reports (texts)", class = "menu-item")
)
)
}
top_bar <- function(video) {
shiny::tags$header(
class = "topbar",
shiny::div(
class = "brand",
shiny::img(src = "logo.svg", alt = "", class = "logo", width = "28", height = "32"),
shiny::h1(class = "name", "decisions"),
shiny::span(class = "tag", "Decision models")
),
shiny::tags$nav(
class = "tabs", `aria-label` = "Pages",
shiny::div(
role = "tablist",
tab_button("build", "Build", TRUE), tab_button("about", "About"),
tab_button("record", "Uptime")
)
),
shiny::div(
class = "topbar-right",
help_switch(),
shiny::span(
class = "chip", shiny::span(class = "dot", `aria-hidden` = "true", "\u25cf"), " ",
shiny::textOutput("status_chip", inline = TRUE)
),
shiny::div(
class = "import-btn",
upload_input("import_file", NULL, c(".zip", ".json"), button = "Import")
),
samples_menu(),
# The quiet way to the help video: on every tab, and after Hide
if (nzchar(video)) {
shiny::tags$button(
id = "open_video_bar", type = "button", class = "btn btn-quiet help-video-bar open-video",
`aria-haspopup` = "dialog", shiny::span(class = "tri", `aria-hidden` = "true"), "Help video"
)
}
)
)
}
# --- Build tab: sidebar -----------------------------------------------------
# A numbered group of the sidebar, such as "01 - Data"
group <- function(num, title, ...) {
shiny::div(class = "group", shiny::h2(class = "eyebrow", shiny::tags$b(num), " \u00b7 ", title), ...)
}
sidebar <- function() {
shiny::tags$aside(
class = "card sidebar", `aria-label` = "Setup",
group(
"01", "Data",
seg_radio("mode", "Kind of data", c(Images = "image", Text = "text")),
shiny::selectInput(
"source", sr("Source"), source_choices("image"), selected = "upload", selectize = FALSE
),
shiny::conditionalPanel(
"input.source == 'upload' && input.mode == 'image'",
upload_input("upload_images", "Images or zip file", c(IMAGE_ACCEPT, ".zip"), multiple = TRUE)
),
shiny::conditionalPanel(
"input.source == 'upload' && input.mode == 'text'",
shiny::textAreaInput("paste_text", "Texts", rows = 4),
upload_input("upload_csv", "CSV file", ".csv"),
shiny::selectInput("csv_text_col", "Text column", character(), selectize = FALSE),
shiny::selectInput("csv_label_col", "Label column", character(), selectize = FALSE)
),
help_line(help_text("data"))
),
group(
"02", "Question",
seg_radio(
"qtype", "Type of question",
c("Yes / no" = "noul", Choice = "choice", Score = "score"), selected = START_QUESTION$type
),
shiny::textAreaInput("instructions", "Instructions", START_QUESTION$instructions, rows = 2),
shiny::uiOutput("answers_ui"),
help_line(help_text("question"))
),
shiny::conditionalPanel(
"input.mode == 'image'",
group(
"03", "References",
shiny::conditionalPanel("input.qtype != 'score'", help_line(help_text("references"))),
shiny::conditionalPanel("input.qtype == 'score'", help_line(help_text("references_score"))),
shiny::uiOutput("references_ui")
)
),
shiny::div(
class = "run-row",
shiny::actionButton("try5", "Try on 5", class = "btn-ghost"),
shiny::actionButton(
"run_all", shiny::tagList("Run all ", shiny::textOutput("run_estimate", inline = TRUE)),
class = "btn-red"
)
),
shiny::div(class = "stop-row", shiny::actionButton("stop", "Stop", class = "btn-ghost")),
htmltools::tagAppendChild(shiny::uiOutput("spend_meter"), spend_meter_ui(0)),
shiny::p(class = "privacy", help_text("privacy")),
shiny::div(class = "limits", role = "status", shiny::textOutput("limits_text", inline = TRUE))
)
}
# --- Build tab: cards -------------------------------------------------------
# The yellow button that opens the help video (in the intro and in About)
help_video_button <- function(id) {
shiny::tags$button(
id = id, type = "button", class = "btn-help-video open-video", `aria-haspopup` = "dialog",
shiny::span(class = "play", `aria-hidden` = "true"),
sprintf("Watch our %d-minute intro video on how to use this app", VIDEO_MINUTES)
)
}
# The intro, with the help video as the first step of a first visit
intro_card <- function(video = video_url()) {
step <- \(n, name, text) {
shiny::tags$li(
shiny::span(class = "num", `aria-hidden` = "true", n),
shiny::span(shiny::strong(name), " \u00b7 ", text)
)
}
shiny::tags$section(
id = "intro", class = "card intro", `aria-label` = "Intro",
shiny::div(
shiny::p(class = "intro-lead", "Ask a model a question about each image or text."),
shiny::p(class = "intro-help", help_text("intro"), " ", shiny::actionLink("intro_hide", "Hide")),
if (nzchar(video)) shiny::div(class = "help-video-row", help_video_button("open_video"))
),
shiny::tags$ol(
class = "steps",
step("1", "Question", "a question, its answers and a few examples"),
step(
"2", "Answers",
"an answer and a probability for each input; with labels, how often Jev is right"
),
step("3", "R code", "the same run in R, to copy or download")
)
)
}
# A region that scrolls sideways; it takes keyboard focus, so that the arrow
# keys can scroll it
scroll_region <- function(label, ...) {
shiny::div(class = "table-wrap", tabindex = "0", role = "region", `aria-label` = label, ...)
}
answers_card <- function() {
shiny::tags$section(
class = "card answers-card", `aria-labelledby` = "answers_title",
shiny::div(
class = "hd",
shiny::h2(
id = "answers_title", class = "card-title", tabindex = "-1",
shiny::span(class = "eyebrow-num", `aria-hidden` = "true", "1"), "Answers"
),
shiny::div(
class = "hd-tools",
shiny::selectInput("target", "Target", character(), selectize = FALSE, width = "auto"),
seg_radio("view", "View", c(Gallery = "gallery", Table = "table"), class = "seg vtabs")
)
),
shiny::div(
class = "bd",
shiny::uiOutput("stats"),
shiny::uiOutput("empty_answers"),
note_box(shiny::span(id = "help_answers", class = "shiny-text-output", help_text("answers"))),
shiny::conditionalPanel("input.view == 'gallery'", shiny::uiOutput("gallery")),
shiny::conditionalPanel(
"input.view == 'table'",
scroll_region("Answers table", shiny::tableOutput("table"))
),
shiny::div(
class = "card-foot",
shiny::downloadButton("download_answers", "Answers .csv", class = "btn-quiet sm", icon = NULL)
)
)
)
}
# Server: shiny::outputOptions(output, id, suspendWhenHidden = FALSE) for "code", "bundle_files", "code_summary"
code_card <- function() {
code <- htmltools::tagAppendAttributes(
shiny::verbatimTextOutput("code", placeholder = FALSE),
class = "code", tabindex = "0", role = "region", `aria-label` = "R code of this run"
)
shiny::tags$section(
class = "card code-card", `aria-labelledby` = "code_title",
# A heading for screen readers (the visible title is the summary line)
shiny::h2(id = "code_title", class = "sr-only", "R code"),
# The summary holds only the summary line; the buttons sit outside it
shiny::tags$details(
class = "code-details",
shiny::tags$summary(
class = "code-summary",
shiny::span(class = "eyebrow-num", `aria-hidden` = "true", "2"),
shiny::span(id = "code_summary", class = "shiny-text-output", "R code")
),
shiny::div(class = "bd", note_box(help_text("code")), code, shiny::uiOutput("bundle_files"))
),
shiny::div(
class = "code-actions",
shiny::tags$button(
type = "button", class = "btn btn-quiet sm copy-btn", `data-target` = "code",
icon_svg(ICON_COPY), shiny::span(class = "copy-label", "Copy")
),
shiny::downloadButton(
"download_code", shiny::tagList(icon_svg(ICON_DOWNLOAD), "Download .R"),
class = "btn-quiet sm", icon = NULL
),
shiny::downloadButton(
"download_bundle", shiny::tagList(icon_svg(ICON_DOWNLOAD), "Bundle .zip"),
class = "btn-red sm", icon = NULL
)
),
shiny::div(id = "copy_status", class = "sr-only", role = "status")
)
}
build_panel <- function() {
shiny::div(
class = "build", sidebar(),
shiny::div(class = "cards", intro_card(), answers_card(), code_card())
)
}
# --- About and Uptime tabs --------------------------------------------------
about_panel <- function() {
# The About text is Markdown; its links open in a new tab. The button of
# the help video goes at its mark.
html <- shiny::markdown(help_text("about")) |>
as.character() |>
stringr::str_replace_all("