diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index ba766090fa7..f915a5d1610 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -747,7 +747,7 @@ function classifyTarget(arg, cwd) { if (relative.startsWith("src/plugins/")) { return "plugin"; } - if (relative.startsWith("ui/src/ui/")) { + if (relative.startsWith("ui/src/")) { return "ui"; } if (relative.startsWith("src/utils/")) { @@ -776,6 +776,17 @@ function resolveLightLaneIncludePatterns(kind, targetArg, cwd) { return null; } +function shouldUseWholeConfigTarget(kind, targetArg, cwd) { + if (isVitestConfigTargetForKind(kind, targetArg, cwd)) { + return true; + } + if (kind !== "ui") { + return false; + } + const relative = toRepoRelativeTarget(targetArg, cwd); + return relative.startsWith("ui/src/") && !relative.startsWith("ui/src/ui/"); +} + function createVitestArgs(params) { return [ "exec", @@ -956,7 +967,7 @@ export function buildVitestRunPlans( (kind === "default" && grouped.every((targetArg) => isFileLikeTarget(toRepoRelativeTarget(targetArg, cwd)))); const useWholeConfigTarget = grouped.some((targetArg) => - isVitestConfigTargetForKind(kind, targetArg, cwd), + shouldUseWholeConfigTarget(kind, targetArg, cwd), ); const includePatterns = useCliTargetArgs ? null diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 396465d3673..476bbc333be 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -259,6 +259,22 @@ describe("scripts/test-projects changed-target routing", () => { ]); }); + it("routes changed ui support files to the ui lane without dead include globs", () => { + const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [ + "ui/src/styles/base.css", + "ui/src/test-helpers/lit-warnings.setup.ts", + ]); + + expect(plans).toEqual([ + { + config: "test/vitest/vitest.ui.config.ts", + forwardedArgs: [], + includePatterns: null, + watchMode: false, + }, + ]); + }); + it("routes auto-reply route source files to route regression tests", () => { expect( resolveChangedTestTargetPlan([ @@ -274,7 +290,6 @@ describe("scripts/test-projects changed-target routing", () => { ], }); }); - it("routes changed utils and shared files to their light scoped lanes", () => { const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [ "src/shared/string-normalization.ts", diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 9fe8289a5d8..c001fcc6355 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -188,13 +188,13 @@ } /* Scrollbar - visible on light backgrounds */ -:root[data-theme="light"]::-webkit-scrollbar-thumb, -:root[data-theme="light"] ::-webkit-scrollbar-thumb { +:root[data-theme-mode="light"]::-webkit-scrollbar-thumb, +:root[data-theme-mode="light"] ::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.15); } -:root[data-theme="light"]::-webkit-scrollbar-thumb:hover, -:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover { +:root[data-theme-mode="light"]::-webkit-scrollbar-thumb:hover, +:root[data-theme-mode="light"] ::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.25); } diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 45faa241f47..5a66ec482ce 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -184,9 +184,10 @@ /* Actions Bar */ .config-actions { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - gap: 12px; + flex-wrap: wrap; + gap: 10px 16px; padding: 10px 20px; background: var(--bg-accent); border-bottom: 1px solid var(--border); @@ -204,6 +205,41 @@ display: flex; align-items: center; gap: 8px; + min-width: 0; +} + +.config-actions__left { + flex: 1 1 280px; + flex-wrap: wrap; +} + +.config-actions__right { + flex: 999 1 420px; + align-items: flex-start; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px 12px; +} + +.config-actions__notice { + flex: 0 1 24rem; + max-width: 24rem; + line-height: 1.35; + text-align: right; +} + +.config-actions__buttons { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + min-width: 0; + margin-left: auto; +} + +.config-actions__buttons .btn { + flex: 0 0 auto; } .config-changes-badge { @@ -231,6 +267,7 @@ .config-status { font-size: 12.5px; color: var(--muted); + line-height: 1.35; } .config-top-tabs { @@ -522,12 +559,26 @@ transform: translateY(-1px); } +.settings-theme-card:disabled { + cursor: not-allowed; +} + .settings-theme-card--active { border-color: color-mix(in srgb, var(--accent) 35%, transparent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent); } +.settings-theme-card--disabled { + opacity: 0.6; +} + +.settings-theme-card--disabled:hover { + border-color: var(--border); + background: var(--bg); + transform: none; +} + .settings-theme-card__icon, .settings-theme-card__check { display: inline-flex; @@ -552,6 +603,109 @@ color: var(--accent); } +.settings-theme-import { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg); +} + +.settings-theme-import__copy { + display: grid; + gap: 4px; +} + +.settings-theme-import__title { + font-size: 13px; + font-weight: 650; + color: var(--text-strong); +} + +.settings-theme-import__hint { + margin: 0; + font-size: 12px; + line-height: 1.45; + color: var(--muted); +} + +.settings-theme-import__field { + display: grid; + gap: 6px; +} + +.settings-theme-import__label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} + +.settings-theme-import__input { + width: 100%; + min-width: 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--text); + font: inherit; +} + +.settings-theme-import__input::placeholder { + color: var(--muted); +} + +.settings-theme-import__input:focus { + outline: none; + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + box-shadow: var(--focus-ring); +} + +.settings-theme-import__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.settings-theme-import__meta { + display: grid; + gap: 4px; +} + +.settings-theme-import__meta-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} + +.settings-theme-import__meta-value { + font-size: 12px; + color: var(--text); + overflow-wrap: anywhere; +} + +.settings-theme-import__message { + padding: 10px 12px; + border-radius: var(--radius-md); + font-size: 12px; + line-height: 1.45; +} + +.settings-theme-import__message--success { + background: var(--ok-subtle); + color: var(--text); +} + +.settings-theme-import__message--error { + background: var(--danger-subtle); + color: var(--text); +} + /* Roundness options */ .settings-roundness__options { display: flex; @@ -1790,3 +1944,30 @@ min-width: 70px; } } + +@media (max-width: 900px) { + .config-actions { + padding: 10px 14px; + } + + .config-actions__left, + .config-actions__right { + flex-basis: 100%; + } + + .config-actions__right { + justify-content: flex-start; + } + + .config-actions__notice { + flex-basis: 100%; + max-width: none; + text-align: left; + } + + .config-actions__buttons { + width: 100%; + justify-content: flex-start; + margin-left: 0; + } +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 268173d0ab3..ce85d3d7f15 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -867,6 +867,15 @@ export function renderApp(state: AppViewState) { themeMode: state.themeMode, setTheme: (theme, context) => state.setTheme(theme, context), setThemeMode: (mode, context) => state.setThemeMode(mode, context), + hasCustomTheme: Boolean(state.settings.customTheme), + customThemeLabel: state.settings.customTheme?.label ?? null, + customThemeSourceUrl: state.settings.customTheme?.sourceUrl ?? null, + customThemeImportUrl: state.customThemeImportUrl, + customThemeImportBusy: state.customThemeImportBusy, + customThemeImportMessage: state.customThemeImportMessage, + onCustomThemeImportUrlChange: (next) => state.setCustomThemeImportUrl(next), + onImportCustomTheme: () => void state.importCustomTheme(), + onClearCustomTheme: () => state.clearCustomTheme(), borderRadius: state.settings.borderRadius, setBorderRadius: (value) => state.setBorderRadius(value), gatewayUrl: state.settings.gatewayUrl, @@ -1007,6 +1016,8 @@ export function renderApp(state: AppViewState) { }, theme: state.theme, themeMode: state.themeMode, + hasCustomTheme: Boolean(state.settings.customTheme), + customThemeLabel: state.settings.customTheme?.label ?? null, borderRadius: state.settings.borderRadius, setTheme: (theme, context) => state.setTheme(theme, context), setThemeMode: (mode, context) => state.setThemeMode(mode, context), diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index f3d8078a325..4dc2a67b408 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -7,6 +7,7 @@ import { setTabFromRoute, syncThemeWithSettings, } from "./app-settings.ts"; +import { normalizeImportedCustomTheme } from "./custom-theme.ts"; import type { ThemeMode, ThemeName } from "./theme.ts"; type Tab = @@ -45,6 +46,7 @@ type SettingsHost = { navWidth: number; navGroupsCollapsed: Record; borderRadius: number; + customTheme?: import("./custom-theme.ts").ImportedCustomTheme; }; theme: ThemeName & ThemeMode; themeMode: ThemeMode; @@ -180,6 +182,66 @@ const createHost = (tab: Tab): SettingsHost => ({ wikiMemoryPalace: null, }); +function createCustomThemeFixture() { + return normalizeImportedCustomTheme( + { + name: "Light Green", + cssVars: { + theme: { + "font-sans": "Inter, system-ui, sans-serif", + "font-mono": "JetBrains Mono, monospace", + }, + light: { + background: "oklch(0.98 0.01 120)", + foreground: "oklch(0.2 0.03 265)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.2 0.03 265)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.2 0.03 265)", + primary: "oklch(0.8 0.2 128)", + "primary-foreground": "oklch(0 0 0)", + secondary: "oklch(0.35 0.03 257)", + "secondary-foreground": "oklch(0.98 0.01 248)", + muted: "oklch(0.96 0.01 248)", + "muted-foreground": "oklch(0.55 0.04 257)", + accent: "oklch(0.98 0.02 155)", + "accent-foreground": "oklch(0.45 0.1 151)", + destructive: "oklch(0.64 0.2 25)", + "destructive-foreground": "oklch(1 0 0)", + border: "oklch(0.92 0.01 255)", + input: "oklch(0.92 0.01 255)", + ring: "oklch(0.8 0.2 128)", + }, + dark: { + background: "oklch(0.12 0.04 265)", + foreground: "oklch(0.98 0.01 248)", + card: "oklch(0.2 0.04 266)", + "card-foreground": "oklch(0.98 0.01 248)", + popover: "oklch(0.2 0.04 266)", + "popover-foreground": "oklch(0.98 0.01 248)", + primary: "oklch(0.8 0.2 128)", + "primary-foreground": "oklch(0 0 0)", + secondary: "oklch(0.28 0.04 260)", + "secondary-foreground": "oklch(0.98 0.01 248)", + muted: "oklch(0.28 0.04 260)", + "muted-foreground": "oklch(0.71 0.03 257)", + accent: "oklch(0.39 0.09 152)", + "accent-foreground": "oklch(0.8 0.2 128)", + destructive: "oklch(0.44 0.16 27)", + "destructive-foreground": "oklch(1 0 0)", + border: "oklch(0.28 0.04 260)", + input: "oklch(0.28 0.04 260)", + ring: "oklch(0.8 0.2 128)", + }, + }, + }, + { + sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", + themeId: "cmlhfpjhw000004l4f4ax3m7z", + }, + ); +} + describe("setTabFromRoute", () => { beforeEach(() => { vi.stubGlobal("localStorage", createStorageMock()); @@ -242,6 +304,18 @@ describe("setTabFromRoute", () => { expect(host.themeResolved).toBe("dash-light"); }); + it("falls back to claw when custom is selected without a stored custom theme", () => { + const host = createHost("chat"); + host.settings.theme = "custom"; + host.settings.themeMode = "dark"; + + syncThemeWithSettings(host); + + expect(host.theme).toBe("claw"); + expect(host.settings.theme).toBe("claw"); + expect(host.themeResolved).toBe("dark"); + }); + it("applies named system themes on OS preference changes", () => { const listeners: Array<(event: MediaQueryListEvent) => void> = []; const matchMedia = vi.fn().mockReturnValue({ @@ -283,6 +357,22 @@ describe("setTabFromRoute", () => { expect(root.dataset.theme).toBe("dash-light"); expect(root.style.colorScheme).toBe("light"); }); + + it("applies imported custom light themes as light-mode tokens", () => { + const root = { + dataset: {} as DOMStringMap, + style: { colorScheme: "" } as CSSStyleDeclaration & { colorScheme: string }, + }; + vi.stubGlobal("document", { documentElement: root } as Document); + + const host = createHost("chat"); + host.settings.customTheme = createCustomThemeFixture(); + applyResolvedTheme(host, "custom-light"); + + expect(host.themeResolved).toBe("custom-light"); + expect(root.dataset.theme).toBe("custom-light"); + expect(root.style.colorScheme).toBe("light"); + }); }); describe("applySettingsFromUrl", () => { diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 3bb4a7b7f3e..c1e990ef01b 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -44,6 +44,7 @@ import { loadPresence, type PresenceState } from "./controllers/presence.ts"; import { loadSessions, type SessionsState } from "./controllers/sessions.ts"; import { loadSkills, type SkillsState } from "./controllers/skills.ts"; import { loadUsage, type UsageState } from "./controllers/usage.ts"; +import { syncCustomThemeStyleTag } from "./custom-theme.ts"; import { isMonitoredAuthProvider } from "./model-auth-helpers.ts"; import { inferBasePathFromPathname, @@ -141,6 +142,7 @@ export function applySettings(host: SettingsHost, next: UiSettings) { }; host.settings = normalized; saveSettings(normalized); + syncCustomThemeStyleTag(normalized.customTheme); if (next.theme !== host.theme || next.themeMode !== host.themeMode) { host.theme = next.theme; host.themeMode = next.themeMode; @@ -413,8 +415,17 @@ export function inferBasePath() { } export function syncThemeWithSettings(host: SettingsHost) { - host.theme = host.settings.theme ?? "claw"; + syncCustomThemeStyleTag(host.settings.customTheme); + const normalizedTheme = + host.settings.theme === "custom" && !host.settings.customTheme + ? "claw" + : (host.settings.theme ?? "claw"); + host.theme = normalizedTheme; host.themeMode = host.settings.themeMode ?? "system"; + if (normalizedTheme !== host.settings.theme) { + host.settings = { ...host.settings, theme: normalizedTheme }; + saveSettings(host.settings); + } applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode)); applyBorderRadius(host.settings.borderRadius ?? 50); syncSystemThemeListener(host); diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 73cf7aaf370..0932e10160d 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -60,6 +60,9 @@ export type AppViewState = { themeMode: ThemeMode; themeResolved: ResolvedTheme; themeOrder: ThemeName[]; + customThemeImportUrl: string; + customThemeImportBusy: boolean; + customThemeImportMessage: { kind: "success" | "error"; text: string } | null; hello: GatewayHelloOk | null; lastError: string | null; lastErrorCode: string | null; @@ -381,6 +384,9 @@ export type AppViewState = { setTab: (tab: Tab) => void; setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void; setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void; + setCustomThemeImportUrl: (next: string) => void; + importCustomTheme: () => Promise; + clearCustomTheme: () => void; setBorderRadius: (value: number) => void; applySettings: (next: UiSettings) => void; applyLocalUserIdentity?: (next: { name?: string | null; avatar?: string | null }) => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index e8ffedd38bd..fec19745b85 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -77,6 +77,7 @@ import type { ClawHubSkillDetail, SkillMessage, } from "./controllers/skills.ts"; +import { importCustomThemeFromUrl } from "./custom-theme.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import type { SidebarContent } from "./sidebar-content.ts"; @@ -155,6 +156,9 @@ export class OpenClawApp extends LitElement { @state() themeMode: ThemeMode = this.settings.themeMode ?? "system"; @state() themeResolved: ResolvedTheme = "dark"; @state() themeOrder: ThemeName[] = this.buildThemeOrder(this.theme); + @state() customThemeImportUrl = ""; + @state() customThemeImportBusy = false; + @state() customThemeImportMessage: { kind: "success" | "error"; text: string } | null = null; @state() hello: GatewayHelloOk | null = null; @state() lastError: string | null = null; @state() lastErrorCode: string | null = null; @@ -672,6 +676,54 @@ export class OpenClawApp extends LitElement { ); } + setCustomThemeImportUrl(next: string) { + this.customThemeImportUrl = next; + if (this.customThemeImportMessage?.kind === "error") { + this.customThemeImportMessage = null; + } + } + + async importCustomTheme() { + if (this.customThemeImportBusy) { + return; + } + this.customThemeImportBusy = true; + this.customThemeImportMessage = null; + try { + const customTheme = await importCustomThemeFromUrl(this.customThemeImportUrl); + applySettingsInternal(this as unknown as Parameters[0], { + ...this.settings, + customTheme, + }); + this.customThemeImportUrl = ""; + this.customThemeImportMessage = { + kind: "success", + text: `Imported ${customTheme.label}.`, + }; + } catch (error) { + this.customThemeImportMessage = { + kind: "error", + text: error instanceof Error ? error.message : "Failed to import tweakcn theme.", + }; + } finally { + this.customThemeImportBusy = false; + } + } + + clearCustomTheme() { + const nextTheme = this.theme === "custom" ? "claw" : this.theme; + applySettingsInternal(this as unknown as Parameters[0], { + ...this.settings, + theme: nextTheme, + customTheme: undefined, + }); + this.themeOrder = this.buildThemeOrder(nextTheme); + this.customThemeImportMessage = { + kind: "success", + text: "Cleared custom theme.", + }; + } + setBorderRadius(value: number) { applySettingsInternal(this as unknown as Parameters[0], { ...this.settings, diff --git a/ui/src/ui/custom-theme.test.ts b/ui/src/ui/custom-theme.test.ts new file mode 100644 index 00000000000..1ac41835962 --- /dev/null +++ b/ui/src/ui/custom-theme.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildCustomThemeStyles, + normalizeImportedCustomTheme, + normalizeTweakcnThemeUrl, + parseImportedCustomTheme, + syncCustomThemeStyleTag, +} from "./custom-theme.ts"; + +function createTweakcnPayload() { + return { + name: "Light Green", + cssVars: { + theme: { + "font-sans": "Inter, system-ui, sans-serif", + "font-mono": "JetBrains Mono, monospace", + }, + light: { + background: "oklch(0.98 0.01 120)", + foreground: "oklch(0.2 0.03 265)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.2 0.03 265)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.2 0.03 265)", + primary: "oklch(0.8 0.2 128)", + "primary-foreground": "oklch(0 0 0)", + secondary: "oklch(0.35 0.03 257)", + "secondary-foreground": "oklch(0.98 0.01 248)", + muted: "oklch(0.96 0.01 248)", + "muted-foreground": "oklch(0.55 0.04 257)", + accent: "oklch(0.98 0.02 155)", + "accent-foreground": "oklch(0.45 0.1 151)", + destructive: "oklch(0.64 0.2 25)", + "destructive-foreground": "oklch(1 0 0)", + border: "oklch(0.92 0.01 255)", + input: "oklch(0.92 0.01 255)", + ring: "oklch(0.8 0.2 128)", + }, + dark: { + background: "oklch(0.12 0.04 265)", + foreground: "oklch(0.98 0.01 248)", + card: "oklch(0.2 0.04 266)", + "card-foreground": "oklch(0.98 0.01 248)", + popover: "oklch(0.2 0.04 266)", + "popover-foreground": "oklch(0.98 0.01 248)", + primary: "oklch(0.8 0.2 128)", + "primary-foreground": "oklch(0 0 0)", + secondary: "oklch(0.28 0.04 260)", + "secondary-foreground": "oklch(0.98 0.01 248)", + muted: "oklch(0.28 0.04 260)", + "muted-foreground": "oklch(0.71 0.03 257)", + accent: "oklch(0.39 0.09 152)", + "accent-foreground": "oklch(0.8 0.2 128)", + destructive: "oklch(0.44 0.16 27)", + "destructive-foreground": "oklch(1 0 0)", + border: "oklch(0.28 0.04 260)", + input: "oklch(0.28 0.04 260)", + ring: "oklch(0.8 0.2 128)", + }, + }, + }; +} + +function createImportedTheme() { + return normalizeImportedCustomTheme(createTweakcnPayload(), { + sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", + themeId: "cmlhfpjhw000004l4f4ax3m7z", + }); +} + +describe("custom theme import helpers", () => { + it("normalizes tweakcn share links and raw registry links", () => { + expect( + normalizeTweakcnThemeUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z"), + ).toEqual({ + sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", + fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z", + themeId: "cmlhfpjhw000004l4f4ax3m7z", + }); + expect( + normalizeTweakcnThemeUrl("https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z"), + ).toEqual({ + sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", + fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z", + themeId: "cmlhfpjhw000004l4f4ax3m7z", + }); + }); + + it("maps a tweakcn payload into a normalized imported theme record", () => { + const imported = createImportedTheme(); + + expect(imported.label).toBe("Light Green"); + expect(imported.sourceUrl).toBe("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z"); + expect(imported.light.bg).toBe("oklch(0.98 0.01 120)"); + expect(imported.dark.bg).toBe("oklch(0.12 0.04 265)"); + expect(imported.light["font-body"]).toBe("Inter, system-ui, sans-serif"); + expect(imported.dark["accent-hover"]).toContain("color-mix"); + }); + + it("builds stable CSS blocks for custom dark and light themes", () => { + const css = buildCustomThemeStyles(createImportedTheme()); + + expect(css).toContain(':root[data-theme="custom"]'); + expect(css).toContain(':root[data-theme="custom-light"]'); + expect(css).toContain("--bg: oklch(0.12 0.04 265);"); + expect(css).toContain("--bg: oklch(0.98 0.01 120);"); + }); + + it("parses stored imported themes and rejects malformed records", () => { + const imported = createImportedTheme(); + + expect(parseImportedCustomTheme(imported)?.themeId).toBe("cmlhfpjhw000004l4f4ax3m7z"); + expect(parseImportedCustomTheme({ ...imported, light: {} })).toBeNull(); + }); + + it("syncs the managed custom theme style tag in the document head", () => { + const appendChild = vi.fn(); + const remove = vi.fn(); + const style = { id: "", textContent: "", remove } as unknown as HTMLStyleElement; + const documentStub = { + head: { appendChild }, + createElement: vi.fn(() => style), + getElementById: vi.fn(() => null), + } as unknown as Document; + vi.stubGlobal("document", documentStub); + + syncCustomThemeStyleTag(createImportedTheme()); + + expect(appendChild).toHaveBeenCalledWith(style); + expect(style.textContent).toContain(':root[data-theme="custom"]'); + + vi.stubGlobal("document", { + head: documentStub.head, + createElement: documentStub.createElement, + getElementById: vi.fn(() => style), + } as unknown as Document); + + syncCustomThemeStyleTag(null); + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/ui/custom-theme.ts b/ui/src/ui/custom-theme.ts new file mode 100644 index 00000000000..afd21d6f7c8 --- /dev/null +++ b/ui/src/ui/custom-theme.ts @@ -0,0 +1,410 @@ +import { z } from "zod"; +import { normalizeOptionalString } from "./string-coerce.ts"; + +const TWEAKCN_HOSTS = new Set(["tweakcn.com", "www.tweakcn.com"]); +const THEME_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; +const CUSTOM_THEME_STYLE_ID = "openclaw-custom-theme"; +const DEFAULT_FONT_BODY = + '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; +const DEFAULT_MONO = + '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace'; + +const MODE_TOKEN_ORDER = [ + "bg", + "bg-accent", + "bg-elevated", + "bg-hover", + "bg-muted", + "bg-content", + "card", + "card-foreground", + "card-highlight", + "popover", + "popover-foreground", + "panel", + "panel-strong", + "panel-hover", + "chrome", + "chrome-strong", + "text", + "text-strong", + "chat-text", + "muted", + "muted-strong", + "muted-foreground", + "border", + "border-strong", + "border-hover", + "input", + "ring", + "accent", + "accent-hover", + "accent-muted", + "accent-subtle", + "accent-foreground", + "accent-glow", + "primary", + "primary-foreground", + "secondary", + "secondary-foreground", + "accent-2", + "accent-2-muted", + "accent-2-subtle", + "destructive", + "destructive-foreground", + "danger", + "danger-muted", + "danger-subtle", + "focus", + "focus-ring", + "focus-glow", + "font-body", + "font-display", + "mono", + "grid-line", +] as const; + +type ModeTokenName = (typeof MODE_TOKEN_ORDER)[number]; +type ThemeTokenMap = Record; + +export type ImportedCustomTheme = { + sourceUrl: string; + themeId: string; + label: string; + importedAt: string; + light: ThemeTokenMap; + dark: ThemeTokenMap; +}; + +const tweakcnCssVarMapSchema = z.record(z.string(), z.string()); + +const tweakcnThemeSchema = z.object({ + name: z.string().optional(), + cssVars: z.object({ + theme: tweakcnCssVarMapSchema.optional(), + light: tweakcnCssVarMapSchema, + dark: tweakcnCssVarMapSchema, + }), +}); + +const importedCustomThemeSchema = z.object({ + sourceUrl: z.string(), + themeId: z.string(), + label: z.string(), + importedAt: z.string(), + light: z.record(z.string(), z.string()), + dark: z.record(z.string(), z.string()), +}); + +type TweakcnThemePayload = z.infer; + +type TweakcnThemeResolution = { + sourceUrl: string; + fetchUrl: string; + themeId: string; +}; + +function requireThemeId(value: string) { + if (!THEME_ID_PATTERN.test(value)) { + throw new Error("Unsupported tweakcn link. Expected a theme share URL."); + } +} + +function normalizeThemeIdFromPath(pathname: string): string { + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 2 && segments[0] === "themes") { + requireThemeId(segments[1]); + return segments[1]; + } + if (segments.length === 3 && segments[0] === "r" && segments[1] === "themes") { + requireThemeId(segments[2]); + return segments[2]; + } + throw new Error("Unsupported tweakcn link. Expected a theme share URL."); +} + +function requireSafeCssValue(value: unknown, label: string) { + const normalized = normalizeOptionalString(value); + if (!normalized) { + throw new Error(`Unsupported tweakcn token: ${label}`); + } + for (const char of normalized) { + const code = char.charCodeAt(0); + if ( + code < 0x20 || + code === 0x7f || + char === "{" || + char === "}" || + char === ";" || + char === "<" || + char === ">" || + char === "`" + ) { + throw new Error(`Unsupported tweakcn token: ${label}`); + } + } + return normalized; +} + +function makeTokenMap(entries: Array<[ModeTokenName, string]>): ThemeTokenMap { + return Object.fromEntries(entries) as ThemeTokenMap; +} + +function normalizeStoredTokenMap(value: Record | undefined): ThemeTokenMap | null { + if (!value || typeof value !== "object") { + return null; + } + const entries: Array<[ModeTokenName, string]> = []; + for (const key of MODE_TOKEN_ORDER) { + const normalized = requireSafeCssValue(value[key], key); + entries.push([key, normalized]); + } + return makeTokenMap(entries); +} + +function resolveModeVar( + theme: Record, + shared: Record | undefined, + key: string, + fallback?: string, +) { + const themeValue = normalizeOptionalString(theme[key]); + if (themeValue) { + return requireSafeCssValue(themeValue, key); + } + const sharedValue = normalizeOptionalString(shared?.[key]); + if (sharedValue) { + return requireSafeCssValue(sharedValue, key); + } + if (fallback != null) { + return requireSafeCssValue(fallback, key); + } + throw new Error(`tweakcn theme is missing required token: ${key}`); +} + +function normalizeModeTokenMap( + mode: "light" | "dark", + theme: Record, + shared: Record | undefined, +): ThemeTokenMap { + const isLight = mode === "light"; + const contrastTarget = isLight ? "black" : "white"; + const background = resolveModeVar(theme, shared, "background"); + const foreground = resolveModeVar(theme, shared, "foreground"); + const card = resolveModeVar(theme, shared, "card"); + const cardForeground = resolveModeVar(theme, shared, "card-foreground"); + const popover = resolveModeVar(theme, shared, "popover"); + const popoverForeground = resolveModeVar(theme, shared, "popover-foreground"); + const primary = resolveModeVar(theme, shared, "primary"); + const primaryForeground = resolveModeVar(theme, shared, "primary-foreground"); + const secondary = resolveModeVar(theme, shared, "secondary"); + const secondaryForeground = resolveModeVar(theme, shared, "secondary-foreground"); + const muted = resolveModeVar(theme, shared, "muted"); + const mutedForeground = resolveModeVar(theme, shared, "muted-foreground"); + const accent = resolveModeVar(theme, shared, "accent"); + const accentForeground = resolveModeVar(theme, shared, "accent-foreground"); + const destructive = resolveModeVar(theme, shared, "destructive"); + const destructiveForeground = resolveModeVar(theme, shared, "destructive-foreground"); + const border = resolveModeVar(theme, shared, "border"); + const input = resolveModeVar(theme, shared, "input"); + const ring = resolveModeVar(theme, shared, "ring"); + const fontBody = resolveModeVar(theme, shared, "font-sans", DEFAULT_FONT_BODY); + const mono = resolveModeVar(theme, shared, "font-mono", DEFAULT_MONO); + + return makeTokenMap([ + ["bg", background], + ["bg-accent", "color-mix(in srgb, var(--bg) 88%, var(--card) 12%)"], + ["bg-elevated", card], + ["bg-hover", "color-mix(in srgb, var(--muted) 68%, var(--bg) 32%)"], + ["bg-muted", muted], + ["bg-content", "color-mix(in srgb, var(--bg) 92%, var(--card) 8%)"], + ["card", card], + ["card-foreground", cardForeground], + ["card-highlight", `color-mix(in srgb, var(--text) ${isLight ? "3" : "5"}%, transparent)`], + ["popover", popover], + ["popover-foreground", popoverForeground], + ["panel", background], + ["panel-strong", card], + ["panel-hover", "color-mix(in srgb, var(--card) 76%, var(--muted) 24%)"], + ["chrome", "color-mix(in srgb, var(--bg) 96%, transparent)"], + ["chrome-strong", "color-mix(in srgb, var(--bg) 98%, transparent)"], + ["text", foreground], + ["text-strong", foreground], + ["chat-text", foreground], + ["muted", mutedForeground], + ["muted-strong", "color-mix(in srgb, var(--muted) 84%, var(--text) 16%)"], + ["muted-foreground", mutedForeground], + ["border", border], + ["border-strong", "color-mix(in srgb, var(--border) 72%, var(--text) 28%)"], + ["border-hover", "color-mix(in srgb, var(--border) 55%, var(--text) 45%)"], + ["input", input], + ["ring", ring], + ["accent", accent], + ["accent-hover", `color-mix(in srgb, var(--accent) 82%, ${contrastTarget} 18%)`], + ["accent-muted", accent], + ["accent-subtle", `color-mix(in srgb, var(--accent) ${isLight ? "10" : "16"}%, transparent)`], + ["accent-foreground", accentForeground], + ["accent-glow", `color-mix(in srgb, var(--accent) ${isLight ? "18" : "30"}%, transparent)`], + ["primary", primary], + ["primary-foreground", primaryForeground], + ["secondary", secondary], + ["secondary-foreground", secondaryForeground], + ["accent-2", primary], + ["accent-2-muted", "color-mix(in srgb, var(--accent-2) 72%, transparent)"], + [ + "accent-2-subtle", + `color-mix(in srgb, var(--accent-2) ${isLight ? "8" : "12"}%, transparent)`, + ], + ["destructive", destructive], + ["destructive-foreground", destructiveForeground], + ["danger", destructive], + ["danger-muted", "color-mix(in srgb, var(--danger) 75%, transparent)"], + ["danger-subtle", `color-mix(in srgb, var(--danger) ${isLight ? "8" : "12"}%, transparent)`], + ["focus", `color-mix(in srgb, var(--ring) ${isLight ? "14" : "22"}%, transparent)`], + [ + "focus-ring", + `0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) ${isLight ? "70" : "80"}%, transparent)`, + ], + ["focus-glow", "0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)"], + ["font-body", fontBody], + ["font-display", fontBody], + ["mono", mono], + ["grid-line", `color-mix(in srgb, var(--text) ${isLight ? "4" : "3"}%, transparent)`], + ]); +} + +function describeThemeLabel(value: string | undefined) { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return "Custom"; + } + return normalized.slice(0, 80); +} + +export function normalizeTweakcnThemeUrl(input: string): TweakcnThemeResolution { + const normalized = normalizeOptionalString(input); + if (!normalized) { + throw new Error("Paste a tweakcn theme link to import."); + } + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + throw new Error("Paste a full tweakcn URL."); + } + if (!TWEAKCN_HOSTS.has(parsed.hostname)) { + throw new Error("Only tweakcn.com theme links are supported."); + } + const themeId = normalizeThemeIdFromPath(parsed.pathname); + return { + themeId, + sourceUrl: `https://tweakcn.com/themes/${themeId}`, + fetchUrl: `https://tweakcn.com/r/themes/${themeId}`, + }; +} + +export function parseImportedCustomTheme(value: unknown): ImportedCustomTheme | null { + const parsed = importedCustomThemeSchema.safeParse(value); + if (!parsed.success) { + return null; + } + try { + requireThemeId(parsed.data.themeId); + const light = normalizeStoredTokenMap(parsed.data.light); + const dark = normalizeStoredTokenMap(parsed.data.dark); + if (!light || !dark) { + return null; + } + return { + sourceUrl: parsed.data.sourceUrl, + themeId: parsed.data.themeId, + label: describeThemeLabel(parsed.data.label), + importedAt: parsed.data.importedAt, + light, + dark, + }; + } catch { + return null; + } +} + +export function normalizeImportedCustomTheme( + payload: unknown, + resolution: Pick, +): ImportedCustomTheme { + const parsed = tweakcnThemeSchema.safeParse(payload); + if (!parsed.success) { + throw new Error("tweakcn returned an invalid theme payload."); + } + const data: TweakcnThemePayload = parsed.data; + const shared = data.cssVars.theme; + return { + sourceUrl: resolution.sourceUrl, + themeId: resolution.themeId, + label: describeThemeLabel(data.name), + importedAt: new Date().toISOString(), + light: normalizeModeTokenMap("light", data.cssVars.light, shared), + dark: normalizeModeTokenMap("dark", data.cssVars.dark, shared), + }; +} + +export async function importCustomThemeFromUrl( + input: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const resolution = normalizeTweakcnThemeUrl(input); + const response = await fetchImpl(resolution.fetchUrl, { + headers: { accept: "application/json" }, + }); + if (!response.ok) { + throw new Error(`tweakcn import failed (${response.status}).`); + } + const payload = await response.json(); + return normalizeImportedCustomTheme(payload, resolution); +} + +export function buildCustomThemeStyles(theme: ImportedCustomTheme) { + const light = normalizeStoredTokenMap(theme.light); + const dark = normalizeStoredTokenMap(theme.dark); + if (!light || !dark) { + return ""; + } + const renderDeclarations = (modeTokens: ThemeTokenMap) => + MODE_TOKEN_ORDER.map((key) => ` --${key}: ${modeTokens[key]};`).join("\n"); + return [ + `:root[data-theme="custom"] {`, + renderDeclarations(dark), + `}`, + `:root[data-theme="custom-light"] {`, + renderDeclarations(light), + `}`, + ].join("\n"); +} + +export function syncCustomThemeStyleTag(theme: ImportedCustomTheme | null | undefined) { + if (typeof document === "undefined") { + return; + } + let style = document.getElementById(CUSTOM_THEME_STYLE_ID) as HTMLStyleElement | null; + if (!theme) { + style?.remove(); + return; + } + let cssText = ""; + try { + cssText = buildCustomThemeStyles(theme); + } catch { + style?.remove(); + return; + } + if (!cssText) { + style?.remove(); + return; + } + if (!style) { + style = document.createElement("style"); + style.id = CUSTOM_THEME_STYLE_ID; + document.head.appendChild(style); + } + style.textContent = cssText; +} diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 4fc48b85f1f..6544eafc0cf 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -7,6 +7,7 @@ import { saveLocalUserIdentity, saveSettings, } from "./storage.ts"; +import { normalizeImportedCustomTheme } from "./custom-theme.ts"; function setTestLocation(params: { protocol: string; host: string; pathname: string }) { vi.stubGlobal("location", { @@ -43,6 +44,66 @@ function expectedGatewayUrl(basePath: string): string { return `${proto}://${location.host}${basePath}`; } +function createCustomThemeFixture() { + return normalizeImportedCustomTheme( + { + name: "Light Green", + cssVars: { + theme: { + "font-sans": "Inter, system-ui, sans-serif", + "font-mono": "JetBrains Mono, monospace", + }, + light: { + background: "oklch(0.98 0.01 120)", + foreground: "oklch(0.2 0.03 265)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.2 0.03 265)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.2 0.03 265)", + primary: "oklch(0.8 0.2 128)", + "primary-foreground": "oklch(0 0 0)", + secondary: "oklch(0.35 0.03 257)", + "secondary-foreground": "oklch(0.98 0.01 248)", + muted: "oklch(0.96 0.01 248)", + "muted-foreground": "oklch(0.55 0.04 257)", + accent: "oklch(0.98 0.02 155)", + "accent-foreground": "oklch(0.45 0.1 151)", + destructive: "oklch(0.64 0.2 25)", + "destructive-foreground": "oklch(1 0 0)", + border: "oklch(0.92 0.01 255)", + input: "oklch(0.92 0.01 255)", + ring: "oklch(0.8 0.2 128)", + }, + dark: { + background: "oklch(0.12 0.04 265)", + foreground: "oklch(0.98 0.01 248)", + card: "oklch(0.2 0.04 266)", + "card-foreground": "oklch(0.98 0.01 248)", + popover: "oklch(0.2 0.04 266)", + "popover-foreground": "oklch(0.98 0.01 248)", + primary: "oklch(0.8 0.2 128)", + "primary-foreground": "oklch(0 0 0)", + secondary: "oklch(0.28 0.04 260)", + "secondary-foreground": "oklch(0.98 0.01 248)", + muted: "oklch(0.28 0.04 260)", + "muted-foreground": "oklch(0.71 0.03 257)", + accent: "oklch(0.39 0.09 152)", + "accent-foreground": "oklch(0.8 0.2 128)", + destructive: "oklch(0.44 0.16 27)", + "destructive-foreground": "oklch(1 0 0)", + border: "oklch(0.28 0.04 260)", + input: "oklch(0.28 0.04 260)", + ring: "oklch(0.8 0.2 128)", + }, + }, + }, + { + sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", + themeId: "cmlhfpjhw000004l4f4ax3m7z", + }, + ); +} + describe("loadSettings default gateway URL derivation", () => { beforeEach(() => { vi.stubGlobal("localStorage", createStorageMock()); @@ -357,6 +418,87 @@ describe("loadSettings default gateway URL derivation", () => { }); }); + it("persists the browser-local custom theme payload when present", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const gwUrl = expectedGatewayUrl(""); + const customTheme = createCustomThemeFixture(); + saveSettings({ + gatewayUrl: gwUrl, + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "custom", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + borderRadius: 50, + customTheme, + }); + + expect(loadSettings()).toMatchObject({ + theme: "custom", + customTheme: { + label: "Light Green", + themeId: "cmlhfpjhw000004l4f4ax3m7z", + }, + }); + }); + + it("falls back to claw when persisted custom theme data is invalid", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const gwUrl = expectedGatewayUrl(""); + localStorage.setItem( + `openclaw.control.settings.v1:${gwUrl}`, + JSON.stringify({ + gatewayUrl: gwUrl, + theme: "custom", + themeMode: "dark", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + borderRadius: 50, + customTheme: { + sourceUrl: "https://tweakcn.com/themes/broken", + themeId: "broken", + label: "Broken", + importedAt: "2026-04-22T00:00:00.000Z", + light: {}, + dark: {}, + }, + sessionsByGateway: { + [gwUrl]: { + sessionKey: "main", + lastActiveSessionKey: "main", + }, + }, + }), + ); + + expect(loadSettings()).toMatchObject({ + theme: "claw", + themeMode: "dark", + }); + }); + it("scopes persisted session selection per gateway", async () => { setTestLocation({ protocol: "https:", diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 994cc388932..bf862528b52 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -23,6 +23,7 @@ type PersistedUiSettings = Omit; // Which nav groups are collapsed borderRadius: number; // Corner roundness (0–100, default 50) + customTheme?: ImportedCustomTheme; locale?: string; }; @@ -219,6 +221,7 @@ export function loadSettings(): UiSettings { const parsedGatewayUrl = normalizeOptionalString(parsed.gatewayUrl) ?? defaults.gatewayUrl; const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl; const scopedSessionSelection = resolveScopedSessionSelection(gatewayUrl, parsed, defaults); + const customTheme = parseImportedCustomTheme((parsed as { customTheme?: unknown }).customTheme); const { theme, mode } = parseThemeSelection( (parsed as { theme?: unknown }).theme, (parsed as { themeMode?: unknown }).themeMode, @@ -229,7 +232,7 @@ export function loadSettings(): UiSettings { token: loadSessionToken(gatewayUrl), sessionKey: scopedSessionSelection.sessionKey, lastActiveSessionKey: scopedSessionSelection.lastActiveSessionKey, - theme, + theme: theme === "custom" && !customTheme ? "claw" : theme, themeMode: mode, chatFocusMode: typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode, @@ -263,6 +266,7 @@ export function loadSettings(): UiSettings { parsed.borderRadius <= 100 ? snapBorderRadius(parsed.borderRadius) : defaults.borderRadius, + customTheme: customTheme ?? undefined, locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined, }; if ("token" in parsed) { @@ -351,6 +355,7 @@ function persistSettings(next: UiSettings) { navWidth: next.navWidth, navGroupsCollapsed: next.navGroupsCollapsed, borderRadius: next.borderRadius, + ...(next.customTheme ? { customTheme: next.customTheme } : {}), sessionsByGateway, ...(next.locale ? { locale: next.locale } : {}), }; diff --git a/ui/src/ui/theme.ts b/ui/src/ui/theme.ts index deb8d6c1f3e..4e15afec353 100644 --- a/ui/src/ui/theme.ts +++ b/ui/src/ui/theme.ts @@ -1,4 +1,4 @@ -export type ThemeName = "claw" | "knot" | "dash"; +export type ThemeName = "claw" | "knot" | "dash" | "custom"; export type ThemeMode = "system" | "light" | "dark"; export type ResolvedTheme = | "dark" @@ -6,9 +6,11 @@ export type ResolvedTheme = | "openknot" | "openknot-light" | "dash" - | "dash-light"; + | "dash-light" + | "custom" + | "custom-light"; -export const VALID_THEME_NAMES = new Set(["claw", "knot", "dash"]); +export const VALID_THEME_NAMES = new Set(["claw", "knot", "dash", "custom"]); export const VALID_THEME_MODES = new Set(["system", "light", "dark"]); type ThemeSelection = { theme: ThemeName; mode: ThemeMode }; @@ -70,5 +72,8 @@ export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme { if (theme === "knot") { return resolvedMode === "light" ? "openknot-light" : "openknot"; } - return resolvedMode === "light" ? "dash-light" : "dash"; + if (theme === "dash") { + return resolvedMode === "light" ? "dash-light" : "dash"; + } + return resolvedMode === "light" ? "custom-light" : "custom"; } diff --git a/ui/src/ui/views/config-quick.ts b/ui/src/ui/views/config-quick.ts index 149bda441d6..9cd215e4f15 100644 --- a/ui/src/ui/views/config-quick.ts +++ b/ui/src/ui/views/config-quick.ts @@ -77,6 +77,8 @@ export type QuickSettingsProps = { // Appearance theme: ThemeName; themeMode: ThemeMode; + hasCustomTheme: boolean; + customThemeLabel?: string | null; borderRadius: number; setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void; setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void; @@ -103,7 +105,7 @@ export type QuickSettingsProps = { // ── Theme options ── type ThemeOption = { id: ThemeName; label: string }; -const THEME_OPTIONS: ThemeOption[] = [ +const BUILTIN_THEME_OPTIONS: ThemeOption[] = [ { id: "claw", label: "Claw" }, { id: "knot", label: "Knot" }, { id: "dash", label: "Dash" }, @@ -378,6 +380,9 @@ function renderSecurityCard(props: QuickSettingsProps) { } function renderAppearanceCard(props: QuickSettingsProps) { + const themeOptions: ThemeOption[] = props.hasCustomTheme + ? [...BUILTIN_THEME_OPTIONS, { id: "custom", label: props.customThemeLabel ?? "Custom" }] + : BUILTIN_THEME_OPTIONS; return html`
${renderCardHeader(icons.spark, "Appearance")} @@ -385,7 +390,7 @@ function renderAppearanceCard(props: QuickSettingsProps) {
Theme
- ${THEME_OPTIONS.map( + ${themeOptions.map( (opt) => html`
+
+
+
Import from tweakcn
+

+ Paste a tweakcn share link. The import stays in this browser only and replaces the + current custom slot. +

+
+ +
+ + ${props.hasCustomTheme + ? html` + + ` + : nothing} +
+ ${props.hasCustomTheme + ? html` +
+ Loaded + ${props.customThemeLabel ?? "Custom"} · + ${props.customThemeSourceUrl ?? "tweakcn"} +
+ ` + : nothing} + ${props.customThemeImportMessage + ? html` +
+ ${props.customThemeImportMessage.text} +
+ ` + : nothing} +
@@ -941,37 +1031,39 @@ export function renderConfig(props: ConfigProps) {
${!rawAvailable ? html` - Raw mode disabled (snapshot cannot safely round-trip raw text). ` : nothing} - ${props.onOpenFile - ? html` - - ` - : nothing} - - - - - +
+ ${props.onOpenFile + ? html` + + ` + : nothing} + + + + + +