diff --git a/main.py b/main.py index 2679d6b8e1..1e7fc3f46d 100755 --- a/main.py +++ b/main.py @@ -61,6 +61,7 @@ def _render_page(self, match: RouteMatch) -> bool: @ui.page('/documentation/{path:path}') @ui.page('/imprint_privacy') def _main_page() -> None: + ui.colors(primary='#317ABE', at_rule='@media (prefers-contrast: more)') ui.context.client.content.classes('p-0 gap-0') header.add_head_html() diff --git a/nicegui/elements/colors.js b/nicegui/elements/colors.js index 235974ed17..e8518f5389 100644 --- a/nicegui/elements/colors.js +++ b/nicegui/elements/colors.js @@ -1,15 +1,55 @@ export default { mounted() { - document.body.style.setProperty("--q-primary", this.primary); - document.body.style.setProperty("--q-secondary", this.secondary); - document.body.style.setProperty("--q-accent", this.accent); - document.body.style.setProperty("--q-dark", this.dark); - document.body.style.setProperty("--q-dark-page", this.darkPage); - document.body.style.setProperty("--q-positive", this.positive); - document.body.style.setProperty("--q-negative", this.negative); - document.body.style.setProperty("--q-info", this.info); - document.body.style.setProperty("--q-warning", this.warning); - applyColors(this.customColors); + if (!this.atRule) { + document.body.style.setProperty("--q-primary", this.primary); + document.body.style.setProperty("--q-secondary", this.secondary); + document.body.style.setProperty("--q-accent", this.accent); + document.body.style.setProperty("--q-dark", this.dark); + document.body.style.setProperty("--q-dark-page", this.darkPage); + document.body.style.setProperty("--q-positive", this.positive); + document.body.style.setProperty("--q-negative", this.negative); + document.body.style.setProperty("--q-info", this.info); + document.body.style.setProperty("--q-warning", this.warning); + applyColors(this.customColors); + return; + } + const colors = { + "--q-primary": this.primary, + "--q-secondary": this.secondary, + "--q-accent": this.accent, + "--q-dark": this.dark, + "--q-dark-page": this.darkPage, + "--q-positive": this.positive, + "--q-negative": this.negative, + "--q-info": this.info, + "--q-warning": this.warning, + }; + let css = Object.entries(colors) + .map(([k, v]) => ` body { ${k}: ${v} !important; }`) + .join("\n"); + for (const [color, value] of Object.entries(this.customColors || {})) { + const name = color.replaceAll("_", "-"); + const varName = "--q-" + name; + css += `\n body { ${varName}: ${value} !important; }`; + css += `\n .text-${name} { color: var(${varName}) !important; }`; + css += `\n .bg-${name} { background-color: var(${varName}) !important; }`; + } + // Clear any prior NiceGUI-added color styles so earlier plain `ui.colors(...)` calls + // don't bleed through globally when this scoped at-rule block takes over. + // Mirrors the `[data-nicegui-custom-colors]` cleanup pattern in `applyColors` (static/nicegui.js). + document.head + .querySelectorAll("[data-nicegui-custom-colors], [data-nicegui-scoped-colors]") + .forEach((el) => el.remove()); + for (const key of Object.keys(colors)) { + document.body.style.removeProperty(key); + } + this.styleEl = document.createElement("style"); + this.styleEl.dataset.niceguiScopedColors = ""; + this.styleEl.innerHTML = `${this.atRule} {\n${css}\n}`; + document.head.appendChild(this.styleEl); + }, + unmounted() { + this.styleEl?.remove(); }, props: { primary: String, @@ -21,6 +61,7 @@ export default { negative: String, info: String, warning: String, + atRule: String, customColors: Object, }, }; diff --git a/nicegui/elements/colors.py b/nicegui/elements/colors.py index ec07b3479a..405b05cc77 100644 --- a/nicegui/elements/colors.py +++ b/nicegui/elements/colors.py @@ -16,6 +16,7 @@ def __init__(self, *, negative: str = DEFAULT_PROP | '#c10015', info: str = DEFAULT_PROP | '#31ccec', warning: str = DEFAULT_PROP | '#f2c037', + at_rule: str = '', **custom_colors: str) -> None: """Color Theming @@ -32,6 +33,7 @@ def __init__(self, *, :param negative: Negative color (default: "#c10015") :param info: Info color (default: "#31ccec") :param warning: Warning color (default: "#f2c037") + :param at_rule: CSS at-rule to limit when the colors apply (e.g. ``"@media (prefers-color-scheme: dark)"``) :param custom_colors: Custom color definitions for branding (needs ``ui.colors`` to be called before custom color is ever used, *added in version 2.2.0*) """ super().__init__() @@ -44,6 +46,7 @@ def __init__(self, *, self._props['negative'] = negative self._props['info'] = info self._props['warning'] = warning + self._props['at-rule'] = at_rule self._props['custom-colors'] = custom_colors QUASAR_COLORS.update({name.replace('_', '-') for name in custom_colors}) diff --git a/nicegui/static/nicegui.css b/nicegui/static/nicegui.css index 0438c4bc75..e970ca2e39 100644 --- a/nicegui/static/nicegui.css +++ b/nicegui/static/nicegui.css @@ -342,3 +342,12 @@ h6.q-timeline__title { position: absolute; right: 1.5em; } +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition: none !important; + animation: none !important; + scroll-behavior: auto !important; + } +} diff --git a/tests/test_colors.py b/tests/test_colors.py index b32ab056d9..36b34115a7 100644 --- a/tests/test_colors.py +++ b/tests/test_colors.py @@ -32,3 +32,24 @@ def replace(): screen.click('Replace') screen.wait(0.5) assert screen.find_by_tag('button').value_of_css_property('background-color') == 'rgba(255, 0, 0, 1)' + + +def test_at_rule(screen: Screen): + @ui.page('/') + def page(): + ui.colors(primary='#ff0000', at_rule='@media (min-width: 0px)') + ui.button('Test Button') + + screen.open('/') + assert screen.find_by_tag('button').value_of_css_property('background-color') == 'rgba(255, 0, 0, 1)' + + +def test_at_rule_supersedes_plain_colors(screen: Screen): + @ui.page('/') + def page(): + ui.colors(primary='red') + ui.colors(primary='blue', at_rule='@media (min-width: 0px)') + ui.button('Test Button') + + screen.open('/') + assert screen.find_by_tag('button').value_of_css_property('background-color') == 'rgba(0, 0, 255, 1)' diff --git a/website/components/hero_section.py b/website/components/hero_section.py index d64935c67a..d4c78904e1 100644 --- a/website/components/hero_section.py +++ b/website/components/hero_section.py @@ -16,7 +16,7 @@ def create() -> None: ) with ui.column(align_items='center').classes('reveal'): ui.html(svg.HAPPY_FACE_SVG, sanitize=False) \ - .classes(f'hero-mascot size-40 stroke-[{d.BLUE}] stroke-2 mb-8') + .classes(f'hero-mascot size-40 stroke-[{d.BLUE}] forced-colors:invert stroke-2 mb-8') ui.markdown('Meet the *NiceGUI*.') \ .classes(f'{d.TEXT_HERO} font-semibold tracking-tighter leading-none [&_em]:not-italic [&_em]:{d.TEXT_BLUE} {d.TEXT_PRIMARY} -mb-2') ui.markdown(''' diff --git a/website/documentation/windows.py b/website/documentation/windows.py index a5590cc335..e3a18844e3 100644 --- a/website/documentation/windows.py +++ b/website/documentation/windows.py @@ -16,14 +16,15 @@ def code_window(code: str = '', *, title: str = 'main.py', language: str = 'python') -> ui.column: """Create a window for code. If code is empty, returns the body column for use as context manager.""" - with ui.column().classes(f'rounded-xl gap-0 min-w-0 {d.BG_CODE} code-window') as window: + with ui.column().classes(f'rounded-xl gap-0 min-w-0 {d.BG_CODE} code-window forced-colors:outline') as window: with _header_row(): phosphor_icon(ICONS.get(language, 'ph-file')).classes('text-base') ui.label(title) if code: ui.space() with ui.button(on_click=lambda: ui.clipboard.write(code)) \ - .props('flat round size=xs').classes('opacity-30 hover:opacity-100 transition-opacity'): + .props('flat round size=xs') \ + .classes('opacity-30 hover:opacity-100 forced-colors:opacity-100 transition-opacity'): phosphor_icon('ph-copy').classes('text-base') if code: ui.markdown(f'````{language}\n{remove_indentation(code)}\n````') \ @@ -43,7 +44,7 @@ def python_window(code: str = '', *, title: str = 'main.py') -> ui.column: def browser_window(content: Callable, *, tab: str | Callable | None = None, lazy: bool = True) -> ui.column: """Create a browser window.""" - with ui.column().classes(f'rounded-xl gap-0 {d.BG_SURFACE} {d.RING} browser-window') as window: + with ui.column().classes(f'rounded-xl gap-0 {d.BG_SURFACE} {d.RING} browser-window forced-colors:outline') as window: with _header_row(): if callable(tab): tab() diff --git a/website/header.py b/website/header.py index 590da49943..04fe76951d 100644 --- a/website/header.py +++ b/website/header.py @@ -81,6 +81,7 @@ def add_header(menu: ui.left_drawer) -> ui.button: f' [&.fade]:!bg-[color-mix(in_srgb,{d._BG_SURFACE_LIGHT}_80%,transparent)]' f' dark:[&.fade]:!bg-[color-mix(in_srgb,{d._BG_SURFACE_DARK}_80%,transparent)]' f' [&.fade]:backdrop-blur-[12px]' + f' media-[(prefers-reduced-transparency:reduce)]:[&.fade]:backdrop-blur-none' f' [&.fade]:!shadow-[0_1px_0_{d._BORDER_LIGHT}]' f' [&.fade]:dark:!shadow-[0_1px_0_{d._BORDER_DARK}]' f' [.q-layout:has(.q-drawer--standard:not(.q-layout--prevent-focus))_&]:!shadow-[0_1px_0_{d._BORDER_LIGHT}]'