feat(ui): add tweakcn theme import

This commit is contained in:
Val Alexander
2026-04-24 17:24:04 -05:00
parent 76b504be31
commit 37666f94ac
17 changed files with 1302 additions and 49 deletions

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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),

View File

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

View File

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

View File

@@ -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<void>;
clearCustomTheme: () => void;
setBorderRadius: (value: number) => void;
applySettings: (next: UiSettings) => void;
applyLocalUserIdentity?: (next: { name?: string | null; avatar?: string | null }) => void;

View File

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

View File

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

410
ui/src/ui/custom-theme.ts Normal file
View File

@@ -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<ModeTokenName, string>;
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<typeof tweakcnThemeSchema>;
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<string, string> | 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<string, string>,
shared: Record<string, string> | 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<string, string>,
shared: Record<string, string> | 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<TweakcnThemeResolution, "sourceUrl" | "themeId">,
): 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<ImportedCustomTheme> {
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;
}

View File

@@ -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:",

View File

@@ -23,6 +23,7 @@ type PersistedUiSettings = Omit<UiSettings, "token" | "sessionKey" | "lastActive
import { isSupportedLocale } from "../i18n/index.ts";
import { getSafeLocalStorage, getSafeSessionStorage } from "../local-storage.ts";
import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts";
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
@@ -63,6 +64,7 @@ export type UiSettings = {
navWidth: number; // Sidebar width when expanded (240400px)
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
borderRadius: number; // Corner roundness (0100, 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 } : {}),
};

View File

@@ -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<ThemeName>(["claw", "knot", "dash"]);
export const VALID_THEME_NAMES = new Set<ThemeName>(["claw", "knot", "dash", "custom"]);
export const VALID_THEME_MODES = new Set<ThemeMode>(["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";
}

View File

@@ -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`
<div class="qs-card">
${renderCardHeader(icons.spark, "Appearance")}
@@ -385,7 +390,7 @@ function renderAppearanceCard(props: QuickSettingsProps) {
<div class="qs-row">
<span class="qs-row__label">Theme</span>
<div class="qs-segmented">
${THEME_OPTIONS.map(
${themeOptions.map(
(opt) => html`
<button
class="qs-segmented__btn ${opt.id === props.theme

View File

@@ -43,6 +43,15 @@ describe("config view", () => {
themeMode: "system" as ThemeMode,
setTheme: vi.fn(),
setThemeMode: vi.fn(),
hasCustomTheme: false,
customThemeLabel: null,
customThemeSourceUrl: null,
customThemeImportUrl: "",
customThemeImportBusy: false,
customThemeImportMessage: null,
onCustomThemeImportUrlChange: vi.fn(),
onImportCustomTheme: vi.fn(),
onClearCustomTheme: vi.fn(),
borderRadius: 50,
setBorderRadius: vi.fn(),
gatewayUrl: "",
@@ -207,6 +216,12 @@ describe("config view", () => {
);
expect(formButton?.classList.contains("active")).toBe(true);
expect(rawButton?.disabled).toBe(true);
const rawNotice = container.querySelector(".config-actions__notice");
const actionButtons = container.querySelector(".config-actions__buttons");
expect(rawNotice).not.toBeNull();
expect(actionButtons).not.toBeNull();
expect(actionButtons?.textContent).toContain("Reload");
expect(actionButtons?.textContent).toContain("Update");
expect(normalizedText(container)).toContain(
"Raw mode disabled (snapshot cannot safely round-trip raw text).",
);
@@ -496,4 +511,65 @@ describe("config view", () => {
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(onFormPatch).toHaveBeenCalledWith(["gateway", "mode"], "local");
});
it("disables the custom theme card until a tweakcn import exists", () => {
const { container } = renderConfigView({
activeSection: "__appearance__",
includeSections: ["__appearance__"],
});
const customButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Custom",
);
const importButton = Array.from(container.querySelectorAll("button")).find((btn) =>
btn.textContent?.includes("Import custom theme"),
);
expect(customButton?.disabled).toBe(true);
expect(importButton?.disabled).toBe(true);
});
it("shows custom theme actions once a tweakcn import exists", () => {
const setTheme = vi.fn();
const onClearCustomTheme = vi.fn();
const onImportCustomTheme = vi.fn();
const onCustomThemeImportUrlChange = vi.fn();
const { container } = renderConfigView({
activeSection: "__appearance__",
includeSections: ["__appearance__"],
hasCustomTheme: true,
customThemeLabel: "Light Green",
customThemeSourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
customThemeImportUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
setTheme,
onClearCustomTheme,
onImportCustomTheme,
onCustomThemeImportUrlChange,
});
const customButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Custom",
);
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"),
);
const clearButton = Array.from(container.querySelectorAll("button")).find((btn) =>
btn.textContent?.includes("Clear custom theme"),
);
replaceButton?.click();
clearButton?.click();
expect(onImportCustomTheme).toHaveBeenCalledTimes(1);
expect(onClearCustomTheme).toHaveBeenCalledTimes(1);
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.dispatchEvent(new Event("input"));
expect(onCustomThemeImportUrlChange).toHaveBeenCalledWith("https://tweakcn.com/themes/custom");
});
});

View File

@@ -62,6 +62,15 @@ export type ConfigProps = {
themeMode: ThemeMode;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
hasCustomTheme: boolean;
customThemeLabel: string | null;
customThemeSourceUrl: string | null;
customThemeImportUrl: string;
customThemeImportBusy: boolean;
customThemeImportMessage: { kind: "success" | "error"; text: string } | null;
onCustomThemeImportUrlChange: (next: string) => void;
onImportCustomTheme: () => void;
onClearCustomTheme: () => void;
borderRadius: number;
setBorderRadius: (value: number) => void;
gatewayUrl: string;
@@ -568,29 +577,48 @@ function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints):
return truncateValue(value);
}
type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult };
const THEME_OPTIONS: ThemeOption[] = [
type ThemeOption = {
id: ThemeName;
label: string;
description: string;
icon: TemplateResult;
disabled?: boolean;
};
const BUILTIN_THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap },
{ id: "knot", label: "Knot", description: "Black & red", icon: icons.link },
{ id: "dash", label: "Dash", description: "Chocolate blueprint", icon: icons.barChart },
];
function renderAppearanceSection(props: ConfigProps) {
const themeOptions: ThemeOption[] = [
...BUILTIN_THEME_OPTIONS,
{
id: "custom",
label: "Custom",
description: props.hasCustomTheme
? `Imported from tweakcn${props.customThemeLabel ? `: ${props.customThemeLabel}` : ""}`
: "Import a tweakcn theme to enable this slot",
icon: icons.spark,
disabled: !props.hasCustomTheme,
},
];
return html`
<div class="settings-appearance">
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Theme</h3>
<p class="settings-appearance__hint">Choose a theme family.</p>
<div class="settings-theme-grid">
${THEME_OPTIONS.map(
${themeOptions.map(
(opt) => html`
<button
class="settings-theme-card ${opt.id === props.theme
? "settings-theme-card--active"
: ""}"
: ""} ${opt.disabled ? "settings-theme-card--disabled" : ""}"
?disabled=${opt.disabled}
title=${opt.description}
@click=${(e: Event) => {
if (opt.id !== props.theme) {
if (!opt.disabled && opt.id !== props.theme) {
const context: ThemeTransitionContext = {
element: (e.currentTarget as HTMLElement) ?? undefined,
};
@@ -609,6 +637,68 @@ function renderAppearanceSection(props: ConfigProps) {
`,
)}
</div>
<div class="settings-theme-import">
<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.
</p>
</div>
<label class="settings-theme-import__field">
<span class="settings-theme-import__label">tweakcn link</span>
<input
class="settings-theme-import__input"
type="url"
placeholder="https://tweakcn.com/themes/..."
.value=${props.customThemeImportUrl}
@input=${(e: Event) =>
props.onCustomThemeImportUrlChange((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div class="settings-theme-import__actions">
<button
class="btn btn--sm primary"
?disabled=${props.customThemeImportBusy ||
props.customThemeImportUrl.trim().length === 0}
@click=${props.onImportCustomTheme}
>
${props.customThemeImportBusy
? "Importing…"
: props.hasCustomTheme
? "Replace custom theme"
: "Import custom theme"}
</button>
${props.hasCustomTheme
? html`
<button class="btn btn--sm danger" @click=${props.onClearCustomTheme}>
Clear custom theme
</button>
`
: nothing}
</div>
${props.hasCustomTheme
? html`
<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
>
</div>
`
: nothing}
${props.customThemeImportMessage
? html`
<div
class="settings-theme-import__message settings-theme-import__message--${props
.customThemeImportMessage.kind}"
>
${props.customThemeImportMessage.text}
</div>
`
: nothing}
</div>
</div>
<div class="settings-appearance__section">
@@ -941,37 +1031,39 @@ export function renderConfig(props: ConfigProps) {
<div class="config-actions__right">
${!rawAvailable
? html`
<span class="config-status muted"
<span class="config-status muted config-actions__notice"
>Raw mode disabled (snapshot cannot safely round-trip raw text).</span
>
`
: nothing}
${props.onOpenFile
? html`
<button
class="btn btn--sm"
title=${props.configPath ? `Open ${props.configPath}` : "Open config file"}
@click=${props.onOpenFile}
>
${icons.fileText} Open
</button>
`
: nothing}
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? t("common.loading") : t("common.reload")}
</button>
<button class="btn btn--sm" ?disabled=${!hasChanges} @click=${props.onReset}>
Clear
</button>
<button class="btn btn--sm primary" ?disabled=${!canSave} @click=${props.onSave}>
${props.saving ? "Saving…" : "Save"}
</button>
<button class="btn btn--sm" ?disabled=${!canApply} @click=${props.onApply}>
${props.applying ? "Applying…" : "Apply"}
</button>
<button class="btn btn--sm" ?disabled=${!canUpdate} @click=${props.onUpdate}>
${props.updating ? "Updating…" : "Update"}
</button>
<div class="config-actions__buttons">
${props.onOpenFile
? html`
<button
class="btn btn--sm"
title=${props.configPath ? `Open ${props.configPath}` : "Open config file"}
@click=${props.onOpenFile}
>
${icons.fileText} Open
</button>
`
: nothing}
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? t("common.loading") : t("common.reload")}
</button>
<button class="btn btn--sm" ?disabled=${!hasChanges} @click=${props.onReset}>
Clear
</button>
<button class="btn btn--sm primary" ?disabled=${!canSave} @click=${props.onSave}>
${props.saving ? "Saving…" : "Save"}
</button>
<button class="btn btn--sm" ?disabled=${!canApply} @click=${props.onApply}>
${props.applying ? "Applying…" : "Apply"}
</button>
<button class="btn btn--sm" ?disabled=${!canUpdate} @click=${props.onUpdate}>
${props.updating ? "Updating…" : "Update"}
</button>
</div>
</div>
</div>