Skip to content
Open
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
1 change: 1 addition & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
61 changes: 51 additions & 10 deletions nicegui/elements/colors.js
Original file line number Diff line number Diff line change
@@ -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; }`;
}
Comment on lines +16 to +36

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at_rule is currently only covered by a test that verifies the media query branch can set the primary color, but it doesn't exercise custom colors inside an at-rule (i.e., scoped .text-<custom> / .bg-<custom> rules) or verify that custom color styles don’t leak outside the at-rule when previous ui.colors calls have injected global custom-color CSS. Adding tests for these behaviors would help prevent regressions in the new styling path.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted. The existing test_at_rule covers the core at-rule branch (scoped --q-primary override applied to a button). Expanding coverage to custom-color scoping + leak-prevention is reasonable but, given Falko's limited review bandwidth, keeping the test surface minimal for this PR. Happy to add more in a follow-up if desired.

// 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);
Comment on lines +30 to +49

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When atRule is set, this code injects a <style> block for custom colors but does not remove any previously injected [data-nicegui-custom-colors] style blocks (created by applyColors). If a page (or earlier render) already called ui.colors(...) without at_rule, the old .text-*/.bg-* rules can remain globally active and defeat the intent of scoping custom colors to the at-rule. Consider removing/replacing the existing NiceGUI custom-colors style blocks (and, if necessary, previously set custom --q-* inline variables) before appending the at-rule style element.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged as a known design edge case. Each ui.colors(...) call mounts a separate Vue element; the at-rule branch injects a scoped <style> element which is cleaned up on unmounted. If a prior ui.colors() (no at_rule) ran applyColors globally within the same page render, those rules do persist — but that's the same behavior as chained ui.colors() calls today, and the at_rule's !important inside the scoped block still wins within the at-rule scope. Cleaning up prior global custom colors is a broader lifecycle change; leaving out of scope for this PR.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 5f2abd3. Followed the existing NiceGUI-removal-before-readd pattern from nicegui/static/nicegui.js (the applyColors function, which removes any existing [data-nicegui-custom-colors] style blocks before appending a new one). The at_rule branch now clears both [data-nicegui-custom-colors] and [data-nicegui-scoped-colors] style tags plus any --q-* inline properties set on document.body by prior plain ui.colors(...) calls, then tags its own scoped <style> with data-nicegui-scoped-colors for symmetrical cleanup by future calls. Added test_at_rule_supersedes_plain_colors covering the scenario.

},
unmounted() {
this.styleEl?.remove();
},
props: {
primary: String,
Expand All @@ -21,6 +61,7 @@ export default {
negative: String,
info: String,
warning: String,
atRule: String,
customColors: Object,
},
};
3 changes: 3 additions & 0 deletions nicegui/elements/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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__()
Expand All @@ -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})

Expand Down
9 changes: 9 additions & 0 deletions nicegui/static/nicegui.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +345 to +352

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reduced-motion rule targets html, body *, which excludes the body element itself and also does not cover pseudo-elements (e.g. ::before/::after) that often carry animations/transitions. To fully respect prefers-reduced-motion, consider expanding the selector to include body and pseudo-elements (commonly *, *::before, *::after scoped as needed).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 4b27a5c. Switched the selector to *, *::before, *::after so the body element and pseudo-element animations are also covered.

}
21 changes: 21 additions & 0 deletions tests/test_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
2 changes: 1 addition & 1 deletion website/components/hero_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('''
Expand Down
7 changes: 4 additions & 3 deletions website/documentation/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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````') \
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions website/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}]'
Expand Down