diff --git a/DESCRIPTION b/DESCRIPTION index f633c8f78..a0e906076 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -30,6 +30,7 @@ Depends: R (>= 2.10) Imports: base64enc, + brand.yml (>= 0.1.0.9000), cachem, fastmap (>= 1.1.1), grDevices, @@ -42,7 +43,6 @@ Imports: rlang, sass (>= 0.4.9) Suggests: - brand.yml, bsicons, curl, fontawesome, @@ -60,6 +60,8 @@ Suggests: utils, withr, yaml +Remotes: + posit-dev/brand-yml@feat/brand-color-light-dark Config/Needs/deploy: BH, chiflights22, colourpicker, commonmark, cpp11, cpsievert/chiflights22, cpsievert/histoslider, dplyr, DT, ggplot2, ggridges, gt, hexbin, histoslider, htmlwidgets, lattice, leaflet, diff --git a/NEWS.md b/NEWS.md index 78b6c5f7c..d9f9d2423 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,10 @@ ## Improvements +* `bs_theme(brand = ...)` now supports light and dark color and typography + variants from `{brand.yml}`. Bootstrap switches the generated variables + through its existing `data-bs-theme` attribute. (posit-dev/brand-yml#120) + * Navsets created with an `id` (e.g. `navset_tab(id = "tabs")`) now use that `id` as their `data-tabsetid`, so their tab panes get stable `tab-tabs-1` style DOM ids instead of ones derived from a random integer. This makes the rendered markup reproducible across renders and easier to target from custom CSS and JavaScript. Navsets without an `id`, and `nav_menu()` dropdowns, keep the random ID. (#1342) # bslib 0.12.0 diff --git a/R/bs-dependencies.R b/R/bs-dependencies.R index 2ddd47e47..13c8362b8 100644 --- a/R/bs-dependencies.R +++ b/R/bs-dependencies.R @@ -144,6 +144,7 @@ maybe_precompiled_css <- function(theme, sass_options, precompiled) { sass_compile_theme <- function(theme, sass_options, sass_cache) { version <- theme_version(theme) + brand <- attr(theme, "brand") contrast_warn <- get_shiny_devmode_option( "bslib.color_contrast_warnings", @@ -155,6 +156,7 @@ sass_compile_theme <- function(theme, sass_options, sass_cache) { ) ) theme <- bs_add_variables(theme, "color-contrast-warnings" = contrast_warn) + attr(theme, "brand") <- brand out_file <- sass( input = theme, @@ -168,11 +170,156 @@ sass_compile_theme <- function(theme, sass_options, sass_cache) { ) ) + dark_css <- bs_brand_dark_css( + theme = theme, + sass_options = sass_options, + sass_cache = sass_cache + ) + if (nzchar(dark_css)) { + out_file <- append_brand_dark_css(out_file, dark_css) + } + bootstrap_javascript_copy_assets(version, dirname(out_file)) out_file } +bs_brand_dark_css <- function(theme, sass_options, sass_cache) { + brand <- attr(theme, "brand") + if (is.null(brand) || theme_version(theme) < 5) { + return("") + } + + dark_theme <- bs_brand_dark_theme(theme, brand) + css <- sass( + input = dark_theme, + options = sass_options, + cache = sass_cache, + cache_key_extra = list( + "bslib-brand-dark", + get_exact_version(theme_version(theme)), + get_package_version("bslib") + ) + ) + + sub( + ":root\\s*,\\s*\\[data-bs-theme=(?:\"light\"|light)\\]", + '[data-bs-theme="dark"]', + css, + perl = TRUE + ) +} + +bs_brand_dark_theme <- function(theme, brand) { + brand_layers <- c("brand_base", "brand_defaults", "brand") + brand_idx <- match("brand_base", names(theme$layers)) + if (is.na(brand_idx)) { + return(theme) + } + + layers <- theme$layers[!names(theme$layers) %in% brand_layers] + dark_bundle <- bs_brand_dark_bundle(brand) + dark_layers <- dark_bundle$layers + layers <- append(layers, dark_layers, after = brand_idx - 1L) + + # Bootstrap components contain Sass-derived colors that root variables alone + # cannot switch. Recompile the theme's rules inside the dark mode selector. + scoped_rules <- if (isTRUE(attr(dark_bundle, "has_dark_variants"))) { + unlist( + lapply(seq_along(layers), function(i) { + if (identical(names(layers)[[i]], "_root")) { + return(NULL) + } + layers[[i]]$rules + }), + recursive = FALSE + ) + } + + layers <- lapply(layers, function(layer) { + layer$rules <- NULL + layer + }) + dark_rule_layers <- list( + "_brand_dark_root" = as_sass_layer(sass_layer( + rules = bs5_sass_files("root") + )) + ) + if (length(scoped_rules)) { + dark_rule_layers[["_brand_dark_components"]] <- as_sass_layer(sass_layer( + rules = c( + list('[data-bs-theme="dark"] {'), + scoped_rules, + list("}") + ) + )) + } + dark_rule_layers[["_brand_dark_runtime"]] <- as_sass_layer(sass_layer_file( + system_file( + "brand", + "bs5", + "_brand-yml-dark-rules.scss", + package = "bslib" + ) + )) + layers <- c(layers, dark_rule_layers) + + structure( + list(layers = layers), + class = class(theme) + ) +} + +bs_brand_dark_bundle <- function(brand) { + brand_fonts <- brand.yml::brand_sass_fonts(brand) + brand_color_palette <- brand.yml::brand_sass_color_palette(brand) + brand_color <- brand.yml::brand_sass_color(brand) + brand_defaults <- brand.yml::brand_sass_defaults_bootstrap(brand) + brand_typography <- brand.yml::brand_sass_typography(brand) + + bundle <- sass_bundle( + "brand_dark_base" = sass_layer_file( + system_file("brand", "bs5", "_brand-yml-dark.scss", package = "bslib") + ), + "brand_dark_bootstrap_defaults" = brand_defaults$layer, + "brand_dark_defaults" = sass_layer( + defaults = list2( + !!!brand_color_palette$defaults, + !!!brand_defaults$defaults, + !!!(brand_color$defaults_dark %||% brand_color$defaults), + !!!brand_fonts$defaults, + !!!(brand_typography$defaults_dark %||% brand_typography$defaults) + ) + ) + ) + attr(bundle, "has_dark_variants") <- + !is.null(brand_color$defaults_dark) || + !is.null(brand_typography$defaults_dark) + bundle +} + +append_brand_dark_css <- function(out_file, dark_css) { + suffix <- substr(rlang::hash(dark_css), 1L, 12L) + ext <- tools::file_ext(out_file) + out_file_dark <- file.path( + dirname(out_file), + sprintf( + "%s-brand-dark-%s.%s", + tools::file_path_sans_ext(basename(out_file)), + suffix, + ext + ) + ) + + if (!file.exists(out_file_dark)) { + file.copy(out_file, out_file_dark) + cat("\n", dark_css, "\n", file = out_file_dark, append = TRUE, sep = "") + } + + attributes(out_file_dark) <- attributes(out_file) + out_file_dark +} + bootstrap_javascript_copy_assets <- function(version, to) { js_files <- bootstrap_javascript(version) js_map_files <- bootstrap_javascript_map(version) diff --git a/R/bs-remove.R b/R/bs-remove.R index b31c4d299..9c3e50bf7 100644 --- a/R/bs-remove.R +++ b/R/bs-remove.R @@ -30,7 +30,11 @@ #' bs4_no_compat <- bs_remove(bs4, "bs3compat") bs_remove <- function(theme, ids = character(0)) { ids <- retain_valid_ids(theme, ids) - sass_bundle_remove(theme, ids) + result <- preserve_theme_attributes(sass_bundle_remove(theme, ids), theme) + if (any(ids %in% c("brand_base", "brand_defaults", "brand"))) { + attr(result, "brand") <- NULL + } + result } #' @rdname bs_remove diff --git a/R/bs-theme-layers.R b/R/bs-theme-layers.R index c540882f8..29ff2d300 100644 --- a/R/bs-theme-layers.R +++ b/R/bs-theme-layers.R @@ -177,8 +177,20 @@ bs_add_mixins <- function(theme, mixins) { #' @export bs_bundle <- function(theme, ...) { assert_bs_theme(theme) - structure( + result <- structure( sass_bundle(theme, ...), class = class(theme) ) + preserve_theme_attributes(result, theme) +} + +preserve_theme_attributes <- function(result, theme) { + extra <- attributes(theme) + extra[c("names", "class")] <- NULL + + result_attrs <- attributes(result) + result_attrs[names(extra)] <- extra + attributes(result) <- result_attrs + + result } diff --git a/inst/brand/bs5/_brand-yml-dark-rules.scss b/inst/brand/bs5/_brand-yml-dark-rules.scss new file mode 100644 index 000000000..b345251f8 --- /dev/null +++ b/inst/brand/bs5/_brand-yml-dark-rules.scss @@ -0,0 +1,18 @@ +/*-- scss:rules --*/ + +[data-bs-theme="dark"] { + @if $link-bg == null { + --#{$prefix}link-bg: initial; + } @else { + --#{$prefix}link-bg: #{$link-bg}; + } +} + +[data-bs-theme="dark"] code { + background-color: $code-bg; +} + +[data-bs-theme="dark"] pre { + color: $pre-color; + background-color: $pre-bg; +} diff --git a/inst/brand/bs5/_brand-yml-dark.scss b/inst/brand/bs5/_brand-yml-dark.scss new file mode 100644 index 000000000..7bdc532da --- /dev/null +++ b/inst/brand/bs5/_brand-yml-dark.scss @@ -0,0 +1,70 @@ +/*-- scss:defaults --*/ + +// Sass variables from the dark brand layer are inserted above this file. This +// isolated compile maps them to both the default and dark Bootstrap variables, +// so Bootstrap's root stylesheet can generate a complete dark-mode override. +$brand_color_foreground: null !default; +$brand_color_background: null !default; +$brand_color_primary: null !default; +$brand_color_secondary: null !default; +$brand_color_tertiary: null !default; +$brand_color_success: null !default; +$brand_color_info: null !default; +$brand_color_warning: null !default; +$brand_color_danger: null !default; +$brand_color_light: null !default; +$brand_color_dark: null !default; +$brand_color_link: null !default; +$brand_typography_base_family: null !default; +$brand_typography_base_size: null !default; +$brand_typography_base_line-height: null !default; +$brand_typography_base_weight: null !default; +$brand_typography_headings_color: null !default; +$brand_typography_monospace_inline_color: null !default; +$brand_typography_monospace_inline_background_color: null !default; +$brand_typography_monospace_block_color: null !default; +$brand_typography_monospace_block_background_color: null !default; +$brand_typography_link_color: null !default; +$brand_typography_link_background_color: null !default; + +$primary: $brand_color_primary !default; +$secondary: $brand_color_secondary !default; +$tertiary: $brand_color_tertiary !default; +$success: $brand_color_success !default; +$info: $brand_color_info !default; +$warning: $brand_color_warning !default; +$danger: $brand_color_danger !default; +$light: $brand_color_light !default; +$dark: $brand_color_dark !default; + +$body-color: $brand_color_foreground !default; +$body-bg: $brand_color_background !default; +$body-color-dark: $brand_color_foreground !default; +$body-bg-dark: $brand_color_background !default; +$body-secondary-color: $brand_color_secondary !default; +$body-secondary-color-dark: $brand_color_secondary !default; +$body-tertiary-color: $brand_color_tertiary !default; +$body-tertiary-color-dark: $brand_color_tertiary !default; + +$font-family-base: $brand_typography_base_family !default; +$font-size-base: $brand_typography_base_size !default; +$line-height-base: $brand_typography_base_line-height !default; +$font-weight-base: $brand_typography_base_weight !default; +$headings-color: $brand_typography_headings_color !default; +$headings-color-dark: $brand_typography_headings_color !default; +$code-color: $brand_typography_monospace_inline_color !default; +$code-color-dark: $brand_typography_monospace_inline_color !default; +$code-bg: $brand_typography_monospace_inline_background_color !default; +$pre-color: $brand_typography_monospace_block_color !default; +$pre-bg: $brand_typography_monospace_block_background_color !default; +$link-color: if( + $brand_typography_link_color != null, + $brand_typography_link_color, + $brand_color_link +) !default; +$link-color-dark: if( + $brand_typography_link_color != null, + $brand_typography_link_color, + $brand_color_link +) !default; +$link-bg: $brand_typography_link_background_color !default; diff --git a/inst/brand/bs5/_brand-yml.scss b/inst/brand/bs5/_brand-yml.scss index de6a1f6f4..56d7425fa 100644 --- a/inst/brand/bs5/_brand-yml.scss +++ b/inst/brand/bs5/_brand-yml.scss @@ -40,6 +40,7 @@ $brand_color_warning: null !default; $brand_color_danger: null !default; $brand_color_light: null !default; $brand_color_dark: null !default; +$brand_color_link: null !default; $brand_typography_base_family: null !default; $brand_typography_base_size: null !default; $brand_typography_base_line-height: null !default; @@ -120,8 +121,16 @@ $code-block-font-weight: $brand_typography_monospace-block_weight !default; $code-block-font-size: $brand_typography_monospace-block_size !default; // brand.typography.link $link-bg: $brand_typography_link_background-color !default; -$link-color: $brand_typography_link_color !default; -$link-color-dark: $brand_typography_link_color !default; +$link-color: if( + $brand_typography_link_color != null, + $brand_typography_link_color, + $brand_color_link +) !default; +$link-color-dark: if( + $brand_typography_link_color != null, + $brand_typography_link_color, + $brand_color_link +) !default; $link-weight: $brand_typography_link_weight !default; $link-decoration: $brand_typography_link_decoration !default; diff --git a/tests/testthat/test-bs-theme-preset-brand.R b/tests/testthat/test-bs-theme-preset-brand.R index e41ca3c83..02220a202 100644 --- a/tests/testthat/test-bs-theme-preset-brand.R +++ b/tests/testthat/test-bs-theme-preset-brand.R @@ -390,3 +390,593 @@ describe("bs_brand_bundle()", { expect_equal(bs_theme(brand = FALSE), bs_theme_base) }) }) + +brand_theme_css <- function(theme, output_style = "expanded") { + withr::local_options("bslib.color_contrast_warnings" = FALSE) + + dependencies <- bs_theme_dependencies( + theme, + sass_options = sass::sass_options(output_style = output_style), + cache = FALSE, + precompiled = FALSE + ) + bootstrap <- Filter( + function(x) identical(x$name, "bootstrap"), + dependencies + )[[1]] + + paste( + readLines(file.path(bootstrap$src$file, bootstrap$stylesheet)), + collapse = "\n" + ) +} + +brand_css <- function(brand, output_style = "expanded") { + brand_theme_css( + bs_theme(version = 5, preset = "bootstrap", brand = brand), + output_style = output_style + ) +} + +brand_css_light_root <- function(css) { + matches <- regmatches( + css, + gregexpr( + ':root\\s*,\\s*\\[data-bs-theme="light"\\]\\s*\\{[^}]*\\}', + css, + perl = TRUE + ) + )[[1]] + matches <- Filter( + function(x) grepl("--bs-body-bg:", x, fixed = TRUE), + matches + ) + expect_length(matches, 1) + matches[[1]] +} + +brand_css_dark_root <- function(css) { + matches <- regmatches( + css, + gregexpr('\\[data-bs-theme="dark"\\]\\s*\\{[^}]*\\}', css, perl = TRUE) + )[[1]] + matches <- Filter( + function(x) grepl("--bs-body-bg:", x, fixed = TRUE), + matches + ) + expect_gte(length(matches), 2) + + # The final stylesheet layer emits the complete dark root followed by + # Bootstrap's dark-specific variables. + tail(matches, 2)[[1]] +} + +brand_css_dark_rule <- function(css, selector) { + matches <- regmatches( + css, + gregexpr( + sprintf('\\[data-bs-theme="dark"\\]\\s+%s\\s*\\{[^}]*\\}', selector), + css, + perl = TRUE + ) + )[[1]] + expect_gte(length(matches), 1) + paste(matches, collapse = "\n") +} + +brand_css_dark_runtime <- function(css) { + matches <- regmatches( + css, + gregexpr('\\[data-bs-theme="dark"\\]\\s*\\{[^}]*\\}', css, perl = TRUE) + )[[1]] + matches <- Filter( + function(x) grepl("--bs-link-bg:", x, fixed = TRUE), + matches + ) + expect_length(matches, 1) + matches[[1]] +} + +describe("brand light and dark color modes", { + skip_if_not_installed("brand.yml", minimum_version = "0.1.0.9000") + + it("preserves brand metadata through public theme mutators", { + theme <- bs_theme( + version = 5, + preset = "bootstrap", + brand = list( + color = list( + primary = list(light = "#123456", dark = "#abcdef") + ) + ) + ) + + mutated <- list( + bundle = bs_bundle(theme, sass::sass_layer(rules = ".bundle {}")), + variables = bs_add_variables(theme, "border-width" = "2px"), + rules = bs_add_rules(theme, ".rules {}"), + functions = bs_add_functions( + theme, + "@function brand-test() { @return true; }" + ), + mixins = bs_add_mixins(theme, "@mixin brand-test() {}"), + update = bs_theme_update(theme, preset = "flatly"), + remove = bs_remove(theme, "_carousel") + ) + + lapply(mutated, function(x) { + expect_identical(attr(x, "brand"), attr(theme, "brand")) + }) + + expect_null(attr(bs_remove(theme, "brand"), "brand")) + }) + + it("does not add dark CSS for Bootstrap 4", { + theme <- suppressWarnings(bs_theme( + version = 4, + preset = "bootstrap", + brand = list( + color = list( + primary = list(light = "#123456", dark = "#abcdef") + ) + ) + )) + theme <- bs_add_rules(theme, ".brand-primary { color: $primary; }") + + expect_identical( + bs_brand_dark_css( + theme, + sass::sass_options(output_style = "expanded"), + FALSE + ), + "" + ) + }) + + it("preserves font dependencies after appending dark CSS", { + cache <- withr::local_tempdir() + theme <- bs_theme( + version = 5, + preset = "bootstrap", + brand = list( + color = list( + primary = list(light = "#123456", dark = "#abcdef") + ), + typography = list( + fonts = list(list(family = "Fira Code", source = "bunny")), + base = list(family = "Fira Code") + ) + ) + ) + + compile_dependencies <- function() { + bs_theme_dependencies( + theme, + sass_options = sass::sass_options(output_style = "expanded"), + cache = cache, + precompiled = FALSE + ) + } + + for (dependencies in list(compile_dependencies(), compile_dependencies())) { + expect_true( + "Fira_Code" %in% + vapply( + dependencies, + function(x) x$name, + character(1) + ) + ) + } + }) + + it("rewrites the dark root after charset and byte-order-mark prefixes", { + brand <- list( + color = list( + primary = list(light = "#123456", dark = "#abcdef") + ), + typography = list(base = list(family = "Café Sans")) + ) + + for (output_style in c("expanded", "compressed")) { + css <- brand_css(brand, output_style = output_style) + expect_match( + brand_css_dark_root(css), + "--bs-primary: #abcdef;", + fixed = TRUE + ) + expect_false(grepl( + ':root\\s*,\\s*\\[data-bs-theme="light"\\]\\s*\\{[^}]*#abcdef', + css, + perl = TRUE + )) + } + }) + + it("emits full color and typography variants in Bootstrap mode selectors", { + css <- brand_css(list( + color = list( + foreground = list(light = "#111111", dark = "#eeeeee"), + background = list(light = "#ffffff", dark = "#222222"), + primary = list(light = "#0066cc", dark = "#66b2ff") + ), + typography = list( + headings = list(color = list(light = "#223344", dark = "#ddeeff")) + ) + )) + + light <- brand_css_light_root(css) + dark <- brand_css_dark_root(css) + + expect_match(light, "--bs-body-color: #111111;", fixed = TRUE) + expect_match(light, "--bs-body-bg: #ffffff;", fixed = TRUE) + expect_match(light, "--bs-primary: #0066cc;", fixed = TRUE) + expect_match(light, "--bs-heading-color: #223344;", fixed = TRUE) + expect_match(dark, "--bs-body-color: #eeeeee;", fixed = TRUE) + expect_match(dark, "--bs-body-bg: #222222;", fixed = TRUE) + expect_match(dark, "--bs-primary: #66b2ff;", fixed = TRUE) + expect_match(dark, "--bs-heading-color: #ddeeff;", fixed = TRUE) + }) + + it("recompiles Bootstrap components and custom rules in dark mode", { + theme <- bs_theme( + version = 5, + preset = "flatly", + brand = list( + color = list( + primary = list(light = "#123456", dark = "#abcdef") + ) + ) + ) + theme <- bs_add_rules( + theme, + ".brand-primary { color: $primary; }" + ) + css <- brand_theme_css(theme) + + expect_match( + brand_css_dark_rule(css, "\\.btn-primary"), + "--bs-btn-bg: #abcdef;", + fixed = TRUE + ) + expect_match( + brand_css_dark_rule(css, "\\.bg-primary"), + "background-color: rgba(var(--bs-primary-rgb)", + fixed = TRUE + ) + expect_match( + brand_css_dark_root(css), + "--bs-primary-rgb: 171, 205, 239;", + fixed = TRUE + ) + expect_match( + brand_css_dark_rule(css, "\\.brand-primary"), + "color: #abcdef;", + fixed = TRUE + ) + }) + + it("switches derived component styles at runtime", { + skip_if_not_installed("chromote") + + browser <- tryCatch( + chromote::ChromoteSession$new(wait_ = TRUE), + error = function(e) NULL + ) + if (is.null(browser)) { + skip("A local Chrome or Chromium browser is required") + } + on.exit(browser$close(), add = TRUE) + + theme <- bs_theme( + version = 5, + preset = "bootstrap", + brand = list( + color = list( + foreground = list(light = "#111111", dark = "#eeeeee"), + background = list(light = "#ffffff", dark = "#222222"), + primary = list(light = "#123456", dark = "#abcdef"), + link = list(light = "#006699", dark = "#cc6600") + ) + ) + ) + bootstrap <- Filter( + function(x) identical(x$name, "bootstrap"), + bs_theme_dependencies( + theme, + sass_options = sass::sass_options(output_style = "expanded"), + cache = FALSE, + precompiled = FALSE + ) + )[[1]] + css <- paste( + readLines( + file.path(bootstrap$src$file, bootstrap$stylesheet), + warn = FALSE + ), + collapse = "\n" + ) + + html_file <- withr::local_tempfile(fileext = ".html") + writeLines( + c( + "", + '
', + paste0( + "", + "" + ), + '', + 'Link' + ), + html_file, + useBytes = TRUE + ) + browser$go_to(paste0("file://", normalizePath(html_file))) + + computed_style <- function(selector, property) { + js <- sprintf( + "getComputedStyle(document.querySelector(%s)).getPropertyValue(%s)", + jsonlite::toJSON(selector, auto_unbox = TRUE), + jsonlite::toJSON(property, auto_unbox = TRUE) + ) + browser$Runtime$evaluate(js)$result$value + } + + expect_equal( + computed_style(".btn-primary", "background-color"), + "rgb(18, 52, 86)" + ) + expect_equal( + computed_style("body", "background-color"), + "rgb(255, 255, 255)" + ) + expect_equal(computed_style("a", "color"), "rgb(0, 102, 153)") + + browser$Runtime$evaluate( + paste0( + "new Promise(resolve => { ", + 'document.documentElement.setAttribute("data-bs-theme", "dark"); ', + "requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)));", + " })" + ), + awaitPromise = TRUE, + returnByValue = TRUE + ) + + expect_equal( + computed_style(".btn-primary", "background-color"), + "rgb(171, 205, 239)" + ) + expect_equal( + computed_style("body", "background-color"), + "rgb(34, 34, 34)" + ) + expect_equal(computed_style("a", "color"), "rgb(204, 102, 0)") + }) + + it("leaves an undefined mode at Bootstrap's default", { + dark_only <- brand_css(list( + color = list(background = list(dark = "#222222")) + )) + expect_match( + brand_css_light_root(dark_only), + "--bs-body-bg: #fff;", + fixed = TRUE + ) + expect_match( + brand_css_dark_root(dark_only), + "--bs-body-bg: #222222;", + fixed = TRUE + ) + + light_only <- brand_css(list( + color = list(primary = list(light = "#112233")) + )) + expect_match( + brand_css_light_root(light_only), + "--bs-primary: #112233;", + fixed = TRUE + ) + expect_match( + brand_css_dark_root(light_only), + "--bs-primary: #0d6efd;", + fixed = TRUE + ) + expect_false(grepl( + "#112233", + brand_css_dark_root(light_only), + fixed = TRUE + )) + expect_match( + brand_css_dark_rule(light_only, "\\.btn-primary"), + "--bs-btn-bg: #0d6efd;", + fixed = TRUE + ) + expect_false(grepl( + "#112233", + brand_css_dark_rule(light_only, "\\.btn-primary"), + fixed = TRUE + )) + }) + + it("resolves partial references within their own color mode", { + css <- brand_css(list( + color = list( + primary = list(dark = "#112233"), + secondary = list(light = "primary", dark = "#445566") + ) + )) + + expect_match( + brand_css_light_root(css), + "--bs-secondary: #6c757d;", + fixed = TRUE + ) + expect_match( + brand_css_dark_root(css), + "--bs-secondary: #445566;", + fixed = TRUE + ) + }) + + it("keeps scalar colors and typography values in both modes", { + css <- brand_css(list( + color = list(primary = "#123456"), + typography = list( + base = list(family = "Georgia"), + headings = list(color = "#445566") + ) + )) + + for (root in list(brand_css_light_root(css), brand_css_dark_root(css))) { + expect_match(root, "--bs-primary: #123456;", fixed = TRUE) + expect_match(root, "--bs-body-font-family: Georgia;", fixed = TRUE) + expect_match(root, "--bs-heading-color: #445566;", fixed = TRUE) + } + }) + + it("switches typography backgrounds and block colors", { + css <- brand_css(list( + typography = list( + link = list( + "background-color" = list( + light = "#e6f0ff", + dark = "#12243d" + ) + ), + "monospace-inline" = list( + "background-color" = list( + light = "#f1f3f5", + dark = "#24292f" + ) + ), + "monospace-block" = list( + color = list(light = "#202124", dark = "#f8f9fa"), + "background-color" = list(light = "#ffffff", dark = "#161b22") + ) + ) + )) + + expect_match(css, "--bs-link-bg: #e6f0ff;", fixed = TRUE) + expect_match(css, "--bs-link-bg: #12243d;", fixed = TRUE) + expect_match(css, "background-color: #f1f3f5;", fixed = TRUE) + expect_match( + brand_css_dark_rule(css, "code"), + "background-color: #24292f;", + fixed = TRUE + ) + expect_match(css, "color: #202124;", fixed = TRUE) + expect_match(css, "background-color: #ffffff;", fixed = TRUE) + expect_match( + brand_css_dark_rule(css, "pre"), + "color: #f8f9fa;", + fixed = TRUE + ) + expect_match( + brand_css_dark_rule(css, "pre"), + "background-color: #161b22;", + fixed = TRUE + ) + }) + + it("does not leak light typography backgrounds into dark mode", { + css <- brand_css(list( + typography = list( + link = list("background-color" = list(light = "#e6f0ff")), + "monospace-inline" = list( + "background-color" = list(light = "#f1f3f5") + ), + "monospace-block" = list( + color = list(light = "#202124"), + "background-color" = list(light = "#ffffff") + ) + ) + )) + + expect_match( + brand_css_dark_runtime(css), + "--bs-link-bg: initial;", + fixed = TRUE + ) + expect_false(grepl( + "#e6f0ff", + brand_css_dark_runtime(css), + fixed = TRUE + )) + expect_false(grepl( + "#f1f3f5", + brand_css_dark_rule(css, "code"), + fixed = TRUE + )) + expect_false(grepl( + "#202124|#ffffff", + brand_css_dark_rule(css, "pre") + )) + }) + + it("uses typography link color before semantic link color in each mode", { + semantic_css <- brand_css(list( + color = list(link = list(light = "#0066cc", dark = "#66b2ff")) + )) + expect_match( + brand_css_light_root(semantic_css), + "--bs-link-color: #0066cc;", + fixed = TRUE + ) + expect_match( + brand_css_dark_root(semantic_css), + "--bs-link-color: #66b2ff;", + fixed = TRUE + ) + + css <- brand_css(list( + color = list(link = list(dark = "#66b2ff")), + typography = list( + link = list(color = list(light = "#0066cc")) + ) + )) + + expect_match( + brand_css_light_root(css), + "--bs-link-color: #0066cc;", + fixed = TRUE + ) + expect_match( + brand_css_dark_root(css), + "--bs-link-color: #66b2ff;", + fixed = TRUE + ) + + override_css <- brand_css(list( + color = list(link = list(light = "#0066cc", dark = "#66b2ff")), + typography = list( + link = list(color = list(light = "#00509e", dark = "#8ac5ff")) + ) + )) + expect_match( + brand_css_light_root(override_css), + "--bs-link-color: #00509e;", + fixed = TRUE + ) + expect_match( + brand_css_dark_root(override_css), + "--bs-link-color: #8ac5ff;", + fixed = TRUE + ) + }) + + it("ships both selectors in one stylesheet for runtime switching", { + css <- brand_css(list( + color = list(primary = list(light = "#0066cc", dark = "#66b2ff")) + )) + + expect_true(grepl('[data-bs-theme="light"]', css, fixed = TRUE)) + expect_gte( + length(gregexpr('\\[data-bs-theme="dark"\\]', css, perl = TRUE)[[1]]), + 2 + ) + }) +})