diff --git a/CHANGELOG.md b/CHANGELOG.md
index 318977f2..b30d036c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,7 @@ if (response.ok) {
#### Fixed
- Fixed delayed rotation feedback where images appeared unchanged until a full page refresh.
+- Fixed light/dark theme switching so the page and browser UI transition more consistently.
### Maintainers
diff --git a/assets/css/app.css b/assets/css/app.css
index aa6f8047..6dc8d4af 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -11,6 +11,57 @@
navigation: auto;
}
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation: none;
+ mix-blend-mode: normal;
+}
+
+::view-transition-old(root) {
+ z-index: 1;
+}
+
+::view-transition-new(root) {
+ z-index: 2;
+}
+
+html.theme-transition-active.theme-transition-to-light::view-transition-new(root) {
+ clip-path: circle(0px at 100% 0%);
+ animation: theme-root-reveal 820ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
+}
+
+html.theme-transition-active.theme-transition-to-dark::view-transition-old(root) {
+ z-index: 2;
+ clip-path: circle(var(--theme-transition-radius, 160vmax) at 100% 0%);
+ animation: theme-root-conceal 820ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
+}
+
+html.theme-transition-active.theme-transition-to-dark::view-transition-new(root) {
+ z-index: 1;
+ animation: none;
+ clip-path: none;
+}
+
+@keyframes theme-root-reveal {
+ from {
+ clip-path: circle(0px at 100% 0%);
+ }
+
+ to {
+ clip-path: circle(var(--theme-transition-radius, 160vmax) at 100% 0%);
+ }
+}
+
+@keyframes theme-root-conceal {
+ from {
+ clip-path: circle(var(--theme-transition-radius, 160vmax) at 100% 0%);
+ }
+
+ to {
+ clip-path: circle(0px at 100% 0%);
+ }
+}
+
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */
@plugin "../vendor/heroicons";
diff --git a/assets/js/app.js b/assets/js/app.js
index acb8dc7b..07ffac07 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -92,6 +92,8 @@ document.addEventListener("DOMContentLoaded", () => {
applyImageFallback(img)
}
})
+
+ syncThemeColorMeta()
})
// Handle form reset events from LiveView
@@ -144,7 +146,7 @@ function copyToClipboardFallback(text) {
window.updateAppearancePreference = async (isDark) => {
const appearance = isDark ? "dark" : "light"
- document.documentElement.setAttribute("data-theme", appearance)
+ applyAppearanceWithTransition(appearance)
try {
await fetch("/profile/appearance", {
@@ -162,6 +164,48 @@ window.updateAppearancePreference = async (isDark) => {
}
}
+const applyAppearanceWithTransition = (appearance) => {
+ const root = document.documentElement
+ if (root.getAttribute("data-theme") === appearance) return
+
+ updateThemeColorMeta(appearance)
+ root.classList.add("theme-transition-active")
+ root.classList.add(`theme-transition-to-${appearance}`)
+ const radius = Math.ceil(Math.hypot(window.innerWidth, window.innerHeight))
+ root.style.setProperty("--theme-transition-radius", `${radius}px`)
+
+ const transition = document.startViewTransition(() => {
+ root.setAttribute("data-theme", appearance)
+ })
+
+ transition.finished.finally(() => {
+ root.classList.remove("theme-transition-active")
+ root.classList.remove("theme-transition-to-light", "theme-transition-to-dark")
+ root.style.removeProperty("--theme-transition-radius")
+ })
+}
+
+const syncThemeColorMeta = () => {
+ const appearance = document.documentElement.getAttribute("data-theme")
+ if (appearance) updateThemeColorMeta(appearance)
+}
+
+const updateThemeColorMeta = (appearance) => {
+ let meta = document.querySelector("meta[name='theme-color']")
+ if (!meta) {
+ meta = document.createElement("meta")
+ meta.setAttribute("name", "theme-color")
+ document.head.appendChild(meta)
+ }
+
+ const themeColor = appearance === "dark" ? "#282A36" : "#f8f8f8"
+ meta.removeAttribute("media")
+ meta.setAttribute("content", themeColor)
+ document.querySelectorAll("meta[name='theme-color']").forEach((item) => {
+ if (item !== meta) item.remove()
+ })
+}
+
// Smooth full-page navigation to reduce perceived flicker
const shouldHandleFullPageNav = (event, link) => {
if (!link) return false
diff --git a/lib/vmemo_web/components/layouts.ex b/lib/vmemo_web/components/layouts.ex
index 04ff8e6b..d7c2f259 100644
--- a/lib/vmemo_web/components/layouts.ex
+++ b/lib/vmemo_web/components/layouts.ex
@@ -11,6 +11,9 @@ defmodule VmemoWeb.Layouts do
use VmemoWeb, :html
use Gettext, backend: VmemoWeb.Gettext
+ @theme_color_light "#f8f8f8"
+ @theme_color_dark "#282A36"
+
def html_lang(assigns) do
case profile_language(assigns) do
"zh" -> "zh-CN"
@@ -27,6 +30,16 @@ defmodule VmemoWeb.Layouts do
end
end
+ def theme_color_value(assigns) do
+ case theme_data_value(assigns) do
+ "dark" -> theme_color_dark()
+ _ -> theme_color_light()
+ end
+ end
+
+ def theme_color_light, do: @theme_color_light
+ def theme_color_dark, do: @theme_color_dark
+
attr :logo_href, :string, default: "/"
attr :cta_href, :string, default: "/login"
attr :cta_label, :string, default: nil
diff --git a/lib/vmemo_web/components/layouts/root.html.heex b/lib/vmemo_web/components/layouts/root.html.heex
index 6d4abfe2..38af500a 100644
--- a/lib/vmemo_web/components/layouts/root.html.heex
+++ b/lib/vmemo_web/components/layouts/root.html.heex
@@ -8,6 +8,23 @@
+
+
+
<.live_title suffix=" | Visual Memory that you have seen">
{assigns[:page_title] || "Vmemo"}