From b3cf07d6cbfed3ecf04b10f1655ee5b78166d75c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 30 Dec 2025 20:25:58 +0100 Subject: [PATCH] feat: add ui theme toggle --- ui/src/styles/base.css | 81 ++++++++++++++++++++++++-- ui/src/styles/components.css | 70 +++++++++++++++++++++- ui/src/styles/layout.css | 5 +- ui/src/ui/app-render.ts | 97 ++++++++++++++++++++++++++++++- ui/src/ui/app.ts | 73 +++++++++++++++++++++++ ui/src/ui/storage.ts | 10 ++++ ui/src/ui/theme-transition.ts | 106 ++++++++++++++++++++++++++++++++++ ui/src/ui/theme.ts | 16 +++++ 8 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 ui/src/ui/theme-transition.ts create mode 100644 ui/src/ui/theme.ts diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index dd85475db..ead5d2893 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -3,8 +3,14 @@ :root { --bg: #0a0e14; --bg-accent: #101826; + --bg-grad-1: #1a2740; + --bg-grad-2: #241626; + --bg-overlay: rgba(255, 255, 255, 0.03); + --bg-glow: rgba(54, 207, 201, 0.08); --panel: rgba(18, 24, 36, 0.92); --panel-strong: rgba(24, 32, 46, 0.95); + --chrome: rgba(10, 14, 20, 0.75); + --chrome-strong: rgba(10, 14, 20, 0.82); --text: rgba(246, 248, 252, 0.95); --muted: rgba(210, 218, 230, 0.62); --border: rgba(255, 255, 255, 0.08); @@ -13,6 +19,8 @@ --ok: #1bd98a; --warn: #f2c94c; --danger: #ff5c5c; + --theme-switch-x: 50%; + --theme-switch-y: 50%; --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --font-body: "Space Grotesk", system-ui, sans-serif; @@ -20,6 +28,28 @@ color-scheme: dark; } +:root[data-theme="light"] { + --bg: #f5f7fb; + --bg-accent: #ffffff; + --bg-grad-1: #e3edf9; + --bg-grad-2: #f7e6f0; + --bg-overlay: rgba(28, 32, 46, 0.04); + --bg-glow: rgba(54, 207, 201, 0.12); + --panel: rgba(255, 255, 255, 0.9); + --panel-strong: rgba(255, 255, 255, 0.98); + --chrome: rgba(255, 255, 255, 0.72); + --chrome-strong: rgba(255, 255, 255, 0.82); + --text: rgba(20, 24, 36, 0.96); + --muted: rgba(50, 58, 76, 0.6); + --border: rgba(16, 24, 40, 0.12); + --accent: #ff7a3d; + --accent-2: #1bb9b1; + --ok: #15b97a; + --warn: #c58a1a; + --danger: #e84343; + color-scheme: light; +} + * { box-sizing: border-box; } @@ -32,9 +62,14 @@ body { body { margin: 0; font: 15px/1.4 var(--font-body); - background: radial-gradient(1200px 900px at 20% 0%, #1a2740 0%, var(--bg) 55%) + background: radial-gradient( + 1200px 900px at 20% 0%, + var(--bg-grad-1) 0%, + var(--bg) 55% + ) + fixed, + radial-gradient(900px 700px at 90% 10%, var(--bg-grad-2) 0%, transparent 55%) fixed, - radial-gradient(900px 700px at 90% 10%, #241626 0%, transparent 55%) fixed, var(--bg); color: var(--text); } @@ -45,18 +80,55 @@ body::before { inset: 0; background: linear-gradient( 135deg, - rgba(255, 255, 255, 0.03) 0%, + var(--bg-overlay) 0%, rgba(255, 255, 255, 0) 35% ), radial-gradient( 600px 400px at 80% 80%, - rgba(54, 207, 201, 0.08), + var(--bg-glow), transparent 60% ); pointer-events: none; z-index: 0; } +@keyframes theme-circle-transition { + 0% { + clip-path: circle( + 0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%) + ); + } + + 100% { + clip-path: circle( + 150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%) + ); + } +} + +html.theme-transition { + view-transition-name: theme; +} + +html.theme-transition::view-transition-old(theme) { + mix-blend-mode: normal; + animation: none; + z-index: 1; +} + +html.theme-transition::view-transition-new(theme) { + mix-blend-mode: normal; + z-index: 2; + animation: theme-circle-transition 0.45s ease-out forwards; +} + +@media (prefers-reduced-motion: reduce) { + html.theme-transition::view-transition-old(theme), + html.theme-transition::view-transition-new(theme) { + animation: none !important; + } +} + clawdis-app { display: block; position: relative; @@ -86,4 +158,3 @@ select { transform: translateY(0); } } - diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 3a5a09999..4b6ccc8dc 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -84,7 +84,75 @@ border: 1px solid var(--border); padding: 6px 10px; border-radius: 999px; - background: rgba(0, 0, 0, 0.3); + background: var(--panel); +} + +.theme-toggle { + --theme-item: 28px; + --theme-gap: 6px; + --theme-pad: 6px; + position: relative; +} + +.theme-toggle__track { + position: relative; + display: grid; + grid-template-columns: repeat(3, var(--theme-item)); + gap: var(--theme-gap); + padding: var(--theme-pad); + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); +} + +.theme-toggle__indicator { + position: absolute; + top: 50%; + left: var(--theme-pad); + width: var(--theme-item); + height: var(--theme-item); + border-radius: 999px; + transform: translateY(-50%) + translateX(calc(var(--theme-index, 0) * (var(--theme-item) + var(--theme-gap)))); + background: var(--panel-strong); + border: 1px solid var(--border); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25); + transition: transform 180ms ease-out, background 180ms ease-out; + z-index: 0; +} + +.theme-toggle__button { + height: var(--theme-item); + width: var(--theme-item); + display: grid; + place-items: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + cursor: pointer; + position: relative; + z-index: 1; + transition: color 150ms ease-out, background 150ms ease-out; +} + +.theme-toggle__button:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.08); +} + +.theme-toggle__button.active { + color: var(--text); +} + +.theme-icon { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.75px; + stroke-linecap: round; + stroke-linejoin: round; } .pill.danger { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 0277730e4..1a81fa776 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -15,7 +15,7 @@ align-items: center; padding: 18px 24px; border-bottom: 1px solid var(--border); - background: rgba(10, 14, 20, 0.75); + background: var(--chrome); backdrop-filter: blur(16px); } @@ -40,7 +40,7 @@ grid-area: nav; padding: 18px 16px; border-right: 1px solid var(--border); - background: rgba(10, 14, 20, 0.8); + background: var(--chrome-strong); } .nav-group { @@ -197,4 +197,3 @@ grid-template-columns: 1fr; } } - diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1130954dc..c82894b95 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -8,6 +8,9 @@ import { titleForTab, type Tab, } from "./navigation"; +import type { UiSettings } from "./storage"; +import type { ThemeMode } from "./theme"; +import type { ThemeTransitionContext } from "./theme-transition"; import type { ConfigSnapshot, CronJob, @@ -54,11 +57,13 @@ export type EventLogEntry = { }; export type AppViewState = { - settings: { gatewayUrl: string; token: string; sessionKey: string }; + settings: UiSettings; password: string; tab: Tab; basePath: string; connected: boolean; + theme: ThemeMode; + themeResolved: "light" | "dark"; hello: GatewayHelloOk | null; lastError: string | null; eventLog: EventLogEntry[]; @@ -127,7 +132,8 @@ export type AppViewState = { client: GatewayBrowserClient | null; connect: () => void; setTab: (tab: Tab) => void; - applySettings: (next: AppViewState["settings"]) => void; + setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void; + applySettings: (next: UiSettings) => void; loadOverview: () => Promise; loadCron: () => Promise; handleWhatsAppStart: (force: boolean) => Promise; @@ -155,6 +161,7 @@ export function renderApp(state: AppViewState) { Health ${state.connected ? "OK" : "Offline"} + ${renderThemeToggle(state)}