diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1b7b9a77..68dafcce5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Added [narwhals](https://posit-dev.github.io/py-narwhals) support for `@render.table`. This allows for any eager data frame supported by narwhals to be returned from a `@render.table` output method. (#1570)
+* Shiny now supports theming via [brand.yml](https://posit-dev.github.io/brand-yml) with a single `_brand.yml` file. Call `ui.Theme.from_brand()` with `__file__` or the path to a `_brand.yml` file and pass the resulting theme to the `theme` argument of `express.ui.page_opts()` (Shiny Express) or `ui.page_*()` functions (Shiny Core) to apply the brand theme to the entire app. (#1743)
+
* `chat_ui()` and `Chat.ui()` gain a `messages` parameter for providing starting messages. (#1736)
### Other changes
diff --git a/docs/_quarto.yml b/docs/_quarto.yml
index 18c79c02d..b656d926e 100644
--- a/docs/_quarto.yml
+++ b/docs/_quarto.yml
@@ -44,3 +44,6 @@ interlinks:
url: https://matplotlib.org/stable/
python:
url: https://docs.python.org/3/
+ brand-yml:
+ url: https://posit-dev.github.io/brand-yml/
+ inv: objects.txt
diff --git a/examples/brand/Monda-OFL.txt b/examples/brand/Monda-OFL.txt
new file mode 100644
index 000000000..4a58f7175
--- /dev/null
+++ b/examples/brand/Monda-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2021 The Monda Project Authors (https://github.com/googlefonts/mondaFont)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/examples/brand/Monda.ttf b/examples/brand/Monda.ttf
new file mode 100644
index 000000000..458b865a3
Binary files /dev/null and b/examples/brand/Monda.ttf differ
diff --git a/examples/brand/_brand.yml b/examples/brand/_brand.yml
new file mode 100644
index 000000000..577d62aa6
--- /dev/null
+++ b/examples/brand/_brand.yml
@@ -0,0 +1,115 @@
+meta:
+ name:
+ full: "Retro Arcade Brand"
+ short: "RetroArc"
+ link:
+ home: https://retroarc.example.com
+ mastodon: https://mastodon.social/@retroarc
+ github: https://github.com/retroarc
+ linkedin: https://linkedin.com/company/retroarc
+ twitter: https://twitter.com/retroarc
+ facebook: https://facebook.com/retroarc
+
+# logo:
+# images:
+# icon-light: logos/retroarc-icon-light.png
+# icon-dark: logos/retroarc-icon-dark.png
+# wide-light: logos/retroarc-wide-light.png
+# wide-dark: logos/retroarc-wide-dark.png
+# tall-light: logos/retroarc-tall-light.png
+# tall-dark: logos/retroarc-tall-dark.png
+# small:
+# light: logos/retroarc-icon-light.png
+# dark: logos/retroarc-icon-dark.png
+# medium:
+# light: logos/retroarc-wide-light.png
+# dark: logos/retroarc-wide-dark.png
+# large:
+# light: logos/retroarc-tall-light.png
+# dark: logos/retroarc-tall-dark.png
+
+color:
+ palette:
+ pink: "#E83E8C"
+ blue: "#007BFF"
+ cyan: "#17A2B8"
+ teal: "#20C997"
+ green: "#28A745"
+ yellow: "#FFD700"
+ orange: "#FF7F50"
+ red: "#FF3333"
+ purple: "#6F42C1"
+ indigo: "#6610F2"
+ black: "#1A1A1A"
+ white: "#F8F8F8"
+ foreground: black
+ background: white
+ primary: purple
+ success: green
+ info: cyan
+ warning: yellow
+ danger: orange
+ light: white
+ dark: black
+
+typography:
+ fonts:
+ - family: Quantico
+ source: google
+ weight: [700]
+ style: [normal, italic]
+ display: swap
+ - family: Monda
+ source: file
+ files:
+ - path: Monda.ttf
+ weight: 400..700
+ - family: Share Tech Mono
+ source: bunny
+ weight: 400
+ style: normal
+ display: swap
+ base:
+ family: Monda
+ size: 17px
+ weight: 400
+ line-height: 1.5
+ headings:
+ family: Quantico
+ weight: 400
+ line-height: 1.2
+ style: normal
+ monospace:
+ family: Share Tech Mono
+ size: 0.9em
+ weight: 400
+ monospace-inline:
+ family: Share Tech Mono
+ # size: 0.9em
+ weight: 400
+ color: yellow
+ background-color: "#1a1a1add"
+ monospace-block:
+ family: Share Tech Mono
+ size: 1.1em
+ weight: 400
+ color: green
+ background-color: black
+ line-height: 1.4
+ link:
+ weight: 400
+ background-color: purple
+ color: white
+ decoration: "underline"
+
+defaults:
+ bootstrap:
+ defaults:
+ my-pink: "$brand-pink"
+ shiny:
+ theme:
+ preset: shiny
+ rules: |
+ .navbar-brand { color: $my-pink }
+ # TODO: Find an appropriate theme variable to set
+ # navbar-bg: $brand-purple
diff --git a/examples/brand/_colors.scss b/examples/brand/_colors.scss
new file mode 100644
index 000000000..618ba867a
--- /dev/null
+++ b/examples/brand/_colors.scss
@@ -0,0 +1,116 @@
+// https://github.com/twbs/bootstrap/blob/v5.3.3/site/assets/scss/_colors.scss
+
+.bd-blue-100 { color: color-contrast($blue-100); background-color: $blue-100; }
+.bd-blue-200 { color: color-contrast($blue-200); background-color: $blue-200; }
+.bd-blue-300 { color: color-contrast($blue-300); background-color: $blue-300; }
+.bd-blue-400 { color: color-contrast($blue-400); background-color: $blue-400; }
+.bd-blue-500 { color: color-contrast($blue-500); background-color: $blue-500; }
+.bd-blue-600 { color: color-contrast($blue-600); background-color: $blue-600; }
+.bd-blue-700 { color: color-contrast($blue-700); background-color: $blue-700; }
+.bd-blue-800 { color: color-contrast($blue-800); background-color: $blue-800; }
+.bd-blue-900 { color: color-contrast($blue-900); background-color: $blue-900; }
+
+.bd-indigo-100 { color: color-contrast($indigo-100); background-color: $indigo-100; }
+.bd-indigo-200 { color: color-contrast($indigo-200); background-color: $indigo-200; }
+.bd-indigo-300 { color: color-contrast($indigo-300); background-color: $indigo-300; }
+.bd-indigo-400 { color: color-contrast($indigo-400); background-color: $indigo-400; }
+.bd-indigo-500 { color: color-contrast($indigo-500); background-color: $indigo-500; }
+.bd-indigo-600 { color: color-contrast($indigo-600); background-color: $indigo-600; }
+.bd-indigo-700 { color: color-contrast($indigo-700); background-color: $indigo-700; }
+.bd-indigo-800 { color: color-contrast($indigo-800); background-color: $indigo-800; }
+.bd-indigo-900 { color: color-contrast($indigo-900); background-color: $indigo-900; }
+
+.bd-purple-100 { color: color-contrast($purple-100); background-color: $purple-100; }
+.bd-purple-200 { color: color-contrast($purple-200); background-color: $purple-200; }
+.bd-purple-300 { color: color-contrast($purple-300); background-color: $purple-300; }
+.bd-purple-400 { color: color-contrast($purple-400); background-color: $purple-400; }
+.bd-purple-500 { color: color-contrast($purple-500); background-color: $purple-500; }
+.bd-purple-600 { color: color-contrast($purple-600); background-color: $purple-600; }
+.bd-purple-700 { color: color-contrast($purple-700); background-color: $purple-700; }
+.bd-purple-800 { color: color-contrast($purple-800); background-color: $purple-800; }
+.bd-purple-900 { color: color-contrast($purple-900); background-color: $purple-900; }
+
+.bd-pink-100 { color: color-contrast($pink-100); background-color: $pink-100; }
+.bd-pink-200 { color: color-contrast($pink-200); background-color: $pink-200; }
+.bd-pink-300 { color: color-contrast($pink-300); background-color: $pink-300; }
+.bd-pink-400 { color: color-contrast($pink-400); background-color: $pink-400; }
+.bd-pink-500 { color: color-contrast($pink-500); background-color: $pink-500; }
+.bd-pink-600 { color: color-contrast($pink-600); background-color: $pink-600; }
+.bd-pink-700 { color: color-contrast($pink-700); background-color: $pink-700; }
+.bd-pink-800 { color: color-contrast($pink-800); background-color: $pink-800; }
+.bd-pink-900 { color: color-contrast($pink-900); background-color: $pink-900; }
+
+.bd-red-100 { color: color-contrast($red-100); background-color: $red-100; }
+.bd-red-200 { color: color-contrast($red-200); background-color: $red-200; }
+.bd-red-300 { color: color-contrast($red-300); background-color: $red-300; }
+.bd-red-400 { color: color-contrast($red-400); background-color: $red-400; }
+.bd-red-500 { color: color-contrast($red-500); background-color: $red-500; }
+.bd-red-600 { color: color-contrast($red-600); background-color: $red-600; }
+.bd-red-700 { color: color-contrast($red-700); background-color: $red-700; }
+.bd-red-800 { color: color-contrast($red-800); background-color: $red-800; }
+.bd-red-900 { color: color-contrast($red-900); background-color: $red-900; }
+
+.bd-orange-100 { color: color-contrast($orange-100); background-color: $orange-100; }
+.bd-orange-200 { color: color-contrast($orange-200); background-color: $orange-200; }
+.bd-orange-300 { color: color-contrast($orange-300); background-color: $orange-300; }
+.bd-orange-400 { color: color-contrast($orange-400); background-color: $orange-400; }
+.bd-orange-500 { color: color-contrast($orange-500); background-color: $orange-500; }
+.bd-orange-600 { color: color-contrast($orange-600); background-color: $orange-600; }
+.bd-orange-700 { color: color-contrast($orange-700); background-color: $orange-700; }
+.bd-orange-800 { color: color-contrast($orange-800); background-color: $orange-800; }
+.bd-orange-900 { color: color-contrast($orange-900); background-color: $orange-900; }
+
+.bd-yellow-100 { color: color-contrast($yellow-100); background-color: $yellow-100; }
+.bd-yellow-200 { color: color-contrast($yellow-200); background-color: $yellow-200; }
+.bd-yellow-300 { color: color-contrast($yellow-300); background-color: $yellow-300; }
+.bd-yellow-400 { color: color-contrast($yellow-400); background-color: $yellow-400; }
+.bd-yellow-500 { color: color-contrast($yellow-500); background-color: $yellow-500; }
+.bd-yellow-600 { color: color-contrast($yellow-600); background-color: $yellow-600; }
+.bd-yellow-700 { color: color-contrast($yellow-700); background-color: $yellow-700; }
+.bd-yellow-800 { color: color-contrast($yellow-800); background-color: $yellow-800; }
+.bd-yellow-900 { color: color-contrast($yellow-900); background-color: $yellow-900; }
+
+.bd-green-100 { color: color-contrast($green-100); background-color: $green-100; }
+.bd-green-200 { color: color-contrast($green-200); background-color: $green-200; }
+.bd-green-300 { color: color-contrast($green-300); background-color: $green-300; }
+.bd-green-400 { color: color-contrast($green-400); background-color: $green-400; }
+.bd-green-500 { color: color-contrast($green-500); background-color: $green-500; }
+.bd-green-600 { color: color-contrast($green-600); background-color: $green-600; }
+.bd-green-700 { color: color-contrast($green-700); background-color: $green-700; }
+.bd-green-800 { color: color-contrast($green-800); background-color: $green-800; }
+.bd-green-900 { color: color-contrast($green-900); background-color: $green-900; }
+
+.bd-teal-100 { color: color-contrast($teal-100); background-color: $teal-100; }
+.bd-teal-200 { color: color-contrast($teal-200); background-color: $teal-200; }
+.bd-teal-300 { color: color-contrast($teal-300); background-color: $teal-300; }
+.bd-teal-400 { color: color-contrast($teal-400); background-color: $teal-400; }
+.bd-teal-500 { color: color-contrast($teal-500); background-color: $teal-500; }
+.bd-teal-600 { color: color-contrast($teal-600); background-color: $teal-600; }
+.bd-teal-700 { color: color-contrast($teal-700); background-color: $teal-700; }
+.bd-teal-800 { color: color-contrast($teal-800); background-color: $teal-800; }
+.bd-teal-900 { color: color-contrast($teal-900); background-color: $teal-900; }
+
+.bd-cyan-100 { color: color-contrast($cyan-100); background-color: $cyan-100; }
+.bd-cyan-200 { color: color-contrast($cyan-200); background-color: $cyan-200; }
+.bd-cyan-300 { color: color-contrast($cyan-300); background-color: $cyan-300; }
+.bd-cyan-400 { color: color-contrast($cyan-400); background-color: $cyan-400; }
+.bd-cyan-500 { color: color-contrast($cyan-500); background-color: $cyan-500; }
+.bd-cyan-600 { color: color-contrast($cyan-600); background-color: $cyan-600; }
+.bd-cyan-700 { color: color-contrast($cyan-700); background-color: $cyan-700; }
+.bd-cyan-800 { color: color-contrast($cyan-800); background-color: $cyan-800; }
+.bd-cyan-900 { color: color-contrast($cyan-900); background-color: $cyan-900; }
+
+.bd-gray-100 { color: color-contrast($gray-100); background-color: $gray-100; }
+.bd-gray-200 { color: color-contrast($gray-200); background-color: $gray-200; }
+.bd-gray-300 { color: color-contrast($gray-300); background-color: $gray-300; }
+.bd-gray-400 { color: color-contrast($gray-400); background-color: $gray-400; }
+.bd-gray-500 { color: color-contrast($gray-500); background-color: $gray-500; }
+.bd-gray-600 { color: color-contrast($gray-600); background-color: $gray-600; }
+.bd-gray-700 { color: color-contrast($gray-700); background-color: $gray-700; }
+.bd-gray-800 { color: color-contrast($gray-800); background-color: $gray-800; }
+.bd-gray-900 { color: color-contrast($gray-900); background-color: $gray-900; }
+
+.bd-white { color: color-contrast($white); background-color: $white; border: 2px solid $body-color;}
+.bd-black { color: color-contrast($black); background-color: $black; }
+.bd-foreground { color: $body-bg; background-color: $body-color; }
+.bd-background { color: $body-color; background-color: $body-bg; border: 2px solid $body-color;}
\ No newline at end of file
diff --git a/examples/brand/app-express.py b/examples/brand/app-express.py
new file mode 100644
index 000000000..bbebddc3e
--- /dev/null
+++ b/examples/brand/app-express.py
@@ -0,0 +1,10 @@
+from shiny.express import input, render, ui
+
+ui.page_opts(theme=ui.Theme.from_brand(__file__))
+
+ui.input_slider("n", "N", 0, 100, 20)
+
+
+@render.code
+def txt():
+ return f"n*2 is {input.n() * 2}"
diff --git a/examples/brand/app.py b/examples/brand/app.py
new file mode 100644
index 000000000..1cdbb8e9b
--- /dev/null
+++ b/examples/brand/app.py
@@ -0,0 +1,342 @@
+import os
+from pathlib import Path
+
+import matplotlib.pyplot as plt
+import numpy as np
+
+from shiny import App, render, ui
+from shiny.ui._theme_brand import bootstrap_colors
+
+# TODO: Move this into the test that runs this app
+os.environ["SHINY_BRAND_YML_RAISE_UNMAPPED"] = "true"
+theme = ui.Theme.from_brand(__file__)
+# theme = ui.Theme()
+theme.add_rules((Path(__file__).parent / "_colors.scss").read_text())
+
+app_ui = ui.page_navbar(
+ ui.nav_panel(
+ "Input Output Demo",
+ ui.layout_sidebar(
+ ui.sidebar(
+ ui.input_slider("slider1", "Numeric Slider Input", 0, 11, 11),
+ ui.input_numeric("numeric1", "Numeric Input Widget", 30),
+ ui.input_date("date1", "Date Input Component", value="2024-01-01"),
+ ui.input_switch("switch1", "Binary Switch Input", True),
+ ui.input_radio_buttons(
+ "radio1",
+ "Radio Button Group",
+ choices=["Option A", "Option B", "Option C", "Option D"],
+ ),
+ ui.input_action_button("action1", "Action Button"),
+ ),
+ ui.layout_columns(
+ ui.value_box(
+ "Metric 1",
+ "100",
+ theme="primary",
+ ),
+ ui.value_box(
+ "Metric 2",
+ "200",
+ theme="secondary",
+ ),
+ ui.value_box(
+ "Metric 3",
+ "300",
+ theme="info",
+ ),
+ ),
+ ui.card(
+ ui.card_header("Plot Output"),
+ ui.output_plot("plot1"),
+ ),
+ ui.card(
+ ui.card_header("Text Output"),
+ ui.output_text_verbatim("out_text1"),
+ ),
+ ),
+ ),
+ ui.nav_panel(
+ "Widget Gallery",
+ ui.layout_column_wrap(
+ ui.card(
+ ui.card_header("Button Variants"),
+ ui.input_action_button("btn1", "Default"),
+ ui.input_action_button("btn2", "Primary", class_="btn-primary"),
+ ui.input_action_button("btn3", "Secondary", class_="btn-secondary"),
+ ui.input_action_button("btn4", "Info", class_="btn-info"),
+ ui.input_action_button("btn5", "Success", class_="btn-success"),
+ ui.input_action_button("btn6", "Warning", class_="btn-warning"),
+ ui.input_action_button("btn7", "Danger", class_="btn-danger"),
+ ),
+ ui.card(
+ ui.card_header("Radio Button Examples"),
+ ui.input_radio_buttons(
+ "radio2",
+ "Standard Radio Group",
+ ["Selection 1", "Selection 2", "Selection 3"],
+ ),
+ ui.input_radio_buttons(
+ "radio3",
+ "Inline Radio Group",
+ ["Option 1", "Option 2", "Option 3"],
+ inline=True,
+ ),
+ ),
+ ui.card(
+ ui.card_header("Checkbox Examples"),
+ ui.input_checkbox_group(
+ "check1",
+ "Standard Checkbox Group",
+ ["Item 1", "Item 2", "Item 3"],
+ ),
+ ui.input_checkbox_group(
+ "check2",
+ "Inline Checkbox Group",
+ ["Choice A", "Choice B", "Choice C"],
+ inline=True,
+ ),
+ ),
+ ui.card(
+ ui.card_header("Select Input Widgets"),
+ ui.input_selectize(
+ "select1",
+ "Selectize Input",
+ ["Selection A", "Selection B", "Selection C"],
+ ),
+ ui.input_select(
+ "select2",
+ "Multiple Select Input",
+ ["Item X", "Item Y", "Item Z"],
+ multiple=True,
+ ),
+ ),
+ ui.card(
+ ui.card_header("Text Input Widgets"),
+ ui.input_text("text1", "Text Input"),
+ ui.input_text_area(
+ "textarea1",
+ "Text Area Input",
+ "Default text content for the text area widget",
+ ),
+ ui.input_password("password1", "Password Input"),
+ ),
+ width=300,
+ heights_equal=False,
+ ),
+ ),
+ ui.nav_panel(
+ "Colors",
+ ui.fill.as_fill_item(
+ ui.div(
+ ui.div(ui.output_ui("ui_colors"), class_="container-sm"),
+ class_="overflow-y-auto",
+ )
+ ),
+ ),
+ ui.nav_panel(
+ "Documentation",
+ ui.fill.as_fill_item(
+ ui.div(
+ ui.div(
+ ui.markdown(
+ """
+ _Just in case it isn't obvious, this text was written by an LLM._
+
+ # Component Documentation
+
+ The Shiny for Python framework, available at [shiny.posit.co/py](https://shiny.posit.co/py/),
+ provides a comprehensive set of UI components for building interactive web applications. These
+ components are designed with **consistency and usability** in mind, making it easier for
+ developers to create professional-grade applications.
+
+ Our framework implements the `ui.page_navbar()` component as the primary navigation structure,
+ allowing for intuitive organization of content across multiple views. Each view can contain
+ various input and output elements, managed through the `ui.card()` container system.
+
+ ## Component Architecture
+
+ *The architecture of our application framework* emphasizes modularity and reusability. Key
+ components like `ui.value_box()` and `ui.layout_column_wrap()` work together to create
+ structured, responsive layouts that adapt to different screen sizes.
+
+
+
+
+ Component |
+ Implementation |
+ Use Case |
+ Status |
+
+
+
+
+ Value Box |
+ ui.value_box() |
+ Metric Display |
+ Production Ready |
+
+
+ Card |
+ ui.card() |
+ Content Container |
+ Production Ready |
+
+
+ Layout |
+ ui.layout_column_wrap() |
+ Component Organization |
+ Production Ready |
+
+
+ Navigation |
+ ui.page_navbar() |
+ Page Structure |
+ Production Ready |
+
+
+
+
+ ## Implementation Best Practices
+
+ When implementing components, maintain consistent patterns in your code. Use the
+ `@render` decorators for output functions and follow the reactive programming model
+ with `@reactive.effect` for side effects.
+
+ Error handling should be implemented at both the UI and server levels. For input
+ validation, use the `req()` function to ensure all required values are present
+ before processing.
+
+ ## Corporate Brand Guidelines
+
+ Effective corporate brand guidelines should accomplish several key objectives:
+
+ 1. **Visual Consistency**: Establish a clear color palette using our theming system.
+ Primary colors should be defined using `class_="btn-primary"` and similar Bootstrap
+ classes.
+
+ 2. *Typography Standards*: Maintain consistent font usage across all text elements.
+ Headers should use the built-in styling provided by the `ui.card_header()` component.
+
+ 3. `Component Styling`: Apply consistent styling to UI elements such as buttons,
+ cards, and value boxes. Use the theme parameter in components like
+ `ui.value_box(theme="primary")`.
+
+ 4. **Layout Principles**: Follow a grid-based layout system using
+ `ui.layout_column_wrap()` with appropriate width parameters to ensure consistent
+ spacing and alignment.
+
+ 5. *Responsive Design*: Implement layouts that adapt gracefully to different screen
+ sizes using the `fillable` parameter in page components.
+
+ Remember that brand guidelines should serve as a framework for consistency while
+ remaining flexible enough to accommodate future updates and modifications to the
+ application interface.
+ """
+ ),
+ class_="container-sm ",
+ ),
+ class_="overflow-y-auto",
+ )
+ ),
+ ),
+ ui.nav_spacer(),
+ ui.nav_control(ui.input_dark_mode(id="color_mode")),
+ title="brand.yml Demo",
+ fillable=True,
+ theme=theme,
+)
+
+
+def server(input, output, session):
+ @render.plot
+ def plot1():
+ colors = {
+ "foreground": theme.brand.color.foreground,
+ "background": theme.brand.color.background,
+ "primary": theme.brand.color.primary,
+ }
+
+ if theme.brand.color:
+ colors.update(theme.brand.color.to_dict("theme"))
+
+ if input.color_mode() == "dark":
+ bg = colors["foreground"]
+ fg = colors["background"]
+ colors.update({"foreground": fg, "background": bg})
+
+ x = np.linspace(0, input.numeric1(), 100)
+ y = np.sin(x) * input.slider1()
+ fig, ax = plt.subplots(facecolor=colors["background"])
+ ax.plot(x, y, color=colors["primary"])
+ ax.set_title("Sine Wave Output", color=colors["foreground"])
+ ax.set_facecolor(colors["background"])
+ ax.tick_params(colors=colors["foreground"])
+ for spine in ax.spines.values():
+ spine.set_edgecolor(colors["foreground"])
+ spine.set_alpha(0.25)
+ return fig
+
+ @render.text
+ def out_text1():
+ return "\n".join(
+ ["def example_function():", ' return "Function output text"']
+ )
+
+ @render.ui
+ def ui_colors():
+ colors = []
+ # Replicates: https://getbootstrap.com/docs/5.3/customize/color/#all-colors
+ # Source: https://github.com/twbs/bootstrap/blob/6e1f75f4/site/content/docs/5.3/customize/color.md?plain=1#L395-L409
+ for color in ["gray", *bootstrap_colors]:
+ if color in ["white", "black"]:
+ continue
+
+ colors += [
+ ui.div(
+ ui.div(color, class_=f"p-3 mb-2 position-relative bd-{color}-500"),
+ *[
+ ui.div(f"{color}-{r}", class_=f"p-3 bd-{color}-{r}")
+ for r in range(100, 1000, 100)
+ ],
+ class_="mb-3",
+ )
+ ]
+
+ return ui.TagList(
+ ui.div(
+ *[
+ ui.div(
+ ui.div(
+ color, class_=f"p-3 mb-2 position-relative text-bg-{color}"
+ ),
+ class_="col-md-3 mb-3",
+ )
+ for color in [
+ "primary",
+ "secondary",
+ "dark",
+ "light",
+ "info",
+ "success",
+ "warning",
+ "danger",
+ ]
+ ],
+ class_="row font-monospace",
+ ),
+ ui.div(
+ *[
+ ui.div(
+ ui.div(color, class_=f"p-3 mb-2 position-relative bd-{color}"),
+ class_="col-md-3 mb-3",
+ )
+ for color in ["black", "white", "foreground", "background"]
+ ],
+ class_="row font-monospace",
+ ),
+ ui.layout_column_wrap(*colors, class_="font-monospace"),
+ )
+
+
+app = App(app_ui, server)
diff --git a/examples/brand/requirements.txt b/examples/brand/requirements.txt
new file mode 100644
index 000000000..430504d8f
--- /dev/null
+++ b/examples/brand/requirements.txt
@@ -0,0 +1,3 @@
+shiny[theme]
+matplotlib
+numpy
diff --git a/pyproject.toml b/pyproject.toml
index 1f94a4d87..29b5d8fab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,7 +52,10 @@ dependencies = [
]
[project.optional-dependencies]
-theme = ["libsass>=0.23.0"]
+theme = [
+ "libsass>=0.23.0",
+ "brand_yml>=0.1.0"
+]
test = [
"pytest>=6.2.4",
"pytest-asyncio>=0.17.2",
@@ -100,6 +103,7 @@ dev = [
"Flake8-pyproject>=1.2.3",
"isort>=5.10.1",
"libsass>=0.23.0",
+ "brand_yml>=0.1.0",
"pyright>=1.1.383",
"pre-commit>=2.15.0",
"wheel",
diff --git a/shiny/_main.py b/shiny/_main.py
index e004f4312..2f7ac632e 100644
--- a/shiny/_main.py
+++ b/shiny/_main.py
@@ -41,6 +41,8 @@ def main() -> None:
"*.htm",
"*.html",
"*.png",
+ "*.yml",
+ "*.yaml",
)
RELOAD_EXCLUDES_DEFAULT = (".*", "*.py[cod]", "__pycache__", "env", "venv")
diff --git a/shiny/ui/_html_deps_external.py b/shiny/ui/_html_deps_external.py
index 5205e6313..00e40c723 100644
--- a/shiny/ui/_html_deps_external.py
+++ b/shiny/ui/_html_deps_external.py
@@ -32,7 +32,7 @@ def shiny_page_theme_deps(theme: str | Path | Theme | ThemeProvider | None) -> T
if theme is None:
deps_theme = None
elif isinstance(theme, Theme):
- deps_theme = theme._html_dependency()
+ deps_theme = theme._html_dependencies()
elif isinstance(theme, str) and theme.startswith(("http", "//")):
deps_theme = head_content(link(rel="stylesheet", href=theme, type="text/css"))
elif isinstance(theme, (str, Path)):
diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py
index 27c7e772f..cba938882 100644
--- a/shiny/ui/_theme.py
+++ b/shiny/ui/_theme.py
@@ -5,8 +5,10 @@
import re
import tempfile
import textwrap
-from typing import Any, Literal, Optional, Sequence, TypeVar
+from typing import TYPE_CHECKING, Any, Literal, Optional, Sequence, TypeVar
+if TYPE_CHECKING:
+ from brand_yml import Brand
from htmltools import HTMLDependency
from .._docstring import add_example
@@ -129,12 +131,11 @@ def server(input):
def __init__(
self,
- preset: ShinyThemePreset = "shiny",
+ preset: str | None = None,
name: Optional[str] = None,
include_paths: Optional[str | pathlib.Path | list[str | pathlib.Path]] = None,
):
- check_is_valid_preset(preset)
- self._preset: ShinyThemePreset = preset
+ self._preset: ShinyThemePreset = check_is_valid_preset(preset or "shiny")
self.name = name
# 2024-06-21: `version` is not exposed because we currently support only BS 5.
# In the future, the Bootstrap version could be chosen by the user on init.
@@ -409,7 +410,7 @@ def to_css(
self._css = self._read_precompiled_css()
return self._css
- check_libsass_installed()
+ check_theme_pkg_installed("libsass", "sass")
import sass
args: SassCompileArgs = {} if compile_args is None else compile_args
@@ -460,25 +461,29 @@ def _dep_create(self, css_path: str | pathlib.Path) -> HTMLDependency:
"href": css_path.name,
"data-shiny-theme": self.name or self._preset, # type: ignore
},
+ # Branded themes re-use this tempdir
+ all_files=False,
)
def _html_dependency_precompiled(self) -> HTMLDependency:
return self._dep_create(css_path=self._dep_css_precompiled_path())
- def _html_dependency(self) -> HTMLDependency:
+ def _html_dependencies(self) -> list[HTMLDependency]:
"""
Create an `HTMLDependency` object from the theme.
Returns
-------
:
- An :class:`~htmltools.HTMLDependency` object representing the theme. In
- most cases, you should not need to call this method directly. Instead, pass
- the `Theme` object directly to the `theme` argument of any Shiny page
- function.
+ An list of :class:`~htmltools.HTMLDependency` objects representing the
+ theme. In most cases, you should not need to call this method directly.
+ Instead, pass the `Theme` object directly to the `theme` argument of any
+ Shiny page function.
"""
+ # Note: return a list so that this method can be overridden in subclasses of
+ # Theme that want to attach additional dependencies to the theme dependency.
if self._can_use_precompiled():
- return self._html_dependency_precompiled()
+ return [self._html_dependency_precompiled()]
css_name = self._dep_css_name()
css_path = os.path.join(self._get_css_tempdir(), css_name)
@@ -487,7 +492,7 @@ def _html_dependency(self) -> HTMLDependency:
with open(css_path, "w") as css_file:
css_file.write(self.to_css())
- return self._dep_create(css_path)
+ return [self._dep_create(css_path)]
def tagify(self) -> None:
raise SyntaxError(
@@ -497,6 +502,90 @@ def tagify(self) -> None:
"or any `shiny.ui.page_*()` function (Shiny Core)."
)
+ @classmethod
+ def from_brand(cls, brand: "str | pathlib.Path | Brand"):
+ """
+ Create a custom Shiny theme from a `_brand.yml`
+
+ Creates a custom Shiny theme for your brand using
+ [brand.yml](https://posit-dev.github.io/brand-yml), a single YAML file that
+ describes the brand's color and typography. Learn more about writing a
+ `_brand.yml` file for your brand at the
+ [brand.yml homepage](https://posit-dev.github.io/brand-yml).
+
+ As a simple example, suppose your brand guidelines include a color palette with
+ custom orange and black colors. The orange is used as the primary accent color
+ and the black for all text. For typography, the brand also uses
+ [Roboto](https://fonts.google.com/specimen/Roboto?query=roboto) and
+ [Roboto Mono](https://fonts.google.com/specimen/Roboto+Mono?query=roboto) from
+ Google Fonts for text and monospace-styled text, respectively. Here's a
+ `_brand.yml` file for this brand:
+
+ ```{.yaml filename="_brand.yml"}
+ meta:
+ name: brand.yml Example
+
+ color:
+ palette:
+ orange: "#F96302"
+ black: "#000000"
+ foreground: black
+ primary: orange
+
+ typography:
+ fonts:
+ - family: Roboto
+ source: google
+ - family: Roboto Mono
+ source: google
+ base: Roboto
+ monospace: Roboto Mono
+ ```
+
+ You can store the `_brand.yml` file next to your Shiny `app.py` or, for larger
+ projects, in a parent folder. To use a theme generated from the `_brand.yml`
+ file, call :meth:`~shiny.ui.Theme.from_brand` on `__file__` and pass the result
+ to the `theme` argument of :func:`~shiny.express.ui.page_opts` (Shiny Express)
+ or the `theme` argument of `shiny.ui.page_*` functions, like
+ :func:`~shiny.ui.page_sidebar`.
+
+ ```{.python filename="app.py"}
+ from shiny.express import input, render, ui
+
+ ui.page_opts(theme=ui.Theme.from_brand(__file__))
+
+ ui.input_slider("n", "N", 0, 100, 20)
+
+
+ @render.code
+ def txt():
+ return f"n*2 is {input.n() * 2}"
+ ```
+
+ Parameters
+ ----------
+ brand
+ A :class:`brand_yml.Brand` instance, or a path to help locate `_brand.yml`.
+ For a path, you can pass `__file__` or a directory containing the
+ `_brand.yml` or a path directly to the `_brand.yml` file.
+
+ Returns
+ -------
+ :
+ A :class:`shiny.ui.Theme` instance with a custom Shiny theme created from
+ the brand guidelines (see :class:`brand_yml.Brand`).
+ """
+ check_theme_pkg_installed("brand_yml")
+
+ from brand_yml import Brand
+
+ from ._theme_brand import ThemeBrand # avoid circular import
+
+ if not isinstance(brand, Brand):
+ brand = Brand.from_yaml(brand)
+
+ return ThemeBrand(brand)
+
def path_pkg_preset(preset: ShinyThemePreset, *args: str) -> str:
"""
@@ -516,21 +605,23 @@ def path_pkg_preset(preset: ShinyThemePreset, *args: str) -> str:
return pathlib.Path(path).as_posix()
-def check_is_valid_preset(preset: ShinyThemePreset) -> None:
+def check_is_valid_preset(preset: str) -> ShinyThemePreset:
if preset not in shiny_theme_presets:
raise ValueError(
f"Invalid preset '{preset}'.\n"
+ f"""Expected one of: "{'", "'.join(shiny_theme_presets)}".""",
)
+ return preset
+
-def check_libsass_installed() -> None:
+def check_theme_pkg_installed(pkg: str, spec: str | None = None) -> None:
import importlib.util
- if importlib.util.find_spec("sass") is None:
+ if importlib.util.find_spec(spec or pkg) is None:
raise ImportError(
- "The 'libsass' package is required to compile custom themes. "
- 'Please install it with `pip install libsass` or `pip install "shiny[theme]"`.',
+ f"The '{pkg}' package is required to compile custom themes. "
+ 'Please install it with `pip install {pkg}` or `pip install "shiny[theme]"`.',
)
diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py
new file mode 100644
index 000000000..5713feb34
--- /dev/null
+++ b/shiny/ui/_theme_brand.py
@@ -0,0 +1,552 @@
+from __future__ import annotations
+
+import os
+import warnings
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Optional, Union
+
+if TYPE_CHECKING:
+ from brand_yml import Brand
+from htmltools import HTMLDependency
+
+from .._versions import bootstrap as v_bootstrap
+from ._theme import Theme
+
+YamlScalarType = Union[str, int, bool, float, None]
+
+
+class ThemeBrandUnmappedFieldError(ValueError):
+ def __init__(self, field: str):
+ self.field = field
+ self.message = f"Unmapped brand.yml field: {field}"
+ super().__init__(self.message)
+
+ def __str__(self):
+ return self.message
+
+
+def warn_or_raise_unmapped_variable(unmapped: str):
+ if os.environ.get("SHINY_BRAND_YML_RAISE_UNMAPPED") == "true":
+ raise ThemeBrandUnmappedFieldError(unmapped)
+ else:
+ warnings.warn(
+ f"Shiny's brand.yml theme does not yet support {unmapped}.",
+ stacklevel=4,
+ )
+
+
+color_map: dict[str, list[str]] = {
+ # Bootstrap uses $gray-900 and $white for the body bg-color by default, and then
+ # swaps them for $gray-100 and $gray-900 in dark mode. brand.yml may end up with
+ # light/dark variants for foreground/background, see posit-dev/brand-yml#38.
+ "foreground": ["brand--foreground", "body-color", "body-bg-dark"],
+ "background": ["brand--background", "body-bg", "body-color-dark"],
+ "primary": ["primary"],
+ "secondary": ["secondary", "body-secondary-color", "body-secondary"],
+ "tertiary": ["body-tertiary-color", "body-tertiary"],
+ "success": ["success"],
+ "info": ["info"],
+ "warning": ["warning"],
+ "danger": ["danger"],
+ "light": ["light"],
+ "dark": ["dark"],
+}
+"""Maps brand.color fields to Bootstrap Sass variables"""
+
+# https://github.com/twbs/bootstrap/blob/6e1f75/scss/_variables.scss#L38-L49
+bootstrap_colors: list[str] = [
+ "white",
+ "black",
+ "blue",
+ "indigo",
+ "purple",
+ "pink",
+ "red",
+ "orange",
+ "yellow",
+ "green",
+ "teal",
+ "cyan",
+]
+"""
+Colors known to Bootstrap
+
+When these colors are named in `colors.palette`, we'll map the brand's colors to the
+corresponding Bootstrap color Sass variable.
+
+* [Bootstrap 5 - Colors](https://getbootstrap.com/docs/5.3/customize/color/#color-sass-maps)
+"""
+
+# TODO: test that these Sass variables exist in Bootstrap
+typography_map: dict[str, dict[str, list[str]]] = {
+ "base": {
+ "family": ["font-family-base"],
+ "size": ["font-size-base"], # TODO: consider using $font-size-root instead
+ "line_height": ["line-height-base"],
+ "weight": ["font-weight-base"],
+ },
+ "headings": {
+ "family": ["headings-font-family"],
+ "line_height": ["headings-line-height"],
+ "weight": ["headings-font-weight"],
+ "color": ["headings-color"],
+ "style": ["headings-style"],
+ },
+ "monospace": {
+ "family": ["font-family-monospace"],
+ "size": ["code-font-size"],
+ "weight": ["code-font-weight"],
+ },
+ "monospace_inline": {
+ "family": ["font-family-monospace-inline"],
+ "color": ["code-color", "code-color-dark"],
+ "background_color": ["code-bg"],
+ "size": ["code-inline-font-size"],
+ "weight": ["code-inline-font-weight"],
+ },
+ "monospace_block": {
+ "family": ["font-family-monospace-block"],
+ "line_height": ["code-block-line-height"],
+ "color": ["pre-color"],
+ "background_color": ["pre-bg"],
+ "weight": ["code-block-font-weight"],
+ "size": ["code-block-font-size"],
+ },
+ "link": {
+ "background_color": ["link-bg"],
+ "color": ["link-color", "link-color-dark"],
+ "weight": ["link-weight"],
+ "decoration": ["link-decoration"],
+ },
+}
+"""Maps brand.typography fields to corresponding Bootstrap Sass variables"""
+
+
+class BrandBootstrapConfigFromYaml:
+ """Validate a Bootstrap config from a YAML source"""
+
+ def __init__(
+ self,
+ path: str,
+ version: Any = None,
+ preset: Any = None,
+ functions: Any = None,
+ defaults: Any = None,
+ mixins: Any = None,
+ rules: Any = None,
+ ):
+
+ # TODO: Remove `path` and handle in try/except block in caller
+ self._path = path
+ self.version = version
+ self.preset: str | None = self._validate_str(preset, "preset")
+ self.functions: str | None = self._validate_str(functions, "functions")
+ self.defaults: dict[str, YamlScalarType] | None = self._validate_defaults(
+ defaults
+ )
+ self.mixins: str | None = self._validate_str(mixins, "mixins")
+ self.rules: str | None = self._validate_str(rules, "rules")
+
+ def _validate_str(self, x: Any, param: str) -> str | None:
+ if x is None or isinstance(x, str):
+ return x
+
+ raise ValueError(
+ f"Invalid brand `{self._path}.{param}`. Must be a string or empty."
+ )
+
+ def _validate_defaults(self, x: Any) -> dict[str, YamlScalarType] | None:
+ if x is None:
+ return None
+
+ if not isinstance(x, dict):
+ raise ValueError(
+ f"Invalid brand `{self._path}.defaults`, must be a dictionary."
+ )
+
+ y: dict[Any, Any] = x
+
+ if not all([isinstance(k, str) for k in y.keys()]):
+ raise ValueError(
+ f"Invalid brand `{self._path}.defaults`, all keys must be strings."
+ )
+
+ if not all(
+ [v is None or isinstance(v, (str, int, float, bool)) for v in y.values()]
+ ):
+ raise ValueError(
+ f"Invalid brand `{self._path}.defaults`, all values must be scalar."
+ )
+
+ res: dict[str, YamlScalarType] = y
+ return res
+
+
+class BrandBootstrapConfig:
+ """Convenience class for storing Bootstrap defaults from a brand instance"""
+
+ def __init__(
+ self,
+ version: Any = v_bootstrap,
+ preset: str | None = None,
+ functions: str | None = None,
+ defaults: dict[str, YamlScalarType] | None = None,
+ mixins: str | None = None,
+ rules: str | None = None,
+ ):
+ if not isinstance(version, (str, int)):
+ raise ValueError(
+ f"Bootstrap version must be a string or integer, not {version!r}."
+ )
+
+ v_major = str(version).split(".")[0]
+ bs_major = str(v_bootstrap).split(".")[0]
+
+ if v_major != bs_major:
+ # TODO (bootstrap-update): Assumes Shiny ships one version of Bootstrap
+ warnings.warn(
+ f"Shiny does not current support Bootstrap version {v_major}. "
+ f"Using Bootstrap v{bs_major} instead.",
+ stacklevel=4,
+ )
+ v_major = bs_major
+
+ self.version = v_major
+ self.preset = preset
+ self.functions = functions
+ self.defaults = defaults
+ self.mixins = mixins
+ self.rules = rules
+
+ @classmethod
+ def from_brand(cls, brand: "Brand"):
+ if not brand.defaults:
+ return cls()
+
+ shiny_args = {}
+ if "shiny" in brand.defaults and "theme" in brand.defaults["shiny"]:
+ shiny_args = brand.defaults["shiny"]["theme"]
+
+ shiny = BrandBootstrapConfigFromYaml(
+ path="defaults.shiny.theme",
+ **shiny_args,
+ )
+
+ bs_args = {}
+ if "bootstrap" in brand.defaults:
+ bs_args = brand.defaults["bootstrap"]
+
+ bootstrap = BrandBootstrapConfigFromYaml(
+ path="defaults.bootstrap",
+ **bs_args,
+ )
+
+ # now combine bootstrap and shiny config options in a way that makes sense
+ def join_str(x: str | None, y: str | None):
+ return "\n".join([z for z in [x, y] if z is not None])
+
+ defaults: dict[str, YamlScalarType] = {}
+ defaults.update(bootstrap.defaults or {})
+ defaults.update(shiny.defaults or {})
+
+ return cls(
+ version=shiny.version or bootstrap.version or v_bootstrap,
+ preset=shiny.preset or bootstrap.preset,
+ functions=join_str(bootstrap.functions, shiny.functions),
+ defaults=defaults,
+ mixins=join_str(bootstrap.mixins, shiny.mixins),
+ rules=join_str(bootstrap.rules, shiny.rules),
+ )
+
+
+class ThemeBrand(Theme):
+ def __init__(
+ self,
+ brand: "Brand",
+ *,
+ include_paths: Optional[str | Path | list[str | Path]] = None,
+ ):
+
+ name = self._get_theme_name(brand)
+ brand_bootstrap = BrandBootstrapConfig.from_brand(brand)
+
+ # Initialize theme ------------------------------------------------------------
+ super().__init__(
+ name=name,
+ preset=brand_bootstrap.preset,
+ include_paths=include_paths,
+ )
+
+ self.brand = brand
+
+ # Prep Sass and CSS Variables -------------------------------------------------
+ sass_vars_theme_colors, sass_vars_brand_colors, css_vars_brand = (
+ ThemeBrand._prepare_color_vars(brand)
+ )
+ sass_vars_typography = ThemeBrand._prepare_typography_vars(brand)
+
+ # Theme -----------------------------------------------------------------------
+ # Defaults are added in reverse order, so each chunk appears above the next
+ # layer of defaults. The intended order in the final output is:
+ # 1. "Brand" Color palette
+ # 2. "Brand" Bootstrap Sass vars
+ # 3. "Brand" theme colors
+ # 4. "Brand" typography
+ # 5. Gray scale variables from "Brand" fg/bg or black/white
+ # 6. Fallback vars needed by additional "Brand" rules
+
+ self.add_defaults("", "// *---- brand: end of defaults ----* //", "")
+ self._add_sass_ensure_variables()
+ self._add_sass_brand_grays()
+ self._add_defaults_hdr("typography", **sass_vars_typography)
+ self._add_defaults_hdr("theme colors", **sass_vars_theme_colors)
+ if brand_bootstrap.defaults:
+ self._add_defaults_hdr("bootstrap defaults", **brand_bootstrap.defaults)
+ self._add_defaults_hdr("brand colors", **sass_vars_brand_colors)
+
+ # "Brand" rules (now in forwards order)
+ self._add_rules_brand_colors(css_vars_brand)
+ self._add_sass_brand_rules()
+ self._add_brand_bootstrap_other(brand_bootstrap)
+
+ def _get_theme_name(self, brand: "Brand") -> str:
+ if not brand.meta or not brand.meta.name:
+ return "brand"
+
+ return brand.meta.name.short or brand.meta.name.full or "brand"
+
+ @staticmethod
+ def _prepare_color_vars(
+ brand: "Brand",
+ ) -> tuple[dict[str, str], dict[str, str], list[str]]:
+ """Colors: create a dictionary of Sass variables and a list of brand CSS variables"""
+ if not brand.color:
+ return {}, {}, []
+
+ mapped: dict[str, str] = {}
+ brand_sass_vars: dict[str, str] = {}
+ brand_css_vars: list[str] = []
+
+ # Map values in colors to their Sass variable counterparts
+ for thm_name, thm_color in brand.color.to_dict(include="theme").items():
+ if thm_name not in color_map:
+ warn_or_raise_unmapped_variable(f"color.{thm_name}")
+ continue
+
+ for sass_var in color_map[thm_name]:
+ mapped[sass_var] = thm_color
+
+ brand_color_palette = brand.color.to_dict(include="palette")
+
+ # Map the brand color palette to Bootstrap's named colors, e.g. $red, $blue.
+ for pal_name, pal_color in brand_color_palette.items():
+ if pal_name in bootstrap_colors:
+ mapped[pal_name] = pal_color
+
+ # Create Sass and CSS variables for the brand color palette
+ # => Sass var: `$brand-{name}: {value}`
+ brand_sass_vars.update({f"brand-{pal_name}": pal_color})
+ # => CSS var: `--brand-{name}: {value}`
+ brand_css_vars.append(f"--brand-{pal_name}: {pal_color};")
+
+ # We keep Sass and "Brand" vars separate so we can ensure "Brand" Sass vars come
+ # first in the compiled Sass definitions.
+ return mapped, brand_sass_vars, brand_css_vars
+
+ @staticmethod
+ def _prepare_typography_vars(brand: "Brand") -> dict[str, str]:
+ """Typography: Create a list of Bootstrap Sass variables"""
+ mapped: dict[str, str] = {}
+
+ if not brand.typography:
+ return mapped
+
+ brand_typography = brand.typography.model_dump(
+ exclude={"fonts"},
+ exclude_none=True,
+ context={"typography_base_size_unit": "rem"},
+ )
+
+ for field, prop in brand_typography.items():
+ if field not in typography_map:
+ warn_or_raise_unmapped_variable(f"typography.{field}")
+ continue
+
+ for prop_key, prop_value in prop.items():
+ if prop_key in typography_map[field]:
+ typo_sass_vars = typography_map[field][prop_key]
+ for typo_sass_var in typo_sass_vars:
+ mapped[typo_sass_var] = prop_value
+ else:
+ warn_or_raise_unmapped_variable(f"typography.{field}.{prop_key}")
+
+ return mapped
+
+ def _add_defaults_hdr(self, header: str, **kwargs: YamlScalarType):
+ self.add_defaults(**kwargs)
+ self.add_defaults(f"\n// *---- brand: {header} ----* //")
+
+ def _add_sass_ensure_variables(self):
+ """Ensure the variables we create to augment Bootstrap's variables exist"""
+ self._add_defaults_hdr(
+ "added variables",
+ **{
+ "code-font-weight": None,
+ "code-inline-font-weight": None,
+ "code-inline-font-size": None,
+ "code-block-font-weight": None,
+ "code-block-font-size": None,
+ "code-block-line-height": None,
+ "link-bg": None,
+ "link-weight": None,
+ },
+ )
+
+ def _add_sass_brand_grays(self):
+ """
+ Adds functions and defaults to handle creating a gray scale palette from the
+ brand color palette, or the brand's foreground/background colors.
+ """
+ self.add_functions(
+ """
+ @function brand-choose-white-black($foreground, $background) {
+ $lum_fg: luminance($foreground);
+ $lum_bg: luminance($background);
+ $contrast: contrast-ratio($foreground, $background);
+
+ @if $contrast < 4.5 {
+ @warn "The contrast ratio of #{$contrast} between the brand's foreground color (#{inspect($foreground)}) and background color (#{inspect($background)}) is very low. Consider picking colors with higher contrast for better readability.";
+ }
+
+ $white: if($lum_fg > $lum_bg, $foreground, $background);
+ $black: if($lum_fg <= $lum_bg, $foreground, $background);
+
+ // If the brand foreground/background are close enough to black/white, we
+ // use those values. Otherwise, we'll mix the white/black from the brand
+ // fg/bg with actual white and black to get something much closer.
+ @return (
+ "white": if(contrast-ratio($white, white) <= 1.15, $white, mix($white, white, 20%)),
+ "black": if(contrast-ratio($black, black) <= 1.15, $black, mix($black, black, 20%)),
+ );
+ }
+ """
+ )
+ self.add_defaults(
+ """
+ // *---- brand: automatic gray gradient ----* //
+ $enable-brand-grays: true !default;
+ // Ensure these variables exist so that we can set them inside of @if context
+ // They can still be overwritten by the user, even with !default;
+ $white: null !default;
+ $black: null !default;
+ $gray-100: null !default;
+ $gray-200: null !default;
+ $gray-300: null !default;
+ $gray-400: null !default;
+ $gray-500: null !default;
+ $gray-600: null !default;
+ $gray-700: null !default;
+ $gray-800: null !default;
+ $gray-900: null !default;
+
+ @if $enable-brand-grays {
+ @if variable-exists(brand--foreground) and variable-exists(brand--background) {
+ $brand-white-black: brand-choose-white-black($brand--foreground, $brand--background);
+ @if $white == null {
+ $white: map-get($brand-white-black, "white") !default;
+ }
+ @if $black == null {
+ $black: map-get($brand-white-black, "black") !default;
+ }
+ }
+ @if $white != null and $black != null {
+ $gray-100: mix($white, $black, 90%) !default;
+ $gray-200: mix($white, $black, 80%) !default;
+ $gray-300: mix($white, $black, 70%) !default;
+ $gray-400: mix($white, $black, 60%) !default;
+ $gray-500: mix($white, $black, 50%) !default;
+ $gray-600: mix($white, $black, 40%) !default;
+ $gray-700: mix($white, $black, 30%) !default;
+ $gray-800: mix($white, $black, 20%) !default;
+ $gray-900: mix($white, $black, 10%) !default;
+ }
+ }
+ """
+ )
+
+ def _add_sass_brand_rules(self):
+ """Additional rules to fill in Bootstrap styles for "Brand" parameters"""
+ self.add_rules(
+ """
+ // *---- brand: brand rules to augment Bootstrap rules ----* //
+ // https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_root.scss#L82
+ :root {
+ --#{$prefix}link-bg: #{$link-bg};
+ --#{$prefix}link-weight: #{$link-weight};
+ }
+ // https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_reboot.scss#L244
+ a {
+ background-color: var(--#{$prefix}link-bg);
+ font-weight: var(--#{$prefix}link-weight);
+ }
+ code {
+ font-weight: $code-font-weight;
+ }
+ code {
+ font-weight: $code-inline-font-weight;
+ font-size: $code-inline-font-size;
+ }
+ // https://github.com/twbs/bootstrap/blob/30e01525/scss/_reboot.scss#L287
+ pre {
+ font-weight: $code-block-font-weight;
+ font-size: $code-block-font-size;
+ line-height: $code-block-line-height;
+ }
+
+ $bslib-dashboard-design: false !default;
+ @if $bslib-dashboard-design and variable-exists(brand--background) {
+ // When brand makes dark mode, it usually hides card definition, so we add
+ // back card borders in dark mode.
+ [data-bs-theme="dark"] {
+ --bslib-card-border-color: RGBA(255, 255, 255, 0.15);
+ }
+ }
+ """
+ )
+
+ def _add_rules_brand_colors(self, css_vars_colors: list[str]):
+ self.add_rules("\n// *---- brand.color.palette ----* //")
+ self.add_rules(":root {", *css_vars_colors, "}")
+
+ def _add_brand_bootstrap_other(self, bootstrap: BrandBootstrapConfig):
+ if bootstrap.functions:
+ self.add_functions(bootstrap.functions)
+ if bootstrap.mixins:
+ self.add_mixins(bootstrap.mixins)
+ if bootstrap.rules:
+ self.add_rules(bootstrap.rules)
+
+ def _html_dependencies(self) -> list[HTMLDependency]:
+ theme_deps = super()._html_dependencies()
+
+ if not self.brand.typography:
+ return theme_deps
+
+ # We're going to put the fonts dependency _inside_ the theme's tempdir, which
+ # relies on the theme's dependency having `all_files=True`. We do this because
+ # Theme handles the tempdir lifecycle and we want the fonts dependency to be
+ # handled in the same way.
+ temp_dir = self._get_css_tempdir()
+ temp_path = Path(temp_dir) / "fonts"
+ temp_path.mkdir(parents=True, exist_ok=True)
+
+ fonts_dep = self.brand.typography.fonts_html_dependency(
+ path_dir=temp_path,
+ name=f"{self._dep_name()}-fonts",
+ version=self._version,
+ )
+
+ if fonts_dep is None:
+ return theme_deps
+
+ return [fonts_dep, *theme_deps]
diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py
index 1bfc42b87..4a9291a2c 100644
--- a/tests/pytest/test_theme.py
+++ b/tests/pytest/test_theme.py
@@ -214,7 +214,7 @@ def test_theme_dep_name_is_valid_path_part():
def test_theme_dependency_has_data_attribute():
theme = Theme("shiny")
- assert theme._html_dependency().stylesheet[0]["data-shiny-theme"] == "shiny" # type: ignore
+ assert theme._html_dependencies()[0].stylesheet[0]["data-shiny-theme"] == "shiny" # type: ignore
theme = Theme("shiny", name="My Fancy Theme")
- assert theme._html_dependency().stylesheet[0]["data-shiny-theme"] == "My Fancy Theme" # type: ignore
+ assert theme._html_dependencies()[0].stylesheet[0]["data-shiny-theme"] == "My Fancy Theme" # type: ignore