Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Improvements

* `ui.Theme.from_brand()` now supports light/dark brand colors and typography
colors. The generated theme applies light colors to ordinary pages and emits
complete mode-scoped Bootstrap/Shiny color tokens and component styles that
respond to the existing `data-bs-theme` attribute without recompiling.

* Navsets created with an `id` (e.g. `ui.navset_tab(id="tabs")`) now use that `id` as their `data-tabsetid`, so their tab panes get stable `tab-tabs-0` style DOM ids instead of ones built from a random integer. This makes the rendered markup reproducible across renders and easier to target from custom CSS and JavaScript. Navsets without an `id`, and `ui.nav_menu()` dropdowns, keep the random ID. (Thanks, @pevolution-ahmed!) (#2410)

### Bug fixes
Expand Down
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ dependencies = [
]

[project.optional-dependencies]
theme = ["libsass>=0.23.0", "brand_yml>=0.2.0"]
# TODO: Restore a version floor after posit-dev/brand-yml#120 is released.
theme = [
"libsass>=0.23.0",
"brand_yml @ git+https://github.com/posit-dev/brand-yml.git@4f216cacc4b03e45a0e7beef6542d428f46df1f9",
]
otel = [
# 1.24.0+ required: same sanitized-stack-trace floor as opentelemetry-api above
"opentelemetry-sdk>=1.24.0",
Expand Down Expand Up @@ -151,7 +155,7 @@ dev = [
"Flake8-pyproject>=1.2.3",
"isort>=5.10.1",
"libsass>=0.23.0",
"brand_yml>=0.2.0",
"brand_yml @ git+https://github.com/posit-dev/brand-yml.git@4f216cacc4b03e45a0e7beef6542d428f46df1f9",
"pyrefly>=1.1.1",
"pyright>=1.1.407",
"pre-commit>=2.15.0",
Expand Down Expand Up @@ -286,4 +290,3 @@ matches = "tests/playwright/**"
# The repository has separate top-level conftest modules for pytest and
# Playwright. A single project search path cannot disambiguate both names.
replace-imports-with-any = ["conftest"]

18 changes: 18 additions & 0 deletions shiny/.agents/skills/shiny-for-python/references/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,24 @@ from shiny.express import ui
ui.page_opts(theme=ui.Theme.from_brand(__file__))
```

Theme and typography colors may use `light` and `dark` variants. Light values
are the default when the page has no explicit color-mode attribute, and
`ui.input_dark_mode()` switches the same compiled theme at runtime:

```yaml
color:
foreground: { light: "#17212b", dark: "#edf2f7" }
background: { light: "#ffffff", dark: "#17212b" }
primary: { light: "#0066cc", dark: "#66b2ff" }
typography:
link:
color: { light: "#0055aa", dark: "#99ccff" }
```

A variant may define only one mode. In that case Shiny preserves Bootstrap's
theme value in the undefined mode rather than copying the defined color across
modes. Scalar colors continue to apply in both modes.

For authoring the `_brand.yml` file itself, use the external `shiny:brand-yml`
skill — do not hand-write the spec here.

Expand Down
163 changes: 160 additions & 3 deletions shiny/ui/_theme_brand.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Union
from typing import TYPE_CHECKING, Any, Optional, TypeGuard, Union

if TYPE_CHECKING:
from brand_yml import Brand
Expand Down Expand Up @@ -177,6 +177,17 @@ def join_str(x: str | None, y: str | None):


class ThemeBrand(Theme):
_THEME_COLOR_NAMES = (
"primary",
"secondary",
"success",
"info",
"warning",
"danger",
"light",
"dark",
)

def __init__(
self,
brand: "Brand",
Expand Down Expand Up @@ -224,6 +235,7 @@ def __init__(

# Rules ----
self.add_rules(*brand_color_palette_rules)
self.add_rules(ThemeBrand._prepare_css_vars(brand))

# Bootstrap extras: functions, mixins, rules (defaults handled above)
self._add_brand_bootstrap_other(brand_bootstrap)
Expand Down Expand Up @@ -255,12 +267,16 @@ def _prepare_color_vars(
# ==> $primary: $brand_color_primary !default;

brand_color_var = f"brand_color_{thm_name}"
defaults_dict[brand_color_var] = thm_color
if ThemeBrand._is_sass_scalar(thm_color):
defaults_dict[brand_color_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 not ThemeBrand._is_sass_scalar(pal_color):
continue

if pal_name in bootstrap_colors:
defaults_dict[pal_name] = pal_color

Expand Down Expand Up @@ -311,10 +327,151 @@ def _prepare_typography_vars(brand: "Brand") -> dict[str, YamlScalarType]:
for field, prop in brand_typography.items():
for prop_key, prop_value in prop.items():
typo_sass_var = f"brand_typography_{field}_{prop_key}"
mapped[typo_sass_var] = prop_value
if ThemeBrand._is_sass_scalar(prop_value):
mapped[typo_sass_var] = prop_value

return mapped

@staticmethod
def _is_sass_scalar(value: Any) -> TypeGuard[YamlScalarType]:
return value is None or isinstance(value, (bool, float, int, str))

@staticmethod
def _color_value(value: Any, mode: str) -> str | None:
if isinstance(value, str):
return value

mode_value = getattr(value, mode, None)
return mode_value if isinstance(mode_value, str) else None

@staticmethod
def _prepare_css_vars(brand: "Brand") -> str:
"""
Emit brand variables and complete Bootstrap color-mode layers.

Light colors apply to an ordinary document without a ``data-bs-theme``
attribute as well as to explicit light mode. Undefined branches are
omitted so Bootstrap's compiled value remains in effect.
"""
brand_css = brand.css_variables(
{
"light": 'html:not([data-bs-theme]), [data-bs-theme="light"]',
"dark": '[data-bs-theme="dark"]',
}
)
mode_rules = [
ThemeBrand._prepare_color_mode(brand, mode) for mode in ("light", "dark")
]
return "\n".join([brand_css, *mode_rules])

@staticmethod
def _typography_color_value(
brand: "Brand", field: str, property_name: str, mode: str
) -> tuple[bool, str | None]:
if brand.typography is None:
return False, None

typography_node = getattr(brand.typography, field, None)
if typography_node is None:
return False, None

value = getattr(typography_node, property_name, None)
return value is not None, ThemeBrand._color_value(value, mode)

@staticmethod
def _prepare_color_mode(brand: "Brand", mode: str) -> str:
color = brand.color
theme_colors = {
name: ThemeBrand._color_value(getattr(color, name, None), mode)
for name in ThemeBrand._THEME_COLOR_NAMES
if color is not None
}
theme_colors = {
name: value for name, value in theme_colors.items() if value is not None
}

def brand_color(name: str) -> str | None:
if color is None:
return None
return ThemeBrand._color_value(getattr(color, name, None), mode)

headings_configured, headings = ThemeBrand._typography_color_value(
brand, "headings", "color", mode
)
link_configured, link = ThemeBrand._typography_color_value(
brand, "link", "color", mode
)
link_background_configured, link_background = (
ThemeBrand._typography_color_value(brand, "link", "background_color", mode)
)
code_configured, code = ThemeBrand._typography_color_value(
brand, "monospace_inline", "color", mode
)
code_background_configured, code_background = (
ThemeBrand._typography_color_value(
brand, "monospace_inline", "background_color", mode
)
)
pre_configured, pre = ThemeBrand._typography_color_value(
brand, "monospace_block", "color", mode
)
pre_background_configured, pre_background = ThemeBrand._typography_color_value(
brand, "monospace_block", "background_color", mode
)

color_link = getattr(color, "link", None) if color is not None else None
if not link_configured:
if color_link is not None:
link = ThemeBrand._color_value(color_link, mode)
else:
link = theme_colors.get("primary")

values = {
"foreground": brand_color("foreground"),
"background": brand_color("background"),
"secondary": brand_color("secondary"),
"tertiary": brand_color("tertiary"),
"headings": headings if headings_configured else None,
"link": link,
"link-background": (
link_background if link_background_configured else None
),
"code": code if code_configured else None,
"code-background": (
code_background if code_background_configured else None
),
"pre": pre if pre_configured else None,
"pre-background": pre_background if pre_background_configured else None,
}

if not theme_colors and not any(value is not None for value in values.values()):
return ""

selectors = (
'("html:not([data-bs-theme])", \'[data-bs-theme="light"]\')'
if mode == "light"
else "('[data-bs-theme=\"dark\"]',)"
)
theme_color_map = ["("]
theme_color_map.extend(
f' "{name}": {value},' for name, value in theme_colors.items()
)
theme_color_map.append(" ),")

arguments = [
"@include brand-color-mode(",
f" {selectors},",
f" {mode},",
*theme_color_map,
]
arguments.extend(
f" ${name}: {value},"
for name, value in values.items()
if value is not None
)
arguments.append(");")
return "\n".join(arguments)

def _add_defaults_hdr(self, header: str, **kwargs: YamlScalarType):
self.add_defaults(**kwargs)
self.add_defaults(f"\n// *---- brand: {header} ----* //")
Expand Down
Loading
Loading