mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 23:30:44 +00:00
feat(ui): add tweakcn theme import
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
141
ui/src/ui/custom-theme.test.ts
Normal file
141
ui/src/ui/custom-theme.test.ts
Normal 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
410
ui/src/ui/custom-theme.ts
Normal 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;
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 (240–400px)
|
||||
navGroupsCollapsed: Record<string, boolean>; // 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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user