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"}