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. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentImplementationUse CaseStatus
Value Boxui.value_box()Metric DisplayProduction Ready
Cardui.card()Content ContainerProduction Ready
Layoutui.layout_column_wrap()Component OrganizationProduction Ready
Navigationui.page_navbar()Page StructureProduction 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