From e3ad82d86df0ecebbddf17d98f22921a60d149f6 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:24:14 -0500 Subject: [PATCH] fix(control-ui): polish tweakcn theme imports Summary: - Improve Control UI tweakcn theme import parsing and labeling. - Apply imported theme names consistently across appearance controls. - Document tweakcn share link and slug import flows. Verification: - pnpm test ui/src/ui/custom-theme.test.ts ui/src/ui/views/config.browser.test.ts ui/src/ui/views/config-quick.test.ts ui/src/ui/app-settings.test.ts ui/src/ui/storage.node.test.ts - pnpm check:changed - pnpm --dir ui build --- docs/web/control-ui.md | 6 ++++ ui/src/styles/config.css | 25 ++++++++++++++ ui/src/ui/app.ts | 12 +++++++ ui/src/ui/custom-theme.test.ts | 39 +++++++++++++++++++++ ui/src/ui/custom-theme.ts | 48 ++++++++++++++++++++++---- ui/src/ui/views/config-quick.test.ts | 9 ++--- ui/src/ui/views/config-quick.ts | 8 ++++- ui/src/ui/views/config.browser.test.ts | 21 ++++++----- ui/src/ui/views/config.ts | 44 +++++++++++++++-------- 9 files changed, 177 insertions(+), 35 deletions(-) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index e58899558bc..439c7ceb7be 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -82,6 +82,12 @@ The Control UI can localize itself on first load based on your browser locale. T - The selected locale is saved in browser storage and reused on future visits. - Missing translation keys fall back to English. +## Appearance themes + +The Appearance panel keeps the built-in Claw, Knot, and Dash themes, plus one browser-local tweakcn import slot. To import a theme, open [tweakcn themes](https://tweakcn.com/themes), choose or create a theme, click **Share**, and paste the copied theme link into Appearance. The importer also accepts `https://tweakcn.com/r/themes/` registry URLs, editor URLs like `https://tweakcn.com/editor/theme?theme=amethyst-haze`, relative `/themes/` paths, raw theme IDs, and default theme names such as `amethyst-haze`. + +Imported themes are stored only in the current browser profile. They are not written to gateway config and do not sync across devices. Replacing the imported theme updates the one local slot; clearing it switches the active theme back to Claw if the imported theme was selected. + ## What it can do (today) diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index ff4e4e534fe..1ef5d4bee56 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -616,6 +616,31 @@ color: var(--muted); } +.settings-theme-import__external { + display: inline-flex; + align-items: center; + gap: 6px; + width: max-content; + max-width: 100%; + color: var(--accent); + font-size: 12px; + font-weight: 600; + text-decoration: none; +} + +.settings-theme-import__external:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +.settings-theme-import__external svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + flex: 0 0 auto; +} + .settings-theme-import__inline-hint { margin: 0; font-size: 12px; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 26b1cafc0f5..9dcb0481af7 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -166,6 +166,7 @@ export class OpenClawApp extends LitElement { @state() customThemeImportMessage: { kind: "success" | "error"; text: string } | null = null; @state() customThemeImportExpanded = false; @state() customThemeImportFocusToken = 0; + private customThemeImportSelectOnSuccess = false; @state() hello: GatewayHelloOk | null = null; @state() lastError: string | null = null; @state() lastErrorCode: string | null = null; @@ -717,6 +718,9 @@ export class OpenClawApp extends LitElement { openCustomThemeImport() { this.customThemeImportExpanded = true; this.customThemeImportFocusToken += 1; + if (!this.settings.customTheme) { + this.customThemeImportSelectOnSuccess = true; + } } async importCustomTheme() { @@ -728,11 +732,18 @@ export class OpenClawApp extends LitElement { this.customThemeImportMessage = null; try { const customTheme = await importCustomThemeFromUrl(this.customThemeImportUrl); + const shouldSelectImportedTheme = + this.theme === "custom" || + !this.settings.customTheme || + this.customThemeImportSelectOnSuccess; applySettingsInternal(this as unknown as Parameters[0], { ...this.settings, + theme: shouldSelectImportedTheme ? "custom" : this.settings.theme, customTheme, }); + this.themeOrder = this.buildThemeOrder(shouldSelectImportedTheme ? "custom" : this.theme); this.customThemeImportUrl = ""; + this.customThemeImportSelectOnSuccess = false; this.customThemeImportMessage = { kind: "success", text: `Imported ${customTheme.label}.`, @@ -750,6 +761,7 @@ export class OpenClawApp extends LitElement { clearCustomTheme() { const nextTheme = this.theme === "custom" ? "claw" : this.theme; this.customThemeImportExpanded = true; + this.customThemeImportSelectOnSuccess = false; applySettingsInternal(this as unknown as Parameters[0], { ...this.settings, theme: nextTheme, diff --git a/ui/src/ui/custom-theme.test.ts b/ui/src/ui/custom-theme.test.ts index 9a181966da4..147955b6ebc 100644 --- a/ui/src/ui/custom-theme.test.ts +++ b/ui/src/ui/custom-theme.test.ts @@ -113,6 +113,45 @@ describe("custom theme import helpers", () => { fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z", themeId: "cmlhfpjhw000004l4f4ax3m7z", }); + expect(normalizeTweakcnThemeUrl("/r/themes/cmlhfpjhw000004l4f4ax3m7z")).toEqual({ + sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", + fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z", + themeId: "cmlhfpjhw000004l4f4ax3m7z", + }); + expect(normalizeTweakcnThemeUrl("cmlhfpjhw000004l4f4ax3m7z")).toEqual({ + sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", + fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z", + themeId: "cmlhfpjhw000004l4f4ax3m7z", + }); + }); + + it("extracts theme ids from copied tweakcn editor URLs and pasted text", () => { + expect( + normalizeTweakcnThemeUrl("https://tweakcn.com/editor/theme?theme=cmlhfpjhw000004l4f4ax3m7z"), + ).toEqual({ + sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", + fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z", + themeId: "cmlhfpjhw000004l4f4ax3m7z", + }); + expect( + normalizeTweakcnThemeUrl("Theme link: 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/editor/theme?theme=amethyst-haze"), + ).toEqual({ + sourceUrl: "https://tweakcn.com/themes/amethyst-haze", + fetchUrl: "https://tweakcn.com/r/themes/amethyst-haze", + themeId: "amethyst-haze", + }); + expect(normalizeTweakcnThemeUrl("amethyst-haze")).toEqual({ + sourceUrl: "https://tweakcn.com/themes/amethyst-haze", + fetchUrl: "https://tweakcn.com/r/themes/amethyst-haze", + themeId: "amethyst-haze", + }); }); it("maps a tweakcn payload into a normalized imported theme record", () => { diff --git a/ui/src/ui/custom-theme.ts b/ui/src/ui/custom-theme.ts index 40f05584fba..f94e7f8fd92 100644 --- a/ui/src/ui/custom-theme.ts +++ b/ui/src/ui/custom-theme.ts @@ -2,7 +2,7 @@ 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 THEME_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/; const CUSTOM_THEME_STYLE_ID = "openclaw-custom-theme"; const MAX_TWEAKCN_THEME_BYTES = 200_000; const MAX_CSS_TOKEN_LENGTH = 240; @@ -165,7 +165,7 @@ function requireThemeId(value: string) { } } -function normalizeThemeIdFromPath(pathname: string): string { +function normalizeThemeIdFromPath(pathname: string): string | null { const segments = pathname.split("/").filter(Boolean); if (segments.length === 2 && segments[0] === "themes") { requireThemeId(segments[1]); @@ -175,6 +175,43 @@ function normalizeThemeIdFromPath(pathname: string): string { requireThemeId(segments[2]); return segments[2]; } + return null; +} + +function normalizePastedThemeInput(input: string): string { + const normalized = normalizeOptionalString(input); + if (!normalized) { + throw new Error("Paste a tweakcn theme link to import."); + } + const inputValue = normalized.replace(/[.,;:]+$/, ""); + if (THEME_ID_PATTERN.test(inputValue)) { + return `https://tweakcn.com/themes/${inputValue}`; + } + if (inputValue.startsWith("/themes/") || inputValue.startsWith("/r/themes/")) { + return `https://tweakcn.com${inputValue}`; + } + if (/^(?:www\.)?tweakcn\.com\//i.test(inputValue)) { + return `https://${inputValue}`; + } + const embeddedUrl = inputValue + .match(/https?:\/\/(?:www\.)?tweakcn\.com\/[^\s<>"')]+/i)?.[0] + ?.replace(/[.,;:]+$/, ""); + return embeddedUrl ?? inputValue; +} + +function normalizeThemeIdFromUrl(parsed: URL): string { + const pathThemeId = normalizeThemeIdFromPath(parsed.pathname); + if (pathThemeId) { + return pathThemeId; + } + const queryThemeId = + parsed.searchParams.get("theme") ?? + parsed.searchParams.get("themeId") ?? + parsed.searchParams.get("id"); + if (queryThemeId) { + requireThemeId(queryThemeId); + return queryThemeId; + } throw new Error("Unsupported tweakcn link. Expected a theme share URL."); } @@ -384,10 +421,7 @@ function describeThemeLabel(value: string | undefined) { } export function normalizeTweakcnThemeUrl(input: string): TweakcnThemeResolution { - const normalized = normalizeOptionalString(input); - if (!normalized) { - throw new Error("Paste a tweakcn theme link to import."); - } + const normalized = normalizePastedThemeInput(input); let parsed: URL; try { parsed = new URL(normalized); @@ -397,7 +431,7 @@ export function normalizeTweakcnThemeUrl(input: string): TweakcnThemeResolution if (!TWEAKCN_HOSTS.has(parsed.hostname)) { throw new Error("Only tweakcn.com theme links are supported."); } - const themeId = normalizeThemeIdFromPath(parsed.pathname); + const themeId = normalizeThemeIdFromUrl(parsed); return { themeId, sourceUrl: `https://tweakcn.com/themes/${themeId}`, diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index 615626ad3f5..9d6a847a9d3 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -285,14 +285,14 @@ describe("renderQuickSettings", () => { } }); - it("always shows the custom theme option in quick settings", () => { + it("shows an import theme option in quick settings before a theme is imported", () => { const container = document.createElement("div"); render(renderQuickSettings(createProps()), container); expect( Array.from(container.querySelectorAll("button")).some( - (button) => button.textContent?.trim() === "Custom", + (button) => button.textContent?.trim() === "Import", ), ).toBe(true); }); @@ -314,7 +314,7 @@ describe("renderQuickSettings", () => { ); const customButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Custom", + (button) => button.textContent?.trim() === "Import", ); customButton?.click(); @@ -332,6 +332,7 @@ describe("renderQuickSettings", () => { createProps({ theme: "claw", hasCustomTheme: true, + customThemeLabel: "Light Green", setTheme, onOpenCustomThemeImport, }), @@ -340,7 +341,7 @@ describe("renderQuickSettings", () => { ); const customButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Custom", + (button) => button.textContent?.trim() === "Light Green", ); customButton?.click(); diff --git a/ui/src/ui/views/config-quick.ts b/ui/src/ui/views/config-quick.ts index bfa4ed941c6..dc35c5d6a63 100644 --- a/ui/src/ui/views/config-quick.ts +++ b/ui/src/ui/views/config-quick.ts @@ -523,7 +523,13 @@ function renderSecurityCard(props: QuickSettingsProps) { } function renderAppearanceCard(props: QuickSettingsProps) { - const themeOptions: ThemeOption[] = [...BUILTIN_THEME_OPTIONS, { id: "custom", label: "Custom" }]; + const importedThemeName = props.hasCustomTheme + ? (props.customThemeLabel ?? "Imported theme") + : "Import"; + const themeOptions: ThemeOption[] = [ + ...BUILTIN_THEME_OPTIONS, + { id: "custom", label: importedThemeName }, + ]; return html`
${renderCardHeader(icons.spark, "Appearance")} diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 5846b9b0314..0dc588927ab 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -874,11 +874,13 @@ describe("config view", () => { }); const customButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Custom", + (btn) => btn.textContent?.trim() === "Import", ); expect(customButton?.disabled).toBe(false); - expect(normalizedText(container)).toContain("Click Custom to import a tweakcn theme"); + expect(normalizedText(container)).toContain( + "Click Import to add one browser-local tweakcn theme", + ); customButton?.click(); @@ -894,11 +896,12 @@ describe("config view", () => { }); const importButton = Array.from(container.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Import custom theme"), + btn.textContent?.includes("Import theme"), ); expect(importButton?.disabled).toBe(true); expect(container.querySelector(".settings-theme-import__input")).not.toBeNull(); + expect(normalizedText(container)).toContain("Share links, editor URLs, registry URLs"); }); it("shows custom theme actions once a tweakcn import exists", () => { @@ -920,17 +923,17 @@ describe("config view", () => { }); const customButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Custom", + (btn) => btn.textContent?.trim() === "Light Green", ); expect(customButton?.disabled).toBe(false); customButton?.click(); expect(setTheme).toHaveBeenCalledWith("custom", expect.any(Object)); const replaceButton = Array.from(container.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Replace custom theme"), + btn.textContent?.includes("Replace Light Green"), ); const clearButton = Array.from(container.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Clear custom theme"), + btn.textContent?.includes("Clear Light Green"), ); replaceButton?.click(); clearButton?.click(); @@ -940,8 +943,10 @@ describe("config view", () => { expect(normalizedText(container)).toContain("Loaded Light Green"); const input = container.querySelector(".settings-theme-import__input") as HTMLInputElement; - input.value = "https://tweakcn.com/themes/custom"; + input.value = "/r/themes/cmlhfpjhw000004l4f4ax3m7z"; input.dispatchEvent(new Event("input")); - expect(onCustomThemeImportUrlChange).toHaveBeenCalledWith("https://tweakcn.com/themes/custom"); + expect(onCustomThemeImportUrlChange).toHaveBeenCalledWith( + "/r/themes/cmlhfpjhw000004l4f4ax3m7z", + ); }); }); diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 42a06afaa86..84fb9bab329 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -788,6 +788,10 @@ const BUILTIN_THEME_OPTIONS: ThemeOption[] = [ { id: "dash", label: "Dash", description: "Chocolate blueprint", icon: icons.barChart }, ]; +function importedThemeName(props: Pick) { + return props.hasCustomTheme && props.customThemeLabel ? props.customThemeLabel : "Imported theme"; +} + function focusCustomThemeImportInput() { const schedule = typeof requestAnimationFrame === "function" @@ -917,14 +921,15 @@ function renderAppearanceSection(props: ConfigProps) { cvs.lastCustomThemeImportFocusToken = props.customThemeImportFocusToken; focusCustomThemeImportInput(); } + const importedName = importedThemeName(props); const themeOptions: ThemeOption[] = [ ...BUILTIN_THEME_OPTIONS, { id: "custom", - label: "Custom", + label: props.hasCustomTheme ? importedName : "Import", description: props.hasCustomTheme - ? `Imported from tweakcn${props.customThemeLabel ? `: ${props.customThemeLabel}` : ""}` - : "Open the tweakcn importer for this browser-local slot", + ? `Imported from tweakcn: ${importedName}` + : "Import a tweakcn theme into this browser-local slot", icon: icons.spark, }, ]; @@ -971,17 +976,27 @@ function renderAppearanceSection(props: ConfigProps) {
Import from tweakcn

- Paste a tweakcn share link. The import stays in this browser only and replaces - the current custom slot. + Open tweakcn.com, choose or create a theme, click Share, then paste the copied + theme link here. Share links, editor URLs, registry URLs, theme IDs, and default + theme names like amethyst-haze are accepted.

+ + Browse tweakcn themes ${icons.externalLink} +