R/app_ui.R

Defines functions golem_add_external_resources app_ui

#' The application User-Interface
#'
#' @param request Internal parameter for `{shiny}`.
#' @noRd
app_ui <- function(request) {
  tagList(
    golem_add_external_resources(),

    shinydashboard::dashboardPage(

      title = "Bayesian Prior Elicitation and Analysis",

      # -- Header --------------------------------------------------------------
      shinydashboard::dashboardHeader(
        title = tagList(
          tags$img(
            src    = "www/favicon.png",
            height = "44px",
            width  = "44px",
            style  = paste0(
              "margin-right:8px;",
              "margin-top:-3px;",
              "vertical-align:middle;",
              "border-radius:6px;",
                            "image-rendering:crisp-edges;"
            )
          ),
          tags$span(
            style = "font-size:20px; letter-spacing:0.5px; display:inline-flex; align-items:baseline;",
            tags$b("bay"),
            tags$span("prior", style = "font-weight:300;")
          )
        ),

        # Theme toggle in the header navbar
        tags$li(
          class = "dropdown",
          style = "padding-top:2px;",
          tags$a(
            href        = "#",
            id          = "theme_toggle",
            class       = "theme-toggle-btn",
            onclick     = "toggleTheme(); return false;",
            title       = "Toggle dark / light mode",
            icon("moon"), " Dark"
          )
        )
      ),

      # -- Sidebar -------------------------------------------------------------
      shinydashboard::dashboardSidebar(
        shinydashboard::sidebarMenu(
          id = "sidebar_menu",
          shinydashboard::menuItem(
            "Welcome",
            tabName  = "welcome",
            icon     = icon("house")
          ),
          shinydashboard::menuItem(
            tagList("Prior Elicitation", uiOutput("step_badge_elicit", inline = TRUE)),
            icon     = icon("pencil"),
            tabName  = "elicitation",
            shinydashboard::menuSubItem(
              "Parametric", tabName = "elicitation", icon = icon("circle")
            ),
            shinydashboard::menuSubItem(
              "Roulette",   tabName = "roulette",    icon = icon("th")
            )
          ),
          shinydashboard::menuItem(
            tagList("Expert Pooling", uiOutput("step_badge_pool", inline = TRUE)),
            tabName  = "pooling",
            icon     = icon("users")
          ),
          shinydashboard::menuItem(
            tagList("Conflict Diagnostics", uiOutput("step_badge_conflict", inline = TRUE)),
            icon     = icon("vial"),
            tabName  = "conflict",
            shinydashboard::menuSubItem(
              "Univariate",    tabName = "conflict",  icon = icon("circle")
            ),
            shinydashboard::menuSubItem(
              "Mahalanobis",   tabName = "mahal",     icon = icon("border-all")
            )
          ),
          shinydashboard::menuItem(
            tagList("Sensitivity Analysis", uiOutput("step_badge_sens", inline = TRUE)),
            tabName  = "sensitivity",
            icon     = icon("chart-bar")
          ),
          shinydashboard::menuItem(
            tagList("Robust Priors", uiOutput("step_badge_robust", inline = TRUE)),
            icon     = icon("shield-halved"),
            tabName  = "robust",
            shinydashboard::menuSubItem(
              "Robust Mixture", tabName = "robust",    icon = icon("layer-group")
            ),
            shinydashboard::menuSubItem(
              "Sceptical",      tabName = "sceptical", icon = icon("scale-balanced")
            ),
            shinydashboard::menuSubItem(
              "Power Prior",    tabName = "power",     icon = icon("bolt")
            )
          ),
          shinydashboard::menuItem(
            "Export Report",
            tabName  = "report",
            icon     = icon("file-export")
          )
        ),

        # Active prior badge + version footer
        tags$div(
          style = "position:absolute; bottom:0; width:100%; padding:4px 0;",
          tags$div(
            style = paste0(
              "font-size:10px; color:#aaa; text-transform:uppercase;",
              "letter-spacing:0.8px; padding:0 12px 2px;"
            ),
            "Active prior:"
          ),
          uiOutput("sidebar_prior_badge"),
          tags$div(
            style = paste0(
              "font-size:9px; color:#666; text-align:center;",
              "padding:4px 0 6px; border-top:1px solid #333; margin-top:4px;"
            ),
            paste0("bayprior v",
                   tryCatch(as.character(utils::packageVersion("bayprior")),
                            error = function(e) "dev"))
          )
        )
      ),

      # -- Body -----------------------------------------------------------------
      shinydashboard::dashboardBody(

        # Theme toggle JS
        tags$head(
          tags$script(HTML("
            function isDark() {
              return document.body.classList.contains('dark-mode');
            }

            // Restore Plotly axis/text colours when switching to light mode.
            function relayoutPlotly() {
              var fg   = '#444444';
              var grid = '#eeeeee';
              document.querySelectorAll('.js-plotly-plot').forEach(function(el) {
                try {
                  Plotly.relayout(el, {
                    paper_bgcolor: '#ffffff', plot_bgcolor: '#ffffff',
                    'font.color': fg,
                    'xaxis.gridcolor': grid, 'xaxis.linecolor': grid,
                    'xaxis.zerolinecolor': grid, 'xaxis.tickfont.color': fg,
                    'xaxis.titlefont.color': fg,
                    'yaxis.gridcolor': grid, 'yaxis.linecolor': grid,
                    'yaxis.zerolinecolor': grid, 'yaxis.tickfont.color': fg,
                    'yaxis.titlefont.color': fg,
                    'legend.bgcolor': '#f8f8f8', 'legend.font.color': fg
                  });
                } catch(e) {}
              });
            }

            // -- Three-state theme: auto | dark | light ---------------------
            // State is stored in localStorage:
            //   null / absent = Auto (follows OS prefers-color-scheme)
            //   'dark'        = Manual dark
            //   'light'       = Manual light
            //
            // Clicking the toggle cycles: Auto -> Dark -> Light -> Auto

            function getThemeState() {
              return localStorage.getItem('bayprior_theme') || 'auto';
            }

            function applyThemeState(state) {
              var btn = document.getElementById('theme_toggle');
              if (state === 'dark') {
                document.body.classList.add('dark-mode');
                if (btn) btn.innerHTML =
                  '<i class=\"fa fa-moon\"></i> Dark' +
                  '<span style=\"font-size:9px;opacity:0.6;margin-left:4px;\">(manual)</span>';
              } else if (state === 'light') {
                document.body.classList.remove('dark-mode');
                relayoutPlotly();
                if (btn) btn.innerHTML =
                  '<i class=\"fa fa-sun\"></i> Light' +
                  '<span style=\"font-size:9px;opacity:0.6;margin-left:4px;\">(manual)</span>';
              } else {
                // Auto -- follow OS
                var osDark = window.matchMedia &&
                             window.matchMedia('(prefers-color-scheme: dark)').matches;
                if (osDark) {
                  document.body.classList.add('dark-mode');
                } else {
                  document.body.classList.remove('dark-mode');
                  relayoutPlotly();
                }
                if (btn) btn.innerHTML =
                  '<i class=\"fa fa-circle-half-stroke\"></i> Auto' +
                  '<span style=\"font-size:9px;opacity:0.6;margin-left:4px;\">' +
                  (osDark ? '(dark)' : '(light)') + '</span>';
              }
              if (window.Shiny)
                Shiny.setInputValue('app_theme', isDark() ? 'dark' : 'light',
                                    {priority:'event'});
            }

            function toggleTheme() {
              var current = getThemeState();
              var next = current === 'auto'  ? 'dark'  :
                         current === 'dark'  ? 'light' : 'auto';
              if (next === 'auto') {
                localStorage.removeItem('bayprior_theme');
              } else {
                localStorage.setItem('bayprior_theme', next);
              }
              applyThemeState(next);
            }

            // -- Initialise on page load -------------------------------------
            // Migration: earlier two-state toggle saved 'light' on first
            // load without explicit user action. Clear stale preferences
            // from before the three-state system was introduced.
            (function() {
              var state   = localStorage.getItem('bayprior_theme');
              var version = localStorage.getItem('bayprior_theme_v');
              if (state && !version) {
                // Saved by old two-state toggle -- treat as auto
                localStorage.removeItem('bayprior_theme');
              }
              // Mark that we are using the versioned three-state system
              localStorage.setItem('bayprior_theme_v', '2');
              applyThemeState(getThemeState());
            })();

            // MutationObserver: AdminLTE sometimes resets body class list
            // during its own initialisation, stripping 'dark-mode'.
            // Watch for that and immediately re-apply the saved preference.
            (function() {
              var obs = new MutationObserver(function() {
                var desired = getThemeState();
                var hasDark = document.body.classList.contains('dark-mode');
                if (desired === 'dark' && !hasDark)
                  document.body.classList.add('dark-mode');
                else if (desired === 'light' && hasDark)
                  document.body.classList.remove('dark-mode');
                else if (desired === 'auto') {
                  var osDark = window.matchMedia &&
                               window.matchMedia('(prefers-color-scheme: dark)').matches;
                  if (osDark && !hasDark)
                    document.body.classList.add('dark-mode');
                  else if (!osDark && hasDark)
                    document.body.classList.remove('dark-mode');
                }
              });
              obs.observe(document.body, { attributes: true,
                                           attributeFilter: ['class'] });
              // Disconnect once Shiny is fully ready -- normal toggle takes over
              document.addEventListener('shiny:sessioninitialized', function() {
                obs.disconnect();
                applyThemeState(getThemeState()); // final apply
              }, { once: true });
            })();

            // -- React to OS changes at runtime ------------------------------
            // Only applies when in Auto mode (no manual preference saved)
            if (window.matchMedia) {
              window.matchMedia('(prefers-color-scheme: dark)')
                .addEventListener('change', function(e) {
                  if (getThemeState() === 'auto') {
                    applyThemeState('auto');
                  }
                });
            }

            // -- Set correct button label after Shiny renders the header -----
            document.addEventListener('shiny:sessioninitialized', function() {
              applyThemeState(getThemeState());
            });

            document.addEventListener('shiny:connected', function() {
              if (window.Shiny)
                Shiny.setInputValue('app_theme', isDark() ? 'dark' : 'light',
                                    {priority:'event'});
            });
          "))
        ),
        shinyjs::useShinyjs(),

        # -- Disabled button + tooltip styles ---------------------------------
        tags$style(HTML("
          .btn[disabled], .btn.disabled {
            opacity: 0.45 !important;
            cursor: not-allowed !important;
            pointer-events: auto !important;
          }
          .btn-tip-wrap {
            position: relative; display: block; width: 100%;
          }
          .btn-tip-wrap .btn-tip-text {
            visibility: hidden; opacity: 0;
            background: #333; color: #fff;
            font-size: 11px; border-radius: 4px;
            padding: 4px 8px; white-space: nowrap;
            position: absolute; bottom: 110%; left: 50%;
            transform: translateX(-50%);
            transition: opacity 0.15s;
            z-index: 9999; pointer-events: none;
          }
          .btn-tip-wrap:hover .btn-tip-text {
            visibility: visible; opacity: 1;
          }
        ")),


        # -- Toast notification + clipboard styles ----------------------------
        tags$style(HTML("
          /* Toast notifications */
          #bp-toast-container {
            position: fixed; top: 64px; right: 16px;
            z-index: 99999; display: flex; flex-direction: column; gap: 8px;
          }
          .bp-toast {
            background: #1D9E75; color: #fff;
            padding: 10px 16px; border-radius: 6px;
            font-size: 13px; font-family: Arial, sans-serif;
            box-shadow: 0 4px 12px rgba(0,0,0,0.25);
            display: flex; align-items: center; gap: 8px;
            animation: bp-slide-in 0.25s ease;
            max-width: 300px;
          }
          .bp-toast.bp-toast-error { background: #c0392b; }
          .bp-toast.bp-toast-warn  { background: #e08030; }
          @keyframes bp-slide-in {
            from { opacity: 0; transform: translateX(40px); }
            to   { opacity: 1; transform: translateX(0); }
          }
          @keyframes bp-slide-out {
            from { opacity: 1; transform: translateX(0); }
            to   { opacity: 0; transform: translateX(40px); }
          }
        ")),

        tags$script(HTML("
          // -- Toast system -----------------------------------------------
          (function() {
            function ensureContainer() {
              var c = document.getElementById('bp-toast-container');
              if (!c) {
                c = document.createElement('div');
                c.id = 'bp-toast-container';
                document.body.appendChild(c);
              }
              return c;
            }
            window.bpToast = function(msg, type, duration) {
              var container = ensureContainer();
              var t = document.createElement('div');
              t.className = 'bp-toast' +
                (type === 'error' ? ' bp-toast-error' :
                 type === 'warn'  ? ' bp-toast-warn'  : '');
              t.innerHTML = (type === 'error' ? '<i class=\'fa fa-circle-xmark\'></i> ' :
                             type === 'warn'  ? '<i class=\'fa fa-triangle-exclamation\'></i> ' :
                                               '<i class=\'fa fa-circle-check\'></i> ') + msg;
              container.appendChild(t);
              setTimeout(function() {
                t.style.animation = 'bp-slide-out 0.25s ease forwards';
                setTimeout(function() { if (t.parentNode) t.parentNode.removeChild(t); },
                           280);
              }, duration || 3000);
            };
          })();

          // -- Clipboard helper -------------------------------------------

          // -- Plot save helper -------------------------------------------
        ")),

        # Inline dark mode Plotly fix -- invert filter at compositor level
        # This overrides Plotly's inline SVG styles which CSS selectors cannot touch
        tags$style(HTML("
          /* White background on the container div so the CSS filter has
             a solid surface to invert -- transparent SVG fill bleeds the
             dark box colour through, which inverts to lavender.
             White inverted = black = correct dark background. */
          body.dark-mode .js-plotly-plot {
            background-color: #ffffff !important;
            filter: invert(1) hue-rotate(180deg);
          }
          body.dark-mode .js-plotly-plot .modebar {
            filter: invert(1) hue-rotate(180deg);
          }
        ")),

        shinydashboard::tabItems(

          shinydashboard::tabItem(
            tabName = "welcome",
            mod_welcome_ui("welcome")
          ),
          shinydashboard::tabItem(
            tabName = "elicitation",
            mod_elicitation_ui("elicitation")
          ),
          shinydashboard::tabItem(
            tabName = "roulette",
            mod_roulette_ui("roulette")
          ),
          shinydashboard::tabItem(
            tabName = "pooling",
            mod_pooling_ui("pooling")
          ),
          shinydashboard::tabItem(
            tabName = "conflict",
            mod_conflict_ui("conflict")
          ),
          shinydashboard::tabItem(
            tabName = "mahal",
            mod_mahal_ui("mahal")
          ),
          shinydashboard::tabItem(
            tabName = "sensitivity",
            mod_sensitivity_ui("sensitivity")
          ),
          shinydashboard::tabItem(
            tabName = "robust",
            mod_robust_ui("robust")
          ),
          shinydashboard::tabItem(
            tabName = "sceptical",
            mod_sceptical_ui("sceptical")
          ),
          shinydashboard::tabItem(
            tabName = "power",
            mod_power_ui("power")
          ),
          shinydashboard::tabItem(
            tabName = "report",
            mod_report_ui("report")
          )
        )
      )
    )
  )
}

#' Add external Resources to the Application
#' @noRd
golem_add_external_resources <- function() {
  addResourcePath("www", app_sys("app/www"))
  tags$head(
    tags$link(rel = "icon", type = "image/png",
              href = "www/favicon.png"),
    tags$link(rel = "shortcut icon", type = "image/png",
              href = "www/favicon.png"),
    tags$link(rel = "stylesheet", type = "text/css",
               href = "www/bayprior-dark.css")
  )
}

Try the bayprior package in your browser

Any scripts or data that you put into this service are public.

bayprior documentation built on Aug. 27, 2026, 1:09 a.m.