feat: add ui theme toggle
This commit is contained in:
@@ -3,8 +3,14 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #0a0e14;
|
--bg: #0a0e14;
|
||||||
--bg-accent: #101826;
|
--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: rgba(18, 24, 36, 0.92);
|
||||||
--panel-strong: rgba(24, 32, 46, 0.95);
|
--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);
|
--text: rgba(246, 248, 252, 0.95);
|
||||||
--muted: rgba(210, 218, 230, 0.62);
|
--muted: rgba(210, 218, 230, 0.62);
|
||||||
--border: rgba(255, 255, 255, 0.08);
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
@@ -13,6 +19,8 @@
|
|||||||
--ok: #1bd98a;
|
--ok: #1bd98a;
|
||||||
--warn: #f2c94c;
|
--warn: #f2c94c;
|
||||||
--danger: #ff5c5c;
|
--danger: #ff5c5c;
|
||||||
|
--theme-switch-x: 50%;
|
||||||
|
--theme-switch-y: 50%;
|
||||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
"Liberation Mono", "Courier New", monospace;
|
"Liberation Mono", "Courier New", monospace;
|
||||||
--font-body: "Space Grotesk", system-ui, sans-serif;
|
--font-body: "Space Grotesk", system-ui, sans-serif;
|
||||||
@@ -20,6 +28,28 @@
|
|||||||
color-scheme: dark;
|
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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -32,9 +62,14 @@ body {
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: 15px/1.4 var(--font-body);
|
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,
|
fixed,
|
||||||
radial-gradient(900px 700px at 90% 10%, #241626 0%, transparent 55%) fixed,
|
|
||||||
var(--bg);
|
var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
@@ -45,18 +80,55 @@ body::before {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(255, 255, 255, 0.03) 0%,
|
var(--bg-overlay) 0%,
|
||||||
rgba(255, 255, 255, 0) 35%
|
rgba(255, 255, 255, 0) 35%
|
||||||
),
|
),
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
600px 400px at 80% 80%,
|
600px 400px at 80% 80%,
|
||||||
rgba(54, 207, 201, 0.08),
|
var(--bg-glow),
|
||||||
transparent 60%
|
transparent 60%
|
||||||
);
|
);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
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 {
|
clawdis-app {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -86,4 +158,3 @@ select {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,75 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 999px;
|
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 {
|
.pill.danger {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 18px 24px;
|
padding: 18px 24px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(10, 14, 20, 0.75);
|
background: var(--chrome);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
grid-area: nav;
|
grid-area: nav;
|
||||||
padding: 18px 16px;
|
padding: 18px 16px;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
background: rgba(10, 14, 20, 0.8);
|
background: var(--chrome-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group {
|
.nav-group {
|
||||||
@@ -197,4 +197,3 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
titleForTab,
|
titleForTab,
|
||||||
type Tab,
|
type Tab,
|
||||||
} from "./navigation";
|
} from "./navigation";
|
||||||
|
import type { UiSettings } from "./storage";
|
||||||
|
import type { ThemeMode } from "./theme";
|
||||||
|
import type { ThemeTransitionContext } from "./theme-transition";
|
||||||
import type {
|
import type {
|
||||||
ConfigSnapshot,
|
ConfigSnapshot,
|
||||||
CronJob,
|
CronJob,
|
||||||
@@ -54,11 +57,13 @@ export type EventLogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AppViewState = {
|
export type AppViewState = {
|
||||||
settings: { gatewayUrl: string; token: string; sessionKey: string };
|
settings: UiSettings;
|
||||||
password: string;
|
password: string;
|
||||||
tab: Tab;
|
tab: Tab;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
theme: ThemeMode;
|
||||||
|
themeResolved: "light" | "dark";
|
||||||
hello: GatewayHelloOk | null;
|
hello: GatewayHelloOk | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
eventLog: EventLogEntry[];
|
eventLog: EventLogEntry[];
|
||||||
@@ -127,7 +132,8 @@ export type AppViewState = {
|
|||||||
client: GatewayBrowserClient | null;
|
client: GatewayBrowserClient | null;
|
||||||
connect: () => void;
|
connect: () => void;
|
||||||
setTab: (tab: Tab) => void;
|
setTab: (tab: Tab) => void;
|
||||||
applySettings: (next: AppViewState["settings"]) => void;
|
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||||
|
applySettings: (next: UiSettings) => void;
|
||||||
loadOverview: () => Promise<void>;
|
loadOverview: () => Promise<void>;
|
||||||
loadCron: () => Promise<void>;
|
loadCron: () => Promise<void>;
|
||||||
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
||||||
@@ -155,6 +161,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
<span>Health</span>
|
<span>Health</span>
|
||||||
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
|
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${renderThemeToggle(state)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<aside class="nav">
|
<aside class="nav">
|
||||||
@@ -383,3 +390,89 @@ function renderTab(state: AppViewState, tab: Tab) {
|
|||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
|
||||||
|
|
||||||
|
function renderThemeToggle(state: AppViewState) {
|
||||||
|
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
|
||||||
|
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
|
||||||
|
const element = event.currentTarget as HTMLElement;
|
||||||
|
const context: ThemeTransitionContext = { element };
|
||||||
|
if (event.clientX || event.clientY) {
|
||||||
|
context.pointerClientX = event.clientX;
|
||||||
|
context.pointerClientY = event.clientY;
|
||||||
|
}
|
||||||
|
state.setTheme(next, context);
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="theme-toggle" style="--theme-index: ${index};">
|
||||||
|
<div class="theme-toggle__track" role="group" aria-label="Theme">
|
||||||
|
<span class="theme-toggle__indicator"></span>
|
||||||
|
<button
|
||||||
|
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
|
||||||
|
@click=${applyTheme("system")}
|
||||||
|
aria-pressed=${state.theme === "system"}
|
||||||
|
aria-label="System theme"
|
||||||
|
title="System"
|
||||||
|
>
|
||||||
|
${renderMonitorIcon()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
|
||||||
|
@click=${applyTheme("light")}
|
||||||
|
aria-pressed=${state.theme === "light"}
|
||||||
|
aria-label="Light theme"
|
||||||
|
title="Light"
|
||||||
|
>
|
||||||
|
${renderSunIcon()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
|
||||||
|
@click=${applyTheme("dark")}
|
||||||
|
aria-pressed=${state.theme === "dark"}
|
||||||
|
aria-label="Dark theme"
|
||||||
|
title="Dark"
|
||||||
|
>
|
||||||
|
${renderMoonIcon()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSunIcon() {
|
||||||
|
return html`
|
||||||
|
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="4"></circle>
|
||||||
|
<path d="M12 2v2"></path>
|
||||||
|
<path d="M12 20v2"></path>
|
||||||
|
<path d="m4.93 4.93 1.41 1.41"></path>
|
||||||
|
<path d="m17.66 17.66 1.41 1.41"></path>
|
||||||
|
<path d="M2 12h2"></path>
|
||||||
|
<path d="M20 12h2"></path>
|
||||||
|
<path d="m6.34 17.66-1.41 1.41"></path>
|
||||||
|
<path d="m19.07 4.93-1.41 1.41"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMoonIcon() {
|
||||||
|
return html`
|
||||||
|
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMonitorIcon() {
|
||||||
|
return html`
|
||||||
|
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
|
||||||
|
<line x1="8" x2="16" y1="21" y2="21"></line>
|
||||||
|
<line x1="12" x2="12" y1="17" y2="21"></line>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } fro
|
|||||||
import { loadSettings, saveSettings, type UiSettings } from "./storage";
|
import { loadSettings, saveSettings, type UiSettings } from "./storage";
|
||||||
import { renderApp } from "./app-render";
|
import { renderApp } from "./app-render";
|
||||||
import { normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
|
import { normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
|
||||||
|
import {
|
||||||
|
resolveTheme,
|
||||||
|
type ResolvedTheme,
|
||||||
|
type ThemeMode,
|
||||||
|
} from "./theme";
|
||||||
|
import {
|
||||||
|
startThemeTransition,
|
||||||
|
type ThemeTransitionContext,
|
||||||
|
} from "./theme-transition";
|
||||||
import type {
|
import type {
|
||||||
ConfigSnapshot,
|
ConfigSnapshot,
|
||||||
CronJob,
|
CronJob,
|
||||||
@@ -72,6 +81,8 @@ export class ClawdisApp extends LitElement {
|
|||||||
@state() password = "";
|
@state() password = "";
|
||||||
@state() tab: Tab = "chat";
|
@state() tab: Tab = "chat";
|
||||||
@state() connected = false;
|
@state() connected = false;
|
||||||
|
@state() theme: ThemeMode = this.settings.theme ?? "system";
|
||||||
|
@state() themeResolved: ResolvedTheme = "dark";
|
||||||
@state() hello: GatewayHelloOk | null = null;
|
@state() hello: GatewayHelloOk | null = null;
|
||||||
@state() lastError: string | null = null;
|
@state() lastError: string | null = null;
|
||||||
@state() eventLog: EventLogEntry[] = [];
|
@state() eventLog: EventLogEntry[] = [];
|
||||||
@@ -159,6 +170,8 @@ export class ClawdisApp extends LitElement {
|
|||||||
private chatScrollFrame: number | null = null;
|
private chatScrollFrame: number | null = null;
|
||||||
basePath = "";
|
basePath = "";
|
||||||
private popStateHandler = () => this.onPopState();
|
private popStateHandler = () => this.onPopState();
|
||||||
|
private themeMedia: MediaQueryList | null = null;
|
||||||
|
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
|
||||||
|
|
||||||
createRenderRoot() {
|
createRenderRoot() {
|
||||||
return this;
|
return this;
|
||||||
@@ -168,12 +181,15 @@ export class ClawdisApp extends LitElement {
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.basePath = this.inferBasePath();
|
this.basePath = this.inferBasePath();
|
||||||
this.syncTabWithLocation(true);
|
this.syncTabWithLocation(true);
|
||||||
|
this.syncThemeWithSettings();
|
||||||
|
this.attachThemeListener();
|
||||||
window.addEventListener("popstate", this.popStateHandler);
|
window.addEventListener("popstate", this.popStateHandler);
|
||||||
this.connect();
|
this.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
window.removeEventListener("popstate", this.popStateHandler);
|
window.removeEventListener("popstate", this.popStateHandler);
|
||||||
|
this.detachThemeListener();
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +287,10 @@ export class ClawdisApp extends LitElement {
|
|||||||
applySettings(next: UiSettings) {
|
applySettings(next: UiSettings) {
|
||||||
this.settings = next;
|
this.settings = next;
|
||||||
saveSettings(next);
|
saveSettings(next);
|
||||||
|
if (next.theme !== this.theme) {
|
||||||
|
this.theme = next.theme;
|
||||||
|
this.applyResolvedTheme(resolveTheme(next.theme));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTab(next: Tab) {
|
setTab(next: Tab) {
|
||||||
@@ -279,6 +299,20 @@ export class ClawdisApp extends LitElement {
|
|||||||
this.syncUrlWithTab(next, false);
|
this.syncUrlWithTab(next, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTheme(next: ThemeMode, context?: ThemeTransitionContext) {
|
||||||
|
const applyTheme = () => {
|
||||||
|
this.theme = next;
|
||||||
|
this.applySettings({ ...this.settings, theme: next });
|
||||||
|
this.applyResolvedTheme(resolveTheme(next));
|
||||||
|
};
|
||||||
|
startThemeTransition({
|
||||||
|
nextTheme: next,
|
||||||
|
applyTheme,
|
||||||
|
context,
|
||||||
|
currentTheme: this.theme,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async refreshActiveTab() {
|
private async refreshActiveTab() {
|
||||||
if (this.tab === "overview") await this.loadOverview();
|
if (this.tab === "overview") await this.loadOverview();
|
||||||
if (this.tab === "connections") await this.loadConnections();
|
if (this.tab === "connections") await this.loadConnections();
|
||||||
@@ -302,6 +336,45 @@ export class ClawdisApp extends LitElement {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncThemeWithSettings() {
|
||||||
|
this.theme = this.settings.theme ?? "system";
|
||||||
|
this.applyResolvedTheme(resolveTheme(this.theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyResolvedTheme(resolved: ResolvedTheme) {
|
||||||
|
this.themeResolved = resolved;
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = resolved;
|
||||||
|
root.style.colorScheme = resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachThemeListener() {
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function")
|
||||||
|
return;
|
||||||
|
this.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
this.themeMediaHandler = (event) => {
|
||||||
|
if (this.theme !== "system") return;
|
||||||
|
this.applyResolvedTheme(event.matches ? "dark" : "light");
|
||||||
|
};
|
||||||
|
if ("addEventListener" in this.themeMedia) {
|
||||||
|
this.themeMedia.addEventListener("change", this.themeMediaHandler);
|
||||||
|
} else {
|
||||||
|
this.themeMedia.addListener(this.themeMediaHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachThemeListener() {
|
||||||
|
if (!this.themeMedia || !this.themeMediaHandler) return;
|
||||||
|
if ("removeEventListener" in this.themeMedia) {
|
||||||
|
this.themeMedia.removeEventListener("change", this.themeMediaHandler);
|
||||||
|
} else {
|
||||||
|
this.themeMedia.removeListener(this.themeMediaHandler);
|
||||||
|
}
|
||||||
|
this.themeMedia = null;
|
||||||
|
this.themeMediaHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
private syncTabWithLocation(replace: boolean) {
|
private syncTabWithLocation(replace: boolean) {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const resolved = tabFromPath(window.location.pathname, this.basePath) ?? "chat";
|
const resolved = tabFromPath(window.location.pathname, this.basePath) ?? "chat";
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
const KEY = "clawdis.control.settings.v1";
|
const KEY = "clawdis.control.settings.v1";
|
||||||
|
|
||||||
|
import type { ThemeMode } from "./theme";
|
||||||
|
|
||||||
export type UiSettings = {
|
export type UiSettings = {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
|
theme: ThemeMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function loadSettings(): UiSettings {
|
export function loadSettings(): UiSettings {
|
||||||
@@ -16,6 +19,7 @@ export function loadSettings(): UiSettings {
|
|||||||
gatewayUrl: defaultUrl,
|
gatewayUrl: defaultUrl,
|
||||||
token: "",
|
token: "",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
|
theme: "system",
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -32,6 +36,12 @@ export function loadSettings(): UiSettings {
|
|||||||
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
||||||
? parsed.sessionKey.trim()
|
? parsed.sessionKey.trim()
|
||||||
: defaults.sessionKey,
|
: defaults.sessionKey,
|
||||||
|
theme:
|
||||||
|
parsed.theme === "light" ||
|
||||||
|
parsed.theme === "dark" ||
|
||||||
|
parsed.theme === "system"
|
||||||
|
? parsed.theme
|
||||||
|
: defaults.theme,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaults;
|
return defaults;
|
||||||
|
|||||||
106
ui/src/ui/theme-transition.ts
Normal file
106
ui/src/ui/theme-transition.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { ThemeMode } from "./theme";
|
||||||
|
|
||||||
|
export type ThemeTransitionContext = {
|
||||||
|
element?: HTMLElement | null;
|
||||||
|
pointerClientX?: number;
|
||||||
|
pointerClientY?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThemeTransitionOptions = {
|
||||||
|
nextTheme: ThemeMode;
|
||||||
|
applyTheme: () => void;
|
||||||
|
context?: ThemeTransitionContext;
|
||||||
|
currentTheme?: ThemeMode | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentWithViewTransition = Document & {
|
||||||
|
startViewTransition?: (callback: () => void) => { finished: Promise<void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp01 = (value: number) => {
|
||||||
|
if (Number.isNaN(value)) return 0.5;
|
||||||
|
if (value <= 0) return 0;
|
||||||
|
if (value >= 1) return 1;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasReducedMotionPreference = () => {
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupThemeTransition = (root: HTMLElement) => {
|
||||||
|
root.classList.remove("theme-transition");
|
||||||
|
root.style.removeProperty("--theme-switch-x");
|
||||||
|
root.style.removeProperty("--theme-switch-y");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startThemeTransition = ({
|
||||||
|
nextTheme,
|
||||||
|
applyTheme,
|
||||||
|
context,
|
||||||
|
currentTheme,
|
||||||
|
}: ThemeTransitionOptions) => {
|
||||||
|
if (currentTheme === nextTheme) return;
|
||||||
|
|
||||||
|
const documentReference = globalThis.document ?? null;
|
||||||
|
if (!documentReference) {
|
||||||
|
applyTheme();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = documentReference.documentElement;
|
||||||
|
const document_ = documentReference as DocumentWithViewTransition;
|
||||||
|
const prefersReducedMotion = hasReducedMotionPreference();
|
||||||
|
|
||||||
|
const canUseViewTransition =
|
||||||
|
Boolean(document_.startViewTransition) && !prefersReducedMotion;
|
||||||
|
|
||||||
|
if (canUseViewTransition) {
|
||||||
|
let xPercent = 0.5;
|
||||||
|
let yPercent = 0.5;
|
||||||
|
|
||||||
|
if (
|
||||||
|
context?.pointerClientX !== undefined &&
|
||||||
|
context?.pointerClientY !== undefined &&
|
||||||
|
typeof window !== "undefined"
|
||||||
|
) {
|
||||||
|
xPercent = clamp01(context.pointerClientX / window.innerWidth);
|
||||||
|
yPercent = clamp01(context.pointerClientY / window.innerHeight);
|
||||||
|
} else if (context?.element) {
|
||||||
|
const rect = context.element.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
typeof window !== "undefined"
|
||||||
|
) {
|
||||||
|
xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth);
|
||||||
|
yPercent = clamp01((rect.top + rect.height / 2) / window.innerHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root.style.setProperty("--theme-switch-x", `${xPercent * 100}%`);
|
||||||
|
root.style.setProperty("--theme-switch-y", `${yPercent * 100}%`);
|
||||||
|
root.classList.add("theme-transition");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transition = document_.startViewTransition?.(() => {
|
||||||
|
applyTheme();
|
||||||
|
});
|
||||||
|
if (transition?.finished) {
|
||||||
|
void transition.finished.finally(() => cleanupThemeTransition(root));
|
||||||
|
} else {
|
||||||
|
cleanupThemeTransition(root);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
cleanupThemeTransition(root);
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme();
|
||||||
|
cleanupThemeTransition(root);
|
||||||
|
};
|
||||||
16
ui/src/ui/theme.ts
Normal file
16
ui/src/ui/theme.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type ThemeMode = "system" | "light" | "dark";
|
||||||
|
export type ResolvedTheme = "light" | "dark";
|
||||||
|
|
||||||
|
export function getSystemTheme(): ResolvedTheme {
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
||||||
|
if (mode === "system") return getSystemTheme();
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user