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
This commit is contained in:
Val Alexander
2026-04-27 12:24:14 -05:00
committed by GitHub
parent fc8ccde542
commit e3ad82d86d
9 changed files with 177 additions and 35 deletions

View File

@@ -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/<id>` registry URLs, editor URLs like `https://tweakcn.com/editor/theme?theme=amethyst-haze`, relative `/themes/<id>` 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)
<AccordionGroup>

View File

@@ -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;

View File

@@ -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<typeof applySettingsInternal>[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<typeof applySettingsInternal>[0], {
...this.settings,
theme: nextTheme,

View File

@@ -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", () => {

View File

@@ -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}`,

View File

@@ -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();

View File

@@ -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`
<div class="qs-card qs-card--appearance">
${renderCardHeader(icons.spark, "Appearance")}

View File

@@ -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",
);
});
});

View File

@@ -788,6 +788,10 @@ const BUILTIN_THEME_OPTIONS: ThemeOption[] = [
{ id: "dash", label: "Dash", description: "Chocolate blueprint", icon: icons.barChart },
];
function importedThemeName(props: Pick<ConfigProps, "hasCustomTheme" | "customThemeLabel">) {
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) {
<div class="settings-theme-import__copy">
<div class="settings-theme-import__title">Import from tweakcn</div>
<p class="settings-theme-import__hint">
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.
</p>
</div>
<a
class="settings-theme-import__external"
href="https://tweakcn.com/themes"
target="_blank"
rel="noreferrer noopener"
>
Browse tweakcn themes ${icons.externalLink}
</a>
<label class="settings-theme-import__field">
<span class="settings-theme-import__label">tweakcn link</span>
<span class="settings-theme-import__label">Theme link or ID</span>
<input
class="settings-theme-import__input"
data-custom-theme-import-input
type="url"
placeholder="https://tweakcn.com/themes/..."
type="text"
spellcheck="false"
placeholder="https://tweakcn.com/editor/theme?theme=... or amethyst-haze"
.value=${props.customThemeImportUrl}
@input=${(e: Event) =>
props.onCustomThemeImportUrlChange(
@@ -999,13 +1014,13 @@ function renderAppearanceSection(props: ConfigProps) {
${props.customThemeImportBusy
? "Importing…"
: props.hasCustomTheme
? "Replace custom theme"
: "Import custom theme"}
? `Replace ${importedName}`
: "Import theme"}
</button>
${props.hasCustomTheme
? html`
<button class="btn btn--sm danger" @click=${props.onClearCustomTheme}>
Clear custom theme
Clear ${importedName}
</button>
`
: nothing}
@@ -1015,8 +1030,7 @@ function renderAppearanceSection(props: ConfigProps) {
<div class="settings-theme-import__meta">
<span class="settings-theme-import__meta-label">Loaded</span>
<span class="settings-theme-import__meta-value"
>${props.customThemeLabel ?? "Custom"} ·
${props.customThemeSourceUrl ?? "tweakcn"}</span
>${importedName} · ${props.customThemeSourceUrl ?? "tweakcn"}</span
>
</div>
`
@@ -1035,8 +1049,8 @@ function renderAppearanceSection(props: ConfigProps) {
`
: html`
<p class="settings-theme-import__inline-hint">
Click <strong>Custom</strong> to import a tweakcn theme into this browser-local
slot.
Click <strong>Import</strong> to add one browser-local tweakcn theme. In tweakcn,
use Share and paste the copied link here.
</p>
`}
</div>