diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index 36ec85680..7ed411870 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -1322,10 +1322,29 @@ def append_state_style(style_obj, style_tag, style_class, device="desktop"): if ":" in key: state, property = key.split(":", 1) css_property = camel_case_to_kebab_case(property) - style_string = f".{style_class}:{state} {{ {css_property}: {value}; }}" + declaration = f"{css_property}: {value};" + if state == "dark": + style_string = dark_mode_style(style_class, declaration) + else: + style_string = f".{style_class}:{state} {{ {declaration} }}" style_tag.append(wrap_with_media_query(style_string, device)) +def dark_mode_style(style_class, declaration): + """CSS for a dark-mode-only style (e.g. a dark variant of a background image). + + Dark mode is active for the OS scheme unless the page is manually pinned to + light, and for the manual dark toggle — mirroring how Builder scopes dark mode + elsewhere via data-theme / data-prefers-color-scheme. + """ + not_light = ':root:not([data-theme="light"]):not([data-prefers-color-scheme="light"])' + forced = f':root[data-theme="dark"] .{style_class}, :root[data-prefers-color-scheme="dark"] .{style_class}' + return ( + f"@media (prefers-color-scheme: dark) {{ {not_light} .{style_class} {{ {declaration} }} }} " + f"{forced} {{ {declaration} }}" + ) + + def get_font_family(font: str) -> str: """Return the first family from a CSS font stack (e.g. 'Inter, sans-serif' -> 'Inter').""" return font.split(",")[0].strip().strip("'\"") diff --git a/frontend/src/components/BackgroundHandler.vue b/frontend/src/components/BackgroundHandler.vue index 504c31755..9758dc2b7 100644 --- a/frontend/src/components/BackgroundHandler.vue +++ b/frontend/src/components/BackgroundHandler.vue @@ -7,6 +7,7 @@ :component="BackgroundInput" label="Background" :enableStates="true" + :variants="bgVariants" :allowDynamicValue="true" placeholder="Set Background" readonly @@ -176,6 +177,10 @@ const BackgroundInput = defineComponent({ }, }); +// "Dark Mode" sits alongside the hover/active/focus states in the "+" menu; its +// value is stored as `dark:background*` styles and rendered under prefers-color-scheme. +const bgVariants = [{ name: "dark", property: "dark:background", label: "Dark Mode" }]; + const activeState = ref(null); const updateActiveState = (e: FocusEvent) => { @@ -386,14 +391,17 @@ const handleSetVariant = (variantName: string, value: string | number | boolean return; } - // Basic transition logic for states - blockController.getSelectedBlocks().forEach((block) => { - if (!block.getStyle("transitionDuration")) { - block.setStyle("transitionDuration", "300ms"); - block.setStyle("transitionTimingFunction", "ease"); - block.setStyle("transitionProperty", "all"); - } - }); + // Basic transition logic for interaction states; dark mode is a theme switch, + // not a hover-style state, so it shouldn't add a transition. + if (variantName !== "dark") { + blockController.getSelectedBlocks().forEach((block) => { + if (!block.getStyle("transitionDuration")) { + block.setStyle("transitionDuration", "300ms"); + block.setStyle("transitionTimingFunction", "ease"); + block.setStyle("transitionProperty", "all"); + } + }); + } // the trigger input is read-only, so a non-null value here can only come from // the "copy current value to state" dropdown — copy the actual base styles diff --git a/frontend/src/components/BuilderBlock.vue b/frontend/src/components/BuilderBlock.vue index e2f68080a..305f9e2f0 100644 --- a/frontend/src/components/BuilderBlock.vue +++ b/frontend/src/components/BuilderBlock.vue @@ -374,7 +374,13 @@ const styles = computed(() => { } Object.keys(styleMap).forEach((key) => { - if (key.startsWith("hover:")) { + if (key.startsWith("dark:")) { + // preview the dark variant when the canvas is in dark mode + if (!props.preview && builderStore.canvasDarkMode) { + styleMap[key.slice(5)] = styleMap[key]; + } + delete styleMap[key]; + } else if (key.startsWith("hover:")) { // state style preview on hover // if (!isHovered.value) { // delete styleMap[key]; diff --git a/frontend/src/utils/builderBlockCopyPaste.ts b/frontend/src/utils/builderBlockCopyPaste.ts index 364cb28a1..66cd70d43 100644 --- a/frontend/src/utils/builderBlockCopyPaste.ts +++ b/frontend/src/utils/builderBlockCopyPaste.ts @@ -389,16 +389,18 @@ function updateURLsInBlock(block: Block, currentSiteURL: string): void { } } - // Update background image URLs - if (block) { - const bgSrc = block.getStyle("backgroundImage"); + // Update background image URLs (including the dark-mode variant) + const absolutizeBackground = (styleKey: string) => { + const bgSrc = block.getStyle(styleKey); if (bgSrc && typeof bgSrc === "string" && bgSrc.startsWith("url(")) { const urlMatch = bgSrc.match(/url\(["']?([^"']+)["']?\)/); if (urlMatch && urlMatch[1] && urlMatch[1].startsWith("/")) { - block.setStyle("backgroundImage", `url(${currentSiteURL}${urlMatch[1]})`); + block.setStyle(styleKey, `url(${currentSiteURL}${urlMatch[1]})`); } } - } + }; + absolutizeBackground("backgroundImage"); + absolutizeBackground("dark:backgroundImage"); // Update href attributes for links // if (block.isLink()) {