From 40ec395ba1823199c4fa2b68673015271ff46d70 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 12:26:37 -0400 Subject: [PATCH 1/6] feat: support light and dark brand colors --- DESCRIPTION | 2 +- NEWS.md | 4 + R/bs-dependencies.R | 112 ++++++++++++++ inst/brand/bs5/_brand-yml-dark.scss | 53 +++++++ tests/testthat/test-bs-theme-preset-brand.R | 161 ++++++++++++++++++++ 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 inst/brand/bs5/_brand-yml-dark.scss diff --git a/DESCRIPTION b/DESCRIPTION index 52cb10330..7eee89983 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -42,7 +42,7 @@ Imports: rlang, sass (>= 0.4.9) Suggests: - brand.yml, + brand.yml (>= 0.1.0.9000), bsicons, curl, fontawesome, diff --git a/NEWS.md b/NEWS.md index 028a4d926..f5e3333bf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,9 @@ # bslib (development version) +* `bs_theme(brand = ...)` now supports light and dark color variants from + `{brand.yml}`. Bootstrap switches the generated variables through its + existing `data-bs-theme` attribute. (#120) + # bslib 0.12.0 ## New features diff --git a/R/bs-dependencies.R b/R/bs-dependencies.R index 2ddd47e47..0996026dd 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,121 @@ 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_root_theme(theme, brand) + css <- sass( + input = dark_theme, + options = sass_options, + cache = sass_cache, + cache_key_extra = list( + "bslib-brand-dark-root", + 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_root_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_layers <- bs_brand_dark_bundle(brand)$layers + layers <- append(layers, dark_layers, after = brand_idx - 1L) + + layers <- lapply(layers, function(layer) { + layer$rules <- NULL + layer + }) + layers <- c( + layers, + list( + "_brand_dark_root" = as_sass_layer(sass_layer( + rules = bs5_sass_files("root") + )) + ) + ) + + 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) + + 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) + ) + ) + ) +} + +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 = "") + } + + 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/inst/brand/bs5/_brand-yml-dark.scss b/inst/brand/bs5/_brand-yml-dark.scss new file mode 100644 index 000000000..a9abdcd35 --- /dev/null +++ b/inst/brand/bs5/_brand-yml-dark.scss @@ -0,0 +1,53 @@ +/*-- 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_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_link_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; +$link-color: $brand_typography_link_color !default; +$link-color-dark: $brand_typography_link_color !default; diff --git a/tests/testthat/test-bs-theme-preset-brand.R b/tests/testthat/test-bs-theme-preset-brand.R index e41ca3c83..d9dbf86a7 100644 --- a/tests/testthat/test-bs-theme-preset-brand.R +++ b/tests/testthat/test-bs-theme-preset-brand.R @@ -390,3 +390,164 @@ describe("bs_brand_bundle()", { expect_equal(bs_theme(brand = FALSE), bs_theme_base) }) }) + +brand_css <- function(brand) { + withr::local_options("bslib.color_contrast_warnings" = FALSE) + + dependencies <- bs_theme_dependencies( + bs_theme(version = 5, preset = "bootstrap", brand = brand), + sass_options = sass::sass_options(output_style = "expanded"), + 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_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]] + 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]] +} + +describe("brand light and dark color modes", { + skip_if_not_installed("brand.yml", minimum_version = "0.1.0.9000") + + 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("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 + )) + }) + + 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("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 + ) + }) +}) From bd4ae753d2de2be02e29383171e3e8112a1480d4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 12:32:18 -0400 Subject: [PATCH 2/6] fix: complete dark brand typography overrides --- NEWS.md | 6 +- R/bs-dependencies.R | 8 + inst/brand/bs5/_brand-yml-dark-rules.scss | 14 ++ inst/brand/bs5/_brand-yml-dark.scss | 21 ++- inst/brand/bs5/_brand-yml.scss | 13 +- tests/testthat/test-bs-theme-preset-brand.R | 155 ++++++++++++++++++++ 6 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 inst/brand/bs5/_brand-yml-dark-rules.scss diff --git a/NEWS.md b/NEWS.md index f5e3333bf..54e77fdd7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,8 +1,8 @@ # bslib (development version) -* `bs_theme(brand = ...)` now supports light and dark color variants from - `{brand.yml}`. Bootstrap switches the generated variables through its - existing `data-bs-theme` attribute. (#120) +* `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) # bslib 0.12.0 diff --git a/R/bs-dependencies.R b/R/bs-dependencies.R index 0996026dd..17bcd895a 100644 --- a/R/bs-dependencies.R +++ b/R/bs-dependencies.R @@ -230,6 +230,14 @@ bs_brand_dark_root_theme <- function(theme, brand) { list( "_brand_dark_root" = as_sass_layer(sass_layer( rules = bs5_sass_files("root") + )), + "_brand_dark_runtime" = as_sass_layer(sass_layer_file( + system_file( + "brand", + "bs5", + "_brand-yml-dark-rules.scss", + package = "bslib" + ) )) ) ) 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..6fee72e26 --- /dev/null +++ b/inst/brand/bs5/_brand-yml-dark-rules.scss @@ -0,0 +1,14 @@ +/*-- scss:rules --*/ + +[data-bs-theme="dark"] { + --#{$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 index a9abdcd35..7bdc532da 100644 --- a/inst/brand/bs5/_brand-yml-dark.scss +++ b/inst/brand/bs5/_brand-yml-dark.scss @@ -14,13 +14,18 @@ $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; @@ -49,5 +54,17 @@ $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; -$link-color: $brand_typography_link_color !default; -$link-color-dark: $brand_typography_link_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 d9dbf86a7..05e660052 100644 --- a/tests/testthat/test-bs-theme-preset-brand.R +++ b/tests/testthat/test-bs-theme-preset-brand.R @@ -433,6 +433,10 @@ brand_css_dark_root <- function(css) { 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 @@ -440,6 +444,32 @@ brand_css_dark_root <- function(css) { 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_length(matches, 1) + matches[[1]] +} + +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") @@ -539,6 +569,131 @@ describe("brand light and dark color modes", { } }) + 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: ;", + 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")) From 4f644072720acf791c13af4369ad94dc9e5e6bdc Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 12:34:46 -0400 Subject: [PATCH 3/6] fix: reset absent dark link backgrounds --- inst/brand/bs5/_brand-yml-dark-rules.scss | 6 +++++- tests/testthat/test-bs-theme-preset-brand.R | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/inst/brand/bs5/_brand-yml-dark-rules.scss b/inst/brand/bs5/_brand-yml-dark-rules.scss index 6fee72e26..b345251f8 100644 --- a/inst/brand/bs5/_brand-yml-dark-rules.scss +++ b/inst/brand/bs5/_brand-yml-dark-rules.scss @@ -1,7 +1,11 @@ /*-- scss:rules --*/ [data-bs-theme="dark"] { - --#{$prefix}link-bg: #{$link-bg}; + @if $link-bg == null { + --#{$prefix}link-bg: initial; + } @else { + --#{$prefix}link-bg: #{$link-bg}; + } } [data-bs-theme="dark"] code { diff --git a/tests/testthat/test-bs-theme-preset-brand.R b/tests/testthat/test-bs-theme-preset-brand.R index 05e660052..ac4e3dba5 100644 --- a/tests/testthat/test-bs-theme-preset-brand.R +++ b/tests/testthat/test-bs-theme-preset-brand.R @@ -629,9 +629,14 @@ describe("brand light and dark color modes", { expect_match( brand_css_dark_runtime(css), - "--bs-link-bg: ;", + "--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"), From 7be4c50b2ed34c137df77be17dbba58d9b72695a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 13:19:11 -0400 Subject: [PATCH 4/6] fix: harden dark brand theme compilation --- R/bs-dependencies.R | 69 +++++--- R/bs-remove.R | 6 +- R/bs-theme-layers.R | 14 +- tests/testthat/test-bs-theme-preset-brand.R | 178 +++++++++++++++++++- 4 files changed, 239 insertions(+), 28 deletions(-) diff --git a/R/bs-dependencies.R b/R/bs-dependencies.R index 17bcd895a..13c8362b8 100644 --- a/R/bs-dependencies.R +++ b/R/bs-dependencies.R @@ -190,27 +190,27 @@ bs_brand_dark_css <- function(theme, sass_options, sass_cache) { return("") } - dark_theme <- bs_brand_dark_root_theme(theme, brand) + 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-root", + "bslib-brand-dark", get_exact_version(theme_version(theme)), get_package_version("bslib") ) ) sub( - "^:root\\s*,\\s*\\[data-bs-theme=(?:\"light\"|light)\\]", + ":root\\s*,\\s*\\[data-bs-theme=(?:\"light\"|light)\\]", '[data-bs-theme="dark"]', css, perl = TRUE ) } -bs_brand_dark_root_theme <- function(theme, brand) { +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)) { @@ -218,29 +218,51 @@ bs_brand_dark_root_theme <- function(theme, brand) { } layers <- theme$layers[!names(theme$layers) %in% brand_layers] - dark_layers <- bs_brand_dark_bundle(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 }) - layers <- c( - layers, - list( - "_brand_dark_root" = as_sass_layer(sass_layer( - rules = bs5_sass_files("root") - )), - "_brand_dark_runtime" = as_sass_layer(sass_layer_file( - system_file( - "brand", - "bs5", - "_brand-yml-dark-rules.scss", - package = "bslib" - ) - )) - ) + 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), @@ -255,7 +277,7 @@ bs_brand_dark_bundle <- function(brand) { brand_defaults <- brand.yml::brand_sass_defaults_bootstrap(brand) brand_typography <- brand.yml::brand_sass_typography(brand) - sass_bundle( + bundle <- sass_bundle( "brand_dark_base" = sass_layer_file( system_file("brand", "bs5", "_brand-yml-dark.scss", package = "bslib") ), @@ -270,6 +292,10 @@ bs_brand_dark_bundle <- function(brand) { ) ) ) + 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) { @@ -290,6 +316,7 @@ append_brand_dark_css <- function(out_file, dark_css) { cat("\n", dark_css, "\n", file = out_file_dark, append = TRUE, sep = "") } + attributes(out_file_dark) <- attributes(out_file) out_file_dark } 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/tests/testthat/test-bs-theme-preset-brand.R b/tests/testthat/test-bs-theme-preset-brand.R index ac4e3dba5..41cb918b3 100644 --- a/tests/testthat/test-bs-theme-preset-brand.R +++ b/tests/testthat/test-bs-theme-preset-brand.R @@ -391,12 +391,12 @@ describe("bs_brand_bundle()", { }) }) -brand_css <- function(brand) { +brand_theme_css <- function(theme, output_style = "expanded") { withr::local_options("bslib.color_contrast_warnings" = FALSE) dependencies <- bs_theme_dependencies( - bs_theme(version = 5, preset = "bootstrap", brand = brand), - sass_options = sass::sass_options(output_style = "expanded"), + theme, + sass_options = sass::sass_options(output_style = output_style), cache = FALSE, precompiled = FALSE ) @@ -411,6 +411,13 @@ brand_css <- function(brand) { ) } +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, @@ -453,8 +460,8 @@ brand_css_dark_rule <- function(css, selector) { perl = TRUE ) )[[1]] - expect_length(matches, 1) - matches[[1]] + expect_gte(length(matches), 1) + paste(matches, collapse = "\n") } brand_css_dark_runtime <- function(css) { @@ -473,6 +480,119 @@ brand_css_dark_runtime <- function(css) { 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( @@ -498,6 +618,44 @@ describe("brand light and dark color modes", { 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("leaves an undefined mode at Bootstrap's default", { dark_only <- brand_css(list( color = list(background = list(dark = "#222222")) @@ -531,6 +689,16 @@ describe("brand light and dark color modes", { 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", { From d7ff416b3adf4326207bbd98249d3039b20e3e4e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 13:35:58 -0400 Subject: [PATCH 5/6] test: cover runtime dark brand styles in browser --- tests/testthat/test-bs-theme-preset-brand.R | 101 ++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/testthat/test-bs-theme-preset-brand.R b/tests/testthat/test-bs-theme-preset-brand.R index 41cb918b3..02220a202 100644 --- a/tests/testthat/test-bs-theme-preset-brand.R +++ b/tests/testthat/test-bs-theme-preset-brand.R @@ -656,6 +656,107 @@ describe("brand light and dark color modes", { ) }) + 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")) From 58f083419e76bc3d58e32a4a8d1e0039772d78e4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 15:06:31 -0400 Subject: [PATCH 6/6] chore: use brand.yml from github --- DESCRIPTION | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 0b9e3b5ef..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 (>= 0.1.0.9000), 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,