Skip to content
Closed
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
46 changes: 45 additions & 1 deletion assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ document.addEventListener("DOMContentLoaded", () => {
applyImageFallback(img)
}
})

syncThemeColorMeta()
})

// Handle form reset events from LiveView
Expand Down Expand Up @@ -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", {
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions lib/vmemo_web/components/layouts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions lib/vmemo_web/components/layouts/root.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<meta
:if={VmemoWeb.Layouts.theme_data_value(assigns)}
name="theme-color"
content={VmemoWeb.Layouts.theme_color_value(assigns)}
/>
<meta
:if={!VmemoWeb.Layouts.theme_data_value(assigns)}
name="theme-color"
media="(prefers-color-scheme: light)"
content={VmemoWeb.Layouts.theme_color_light()}
/>
<meta
:if={!VmemoWeb.Layouts.theme_data_value(assigns)}
name="theme-color"
media="(prefers-color-scheme: dark)"
content={VmemoWeb.Layouts.theme_color_dark()}
/>
<.live_title suffix=" | Visual Memory that you have seen">
{assigns[:page_title] || "Vmemo"}
</.live_title>
Expand Down