Skip to content

Commit a9b40c4

Browse files
committed
feat: add Liquid Glass interface style with settings selector
Adds a second theme style emulating modern macOS liquid glass, orthogonal to the existing system/light/dark color-scheme toggle: - ThemeProvider gains themeStyle (classic|glass), persisted to localStorage and synced across tabs via data-theme-style on <html> - Glass CSS: palette-derived ambient backdrop, translucent blurred panels with specular rims, rounder radii, plus @supports and prefers-reduced-transparency fallbacks - Settings page gains an Appearance section with a Classic vault / Liquid glass selector - Phoenix app shell bootstraps data-theme-style before hydration to avoid a style flash Closes TASK-31.
1 parent 9d6e708 commit a9b40c4

7 files changed

Lines changed: 298 additions & 5 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
id: TASK-31
3+
title: Add Liquid Glass theme style with settings selector
4+
status: Done
5+
assignee:
6+
- '@cfbender'
7+
created_date: '2026-08-04 15:53'
8+
updated_date: '2026-08-04 16:04'
9+
labels: []
10+
dependencies: []
11+
ordinal: 46000
12+
---
13+
14+
## Description
15+
16+
<!-- SECTION:DESCRIPTION:BEGIN -->
17+
Add a second visual theme that emulates modern macOS Liquid Glass (translucent, blurred, highly rounded surfaces over an ambient backdrop). Users pick between the existing Classic look and Liquid Glass via a selector on the Settings page. The style is orthogonal to the existing System/Light/Dark color-scheme toggle so glass works in both light and dark.
18+
<!-- SECTION:DESCRIPTION:END -->
19+
20+
## Acceptance Criteria
21+
<!-- AC:BEGIN -->
22+
- [x] #1 Settings page has an Appearance section with a selector offering Classic and Liquid Glass
23+
- [x] #2 Selecting Liquid Glass applies translucent blurred surfaces, rounder radii, and an ambient backdrop across the app shell, cards, dialogs, dropdowns, and form controls
24+
- [x] #3 Choice persists across reloads and tabs and composes with the existing system/light/dark theme toggle
25+
- [x] #4 Reduced-transparency/motion preferences and browsers without backdrop-filter still get readable UI
26+
- [x] #5 Typecheck and react tests pass
27+
<!-- AC:END -->
28+
29+
## Implementation Plan
30+
31+
<!-- SECTION:PLAN:BEGIN -->
32+
1. Extend ThemeProvider (assets/react/src/lib/theme.tsx) with an orthogonal themeStyle dimension: classic | glass, persisted to localStorage key manavault:theme-style, applied as data-theme-style on <html>, synced across tabs like theme.
33+
2. Add glass CSS in assets/css/app.css scoped to html[data-theme-style=glass]: rounder daisyUI radius tokens, ambient fixed multi-hue backdrop derived from theme tokens, translucent blurred surfaces (backdrop-filter blur+saturate) for header, cards, dropdown/menu, modal boxes, inputs/selects/buttons, with inset highlight rims; fallbacks via @supports and prefers-reduced-transparency.
34+
3. Add AppearanceSection to Settings (assets/react/src/pages/settings/) using PageSection + card pattern with two selectable style options (Classic, Liquid Glass) wired to useTheme().
35+
4. Verify: aube typecheck/tests, visual check in browser in light+dark.
36+
<!-- SECTION:PLAN:END -->
37+
38+
## Implementation Notes
39+
40+
<!-- SECTION:NOTES:BEGIN -->
41+
Theme style is orthogonal to light/dark: data-theme-style on <html>, localStorage key manavault:theme-style (absent = classic), bootstrapped pre-React in app_controller.ex to avoid style flash and synced across tabs via storage events. Glass CSS targets .card/.dropdown-content/Radix dialog panels/inputs plus app-shell header; card tiles do not use .card so grid perf is unaffected; fullscreen-printing-dialog keeps its bespoke background. Verified via headless Chrome CDP screenshots: classic light unchanged, glass light+dark on /settings and /decks, prefers-reduced-transparency emulation renders opaque panels.
42+
<!-- SECTION:NOTES:END -->
43+
44+
## Final Summary
45+
46+
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
47+
Added a Liquid Glass interface style: ThemeProvider gained themeStyle (classic|glass) persisted and tab-synced; app.css defines glass tokens, palette-derived ambient backdrop, and translucent blurred panels with rounder radii plus @supports and prefers-reduced-transparency fallbacks; Settings gained an Appearance section with Classic/Liquid Glass selector; Phoenix shell bootstraps data-theme-style before hydration. Verified with typecheck, lint, react tests (15 passed), mix compile --warnings-as-errors, and headless-Chrome screenshots in light/dark/reduced-transparency.
48+
<!-- SECTION:FINAL_SUMMARY:END -->

‎assets/css/app.css‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,106 @@
105105
--set-symbol-common-color: #000;
106106
}
107107

108+
/* Liquid Glass theme style (macOS-inspired). Orthogonal to light/dark: it only
109+
changes how surfaces render, not the color palette. Applied via
110+
data-theme-style="glass" on <html>; "classic" leaves everything untouched. */
111+
html[data-theme-style="glass"] {
112+
--radius-selector: 2rem;
113+
--radius-field: 1rem;
114+
--radius-box: 1.5rem;
115+
--border: 1px;
116+
--glass-blur: 20px;
117+
--glass-surface: color-mix(in oklch, var(--color-base-100) 58%, transparent);
118+
--glass-surface-raised: color-mix(in oklch, var(--color-base-200) 52%, transparent);
119+
--glass-field: color-mix(in oklch, var(--color-base-100) 52%, transparent);
120+
--glass-border: color-mix(in oklch, var(--color-base-content) 16%, transparent);
121+
--glass-rim: color-mix(in oklch, white 45%, transparent);
122+
--glass-shadow: 0 12px 32px -12px rgb(0 0 0 / 0.22), 0 1px 2px rgb(0 0 0 / 0.06);
123+
}
124+
125+
html[data-theme-style="glass"][data-theme="dark"] {
126+
--glass-surface: color-mix(in oklch, var(--color-base-200) 55%, transparent);
127+
--glass-surface-raised: color-mix(in oklch, var(--color-base-300) 48%, transparent);
128+
--glass-field: color-mix(in oklch, var(--color-base-200) 50%, transparent);
129+
--glass-border: color-mix(in oklch, white 12%, transparent);
130+
--glass-rim: color-mix(in oklch, white 14%, transparent);
131+
--glass-shadow: 0 16px 40px -12px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.35);
132+
}
133+
134+
/* Ambient backdrop the glass refracts: soft claret/brass/grove pools derived
135+
from the active palette, fixed to the viewport like a desktop wallpaper. */
136+
html[data-theme-style="glass"] body {
137+
background-color: var(--color-base-100);
138+
background-image:
139+
radial-gradient(
140+
56rem 38rem at 14% -6%,
141+
color-mix(in oklch, var(--color-primary), var(--color-base-100) 72%),
142+
transparent 62%
143+
),
144+
radial-gradient(
145+
48rem 34rem at 104% 16%,
146+
color-mix(in oklch, var(--color-accent), var(--color-base-100) 74%),
147+
transparent 64%
148+
),
149+
radial-gradient(
150+
50rem 42rem at 28% 112%,
151+
color-mix(in oklch, var(--color-secondary), var(--color-base-100) 76%),
152+
transparent 62%
153+
);
154+
background-attachment: fixed;
155+
}
156+
157+
html[data-theme-style="glass"] .app-shell-root {
158+
background-color: transparent;
159+
}
160+
161+
html[data-theme-style="glass"] :is(.card, .dropdown-content),
162+
html[data-theme-style="glass"] section[role="dialog"]:not(.fullscreen-printing-dialog) {
163+
background-color: var(--glass-surface);
164+
border-color: var(--glass-border);
165+
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(160%);
166+
backdrop-filter: blur(var(--glass-blur)) saturate(160%);
167+
box-shadow:
168+
inset 0 1px 0 0 var(--glass-rim),
169+
var(--glass-shadow);
170+
}
171+
172+
html[data-theme-style="glass"] .app-shell-header:not(.bg-transparent) {
173+
background-color: color-mix(in oklch, var(--color-base-100) 50%, transparent);
174+
border-bottom: 1px solid var(--glass-border);
175+
-webkit-backdrop-filter: blur(24px) saturate(170%);
176+
backdrop-filter: blur(24px) saturate(170%);
177+
}
178+
179+
html[data-theme-style="glass"] :is(.input, .select, .textarea) {
180+
background-color: var(--glass-field);
181+
border-color: var(--glass-border);
182+
box-shadow: inset 0 1px 0 0 color-mix(in oklch, white 18%, transparent);
183+
}
184+
185+
html[data-theme-style="glass"] :is(.btn-outline, .btn-ghost):hover {
186+
background-color: var(--glass-surface-raised);
187+
}
188+
189+
/* Keep surfaces readable when translucency is unwanted or unsupported. */
190+
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
191+
html[data-theme-style="glass"] :is(.card, .dropdown-content, .input, .select, .textarea),
192+
html[data-theme-style="glass"] section[role="dialog"]:not(.fullscreen-printing-dialog),
193+
html[data-theme-style="glass"] .app-shell-header:not(.bg-transparent) {
194+
background-color: color-mix(in oklch, var(--color-base-100) 94%, transparent);
195+
}
196+
}
197+
198+
@media (prefers-reduced-transparency: reduce) {
199+
html[data-theme-style="glass"] :is(.card, .dropdown-content, .input, .select, .textarea),
200+
html[data-theme-style="glass"] section[role="dialog"]:not(.fullscreen-printing-dialog),
201+
html[data-theme-style="glass"] .app-shell-header:not(.bg-transparent) {
202+
background-color: var(--color-base-100);
203+
-webkit-backdrop-filter: none;
204+
backdrop-filter: none;
205+
}
206+
}
207+
108208
.fullscreen-printing-dialog {
109209
--card-modal-bg: color-mix(in oklch, var(--color-primary), var(--color-base-100) 88%);
110210
--card-modal-fg: color-mix(in oklch, var(--color-primary), black 50%);

‎assets/react/src/components/app-shell.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function AppShell() {
116116

117117
return (
118118
<PageTitleProvider>
119-
<div className="min-h-screen bg-base-100 text-base-content">
119+
<div className="app-shell-root min-h-screen bg-base-100 text-base-content">
120120
<header
121121
className={cn(
122122
"app-shell-header sticky top-0 z-30",

‎assets/react/src/lib/theme.tsx‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react"
22

33
type Theme = "system" | "light" | "dark"
4+
export type ThemeStyle = "classic" | "glass"
45

5-
const ThemeContext = createContext<{ theme: Theme; setTheme: (theme: Theme) => void } | null>(null)
6+
const ThemeContext = createContext<{
7+
theme: Theme
8+
setTheme: (theme: Theme) => void
9+
themeStyle: ThemeStyle
10+
setThemeStyle: (style: ThemeStyle) => void
11+
} | null>(null)
612
const storageKey = "manavault:theme"
13+
const styleStorageKey = "manavault:theme-style"
714

815
function systemTheme() {
916
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
@@ -17,6 +24,14 @@ function storedTheme() {
1724
}
1825
}
1926

27+
function storedThemeStyle(): ThemeStyle {
28+
try {
29+
return localStorage.getItem(styleStorageKey) === "glass" ? "glass" : "classic"
30+
} catch {
31+
return "classic"
32+
}
33+
}
34+
2035
function persistTheme(theme: Theme) {
2136
try {
2237
if (theme === "system") {
@@ -29,6 +44,18 @@ function persistTheme(theme: Theme) {
2944
}
3045
}
3146

47+
function persistThemeStyle(style: ThemeStyle) {
48+
try {
49+
if (style === "classic") {
50+
localStorage.removeItem(styleStorageKey)
51+
} else {
52+
localStorage.setItem(styleStorageKey, style)
53+
}
54+
} catch {
55+
// Storage can be unavailable or full. The DOM style still applies for this page load.
56+
}
57+
}
58+
3259
function applyTheme(theme: Theme) {
3360
const resolved = theme === "system" ? systemTheme() : theme
3461
document.documentElement.setAttribute("data-theme", resolved)
@@ -37,20 +64,34 @@ function applyTheme(theme: Theme) {
3764
persistTheme(theme)
3865
}
3966

67+
function applyThemeStyle(style: ThemeStyle) {
68+
document.documentElement.setAttribute("data-theme-style", style)
69+
70+
persistThemeStyle(style)
71+
}
72+
4073
export function ThemeProvider({ children }: { children: ReactNode }) {
4174
const [theme, setTheme] = useState<Theme>(storedTheme)
75+
const [themeStyle, setThemeStyle] = useState<ThemeStyle>(storedThemeStyle)
4276

4377
useEffect(() => {
4478
applyTheme(theme)
4579
}, [theme])
4680

81+
useEffect(() => {
82+
applyThemeStyle(themeStyle)
83+
}, [themeStyle])
84+
4785
useEffect(() => {
4886
const media = window.matchMedia("(prefers-color-scheme: dark)")
4987
const handleSystemChange = () => {
5088
if (storedTheme() === "system") applyTheme("system")
5189
}
5290
const handleStorage = (event: StorageEvent) => {
5391
if (event.key === storageKey) setTheme((event.newValue as Theme | null) || "system")
92+
if (event.key === styleStorageKey) {
93+
setThemeStyle(event.newValue === "glass" ? "glass" : "classic")
94+
}
5495
}
5596

5697
media.addEventListener("change", handleSystemChange)
@@ -62,7 +103,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
62103
}
63104
}, [])
64105

65-
const value = useMemo(() => ({ theme, setTheme }), [theme])
106+
const value = useMemo(() => ({ theme, setTheme, themeStyle, setThemeStyle }), [theme, themeStyle])
66107

67108
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
68109
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Check, Droplets, Vault } from "lucide-react"
2+
import { PageSection } from "../../components/app-shell"
3+
import { useTheme, type ThemeStyle } from "../../lib/theme"
4+
import { cn } from "../../lib/utils"
5+
6+
const styleOptions: {
7+
value: ThemeStyle
8+
label: string
9+
description: string
10+
icon: typeof Vault
11+
}[] = [
12+
{
13+
value: "classic",
14+
label: "Classic vault",
15+
description: "Solid, tactile surfaces with crisp borders and compact corners.",
16+
icon: Vault,
17+
},
18+
{
19+
value: "glass",
20+
label: "Liquid glass",
21+
description:
22+
"Translucent, blurred panels over an ambient backdrop, in the spirit of modern macOS.",
23+
icon: Droplets,
24+
},
25+
]
26+
27+
export function AppearanceSection() {
28+
const { themeStyle, setThemeStyle } = useTheme()
29+
30+
return (
31+
<PageSection title="Appearance" count="Interface style">
32+
<div className="card border border-base-300 bg-base-100 shadow-sm">
33+
<div className="card-body gap-4 p-6">
34+
<div>
35+
<h2 className="text-2xl font-black tracking-normal">Interface style</h2>
36+
<p className="mt-1 text-sm text-base-content/60">
37+
Choose how surfaces render. Light and dark modes stay on the toggle in the navigation
38+
and apply to both styles.
39+
</p>
40+
</div>
41+
<div className="grid gap-3 sm:grid-cols-2">
42+
{styleOptions.map((option) => {
43+
const selected = themeStyle === option.value
44+
45+
return (
46+
<button
47+
key={option.value}
48+
type="button"
49+
aria-pressed={selected}
50+
onClick={() => setThemeStyle(option.value)}
51+
className={cn(
52+
"flex items-start gap-3 rounded-box border p-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35",
53+
selected
54+
? "border-primary/50 bg-primary/10"
55+
: "border-base-300 bg-base-200/40 hover:border-primary/40",
56+
)}
57+
>
58+
<option.icon
59+
className={cn(
60+
"mt-0.5 h-5 w-5 shrink-0",
61+
selected ? "text-primary" : "text-base-content/70",
62+
)}
63+
/>
64+
<span className="min-w-0">
65+
<span className="flex items-center gap-2 font-bold">
66+
{option.label}
67+
{selected ? <Check className="h-4 w-4 text-primary" /> : null}
68+
</span>
69+
<span className="mt-1 block text-sm text-base-content/60">
70+
{option.description}
71+
</span>
72+
</span>
73+
</button>
74+
)
75+
})}
76+
</div>
77+
</div>
78+
</div>
79+
</PageSection>
80+
)
81+
}

‎assets/react/src/pages/settings/page.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { pluralize, present } from "../../lib/utils"
77
import { AutoSortSummaryDialog } from "../collection/auto-sort-summary-dialog"
88
import { AutoSortCollectionDocument } from "../collection/documents"
99
import type { AutoSortCollectionResult } from "../collection/types"
10+
import { AppearanceSection } from "./appearance-section"
1011
import { BackupSettingsForm } from "./backup-settings-form"
1112
import { CollectionAutoSortSection } from "./collection-auto-sort-section"
1213
import { DefaultDeckTagsSection } from "./default-deck-tags-section"
@@ -305,7 +306,7 @@ export function SettingsPage() {
305306
<PageHeader
306307
eyebrow="Settings"
307308
title="Settings"
308-
description="Manage the mobile shell, collection auto-sort rules, cloud backups, manual restores, and Scryfall catalog maintenance."
309+
description="Manage appearance, the mobile shell, collection auto-sort rules, cloud backups, manual restores, and Scryfall catalog maintenance."
309310
/>
310311

311312
{settingsQuery.error ? <Alert tone="error">{errorMessage(settingsQuery.error)}</Alert> : null}
@@ -319,6 +320,8 @@ export function SettingsPage() {
319320
{error ? <Alert tone="error">{error}</Alert> : null}
320321
{message ? <Alert tone="success">{message}</Alert> : null}
321322

323+
<AppearanceSection />
324+
322325
{nativeShell ? <NativeAppSection {...nativeSectionProps} /> : null}
323326

324327
<BackupSettingsForm

‎lib/manavault_web/controllers/app_controller.ex‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,27 @@ defmodule ManavaultWeb.AppController do
181181
if (!document.documentElement.hasAttribute("data-theme")) {
182182
setTheme(storedTheme());
183183
}
184-
window.addEventListener("storage", (e) => e.key === storageKey && setTheme(e.newValue || "system"));
184+
185+
const styleStorageKey = "manavault:theme-style";
186+
187+
const storedThemeStyle = () => {
188+
try {
189+
return localStorage.getItem(styleStorageKey) === "glass" ? "glass" : "classic";
190+
} catch {
191+
return "classic";
192+
}
193+
};
194+
195+
const setThemeStyle = (style) => {
196+
document.documentElement.setAttribute("data-theme-style", style === "glass" ? "glass" : "classic");
197+
};
198+
if (!document.documentElement.hasAttribute("data-theme-style")) {
199+
setThemeStyle(storedThemeStyle());
200+
}
201+
window.addEventListener("storage", (e) => {
202+
if (e.key === storageKey) setTheme(e.newValue || "system");
203+
if (e.key === styleStorageKey) setThemeStyle(e.newValue);
204+
});
185205
186206
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
187207
if (document.documentElement.getAttribute("data-theme-source") === "system") {

0 commit comments

Comments
 (0)