mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
perf(ui): defer custom theme import to settings (#130710)
This commit is contained in:
committed by
GitHub
parent
5e8597f62d
commit
42ce03e72e
@@ -1,15 +1,12 @@
|
||||
// @vitest-environment node
|
||||
// Control UI tests cover custom theme behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { importCustomThemeFromUrl } from "../pages/config/custom-theme-import.ts";
|
||||
import {
|
||||
createImportedCustomThemeFixture as createImportedTheme,
|
||||
createTweakcnThemePayload as createTweakcnPayload,
|
||||
} from "../test-helpers/custom-theme.ts";
|
||||
import {
|
||||
importCustomThemeFromUrl,
|
||||
parseImportedCustomTheme,
|
||||
syncCustomThemeStyleTag,
|
||||
} from "./custom-theme.ts";
|
||||
import { parseImportedCustomTheme, syncCustomThemeStyleTag } from "./custom-theme.ts";
|
||||
import type { ImportedCustomTheme } from "./custom-theme.ts";
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
+6
-313
@@ -1,19 +1,10 @@
|
||||
import { asNullableRecord as readThemeRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
// Control UI module implements custom theme behavior.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { readResponseTextWithLimit } from "../lib/response-body.ts";
|
||||
|
||||
const TWEAKCN_HOSTS = new Set(["tweakcn.com", "www.tweakcn.com"]);
|
||||
const THEME_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
||||
export const THEME_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
||||
const CUSTOM_THEME_STYLE_ID = "openclaw-custom-theme";
|
||||
const MAX_TWEAKCN_THEME_BYTES = 200_000;
|
||||
const MAX_CSS_TOKEN_LENGTH = 240;
|
||||
const TWEAKCN_FETCH_TIMEOUT_MS = 10_000;
|
||||
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 FORBIDDEN_CSS_VALUE_PARTS = [
|
||||
"url(",
|
||||
"image(",
|
||||
@@ -26,10 +17,6 @@ const FORBIDDEN_CSS_VALUE_PARTS = [
|
||||
"@import",
|
||||
"expression(",
|
||||
] as const;
|
||||
const SAFE_COLOR_KEYWORDS = new Set(["black", "white", "transparent", "currentcolor"]);
|
||||
const SAFE_COLOR_FUNCTION_PATTERN =
|
||||
/^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\([a-z0-9+\-.,/%\s]+\)$/i;
|
||||
const SAFE_HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
const SAFE_FONT_FAMILY_PUNCTUATION = new Set([",", "'", '"', ".", "_", "-"]);
|
||||
|
||||
const MODE_TOKEN_ORDER = [
|
||||
@@ -99,76 +86,13 @@ export type ImportedCustomTheme = {
|
||||
dark: ThemeTokenMap;
|
||||
};
|
||||
|
||||
// Shape checks are intentionally shallow: normalizeStoredTokenMap and
|
||||
// resolveModeVar re-validate every token (presence, length, safe CSS) and
|
||||
// throw on anything off, so no schema library is needed at this boundary.
|
||||
type TweakcnThemeResolution = {
|
||||
sourceUrl: string;
|
||||
fetchUrl: string;
|
||||
themeId: string;
|
||||
};
|
||||
|
||||
function requireThemeId(value: string) {
|
||||
export 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 | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const themeId = segments.at(-1);
|
||||
if (!themeId) {
|
||||
return null;
|
||||
}
|
||||
if (segments.length === 2 && segments[0] === "themes") {
|
||||
requireThemeId(themeId);
|
||||
return themeId;
|
||||
}
|
||||
if (segments.length === 3 && segments[0] === "r" && segments[1] === "themes") {
|
||||
requireThemeId(themeId);
|
||||
return themeId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePastedThemeInput(input: string): string {
|
||||
const normalized = normalizeOptionalString(input);
|
||||
if (!normalized) {
|
||||
throw new Error("Paste a tweakcn theme link to import.");
|
||||
}
|
||||
const inputValue = normalized.replace(/[.,;:]+$/, "");
|
||||
if (THEME_ID_PATTERN.test(inputValue)) {
|
||||
return `https://tweakcn.com/themes/${inputValue}`;
|
||||
}
|
||||
if (inputValue.startsWith("/themes/") || inputValue.startsWith("/r/themes/")) {
|
||||
return `https://tweakcn.com${inputValue}`;
|
||||
}
|
||||
if (/^(?:www\.)?tweakcn\.com\//i.test(inputValue)) {
|
||||
return `https://${inputValue}`;
|
||||
}
|
||||
const embeddedUrl = inputValue
|
||||
.match(/https?:\/\/(?:www\.)?tweakcn\.com\/[^\s<>"')]+/i)?.[0]
|
||||
?.replace(/[.,;:]+$/, "");
|
||||
return embeddedUrl ?? inputValue;
|
||||
}
|
||||
|
||||
function normalizeThemeIdFromUrl(parsed: URL): string {
|
||||
const pathThemeId = normalizeThemeIdFromPath(parsed.pathname);
|
||||
if (pathThemeId) {
|
||||
return pathThemeId;
|
||||
}
|
||||
const queryThemeId =
|
||||
parsed.searchParams.get("theme") ??
|
||||
parsed.searchParams.get("themeId") ??
|
||||
parsed.searchParams.get("id");
|
||||
if (queryThemeId) {
|
||||
requireThemeId(queryThemeId);
|
||||
return queryThemeId;
|
||||
}
|
||||
throw new Error("Unsupported tweakcn link. Expected a theme share URL.");
|
||||
}
|
||||
|
||||
function requireSafeCssValue(value: unknown, label: string) {
|
||||
export function requireSafeCssValue(value: unknown, label: string) {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
@@ -201,19 +125,6 @@ function requireSafeCssValue(value: unknown, label: string) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireSafeExternalColorValue(value: unknown, label: string) {
|
||||
const normalized = requireSafeCssValue(value, label);
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (
|
||||
SAFE_COLOR_KEYWORDS.has(lowered) ||
|
||||
SAFE_HEX_COLOR_PATTERN.test(normalized) ||
|
||||
SAFE_COLOR_FUNCTION_PATTERN.test(normalized)
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
|
||||
function isSafeFontFamilyCharacter(char: string) {
|
||||
const code = char.charCodeAt(0);
|
||||
return (
|
||||
@@ -225,7 +136,7 @@ function isSafeFontFamilyCharacter(char: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function requireSafeFontFamilyValue(value: unknown, label: string) {
|
||||
export function requireSafeFontFamilyValue(value: unknown, label: string) {
|
||||
const normalized = requireSafeCssValue(value, label);
|
||||
if (
|
||||
normalized.includes("(") ||
|
||||
@@ -237,14 +148,7 @@ function requireSafeFontFamilyValue(value: unknown, label: string) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireSafeExternalModeValue(value: unknown, label: string) {
|
||||
if (label === "font-sans" || label === "font-mono") {
|
||||
return requireSafeFontFamilyValue(value, label);
|
||||
}
|
||||
return requireSafeExternalColorValue(value, label);
|
||||
}
|
||||
|
||||
function makeTokenMap(entries: Array<[ModeTokenName, string]>): ThemeTokenMap {
|
||||
export function makeTokenMap(entries: Array<[ModeTokenName, string]>): ThemeTokenMap {
|
||||
return Object.fromEntries(entries) as ThemeTokenMap;
|
||||
}
|
||||
|
||||
@@ -263,120 +167,7 @@ function normalizeStoredTokenMap(value: Record<string, unknown> | undefined): Th
|
||||
return makeTokenMap(entries);
|
||||
}
|
||||
|
||||
function resolveModeVar(
|
||||
theme: Record<string, unknown>,
|
||||
shared: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
fallback?: string,
|
||||
) {
|
||||
const themeValue = normalizeOptionalString(theme[key]);
|
||||
if (themeValue) {
|
||||
return requireSafeExternalModeValue(themeValue, key);
|
||||
}
|
||||
const sharedValue = normalizeOptionalString(shared?.[key]);
|
||||
if (sharedValue) {
|
||||
return requireSafeExternalModeValue(sharedValue, key);
|
||||
}
|
||||
if (fallback != null) {
|
||||
return key === "font-sans" || key === "font-mono"
|
||||
? requireSafeFontFamilyValue(fallback, key)
|
||||
: requireSafeCssValue(fallback, key);
|
||||
}
|
||||
throw new Error(`tweakcn theme is missing required token: ${key}`);
|
||||
}
|
||||
|
||||
function normalizeModeTokenMap(
|
||||
mode: "light" | "dark",
|
||||
theme: Record<string, unknown>,
|
||||
shared: Record<string, unknown> | 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) {
|
||||
export function describeThemeLabel(value: string | undefined) {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
return "Custom";
|
||||
@@ -384,25 +175,6 @@ function describeThemeLabel(value: string | undefined) {
|
||||
return truncateUtf16Safe(normalized, 80);
|
||||
}
|
||||
|
||||
function normalizeTweakcnThemeUrl(input: string): TweakcnThemeResolution {
|
||||
const normalized = normalizePastedThemeInput(input);
|
||||
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 = normalizeThemeIdFromUrl(parsed);
|
||||
return {
|
||||
themeId,
|
||||
sourceUrl: `https://tweakcn.com/themes/${themeId}`,
|
||||
fetchUrl: `https://tweakcn.com/r/themes/${themeId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseImportedCustomTheme(value: unknown): ImportedCustomTheme | null {
|
||||
const record = readThemeRecord(value);
|
||||
if (!record) {
|
||||
@@ -437,85 +209,6 @@ export function parseImportedCustomTheme(value: unknown): ImportedCustomTheme |
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImportedCustomTheme(
|
||||
payload: unknown,
|
||||
resolution: Pick<TweakcnThemeResolution, "sourceUrl" | "themeId">,
|
||||
): ImportedCustomTheme {
|
||||
const record = readThemeRecord(payload);
|
||||
const cssVars = readThemeRecord(record?.cssVars);
|
||||
const light = readThemeRecord(cssVars?.light);
|
||||
const dark = readThemeRecord(cssVars?.dark);
|
||||
const shared = cssVars?.theme === undefined ? undefined : readThemeRecord(cssVars.theme);
|
||||
if (!record || !cssVars || !light || !dark || shared === null) {
|
||||
throw new Error("tweakcn returned an invalid theme payload.");
|
||||
}
|
||||
return {
|
||||
sourceUrl: resolution.sourceUrl,
|
||||
themeId: resolution.themeId,
|
||||
label: describeThemeLabel(normalizeOptionalString(record.name)),
|
||||
importedAt: new Date().toISOString(),
|
||||
light: normalizeModeTokenMap("light", light, shared),
|
||||
dark: normalizeModeTokenMap("dark", dark, shared),
|
||||
};
|
||||
}
|
||||
|
||||
function assertTweakcnResponseUrl(value: string | undefined) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error("Unexpected tweakcn import response URL.");
|
||||
}
|
||||
if (parsed.protocol !== "https:" || !TWEAKCN_HOSTS.has(parsed.hostname)) {
|
||||
throw new Error("Unexpected redirect during tweakcn import.");
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonResponseWithLimit(response: Response): Promise<unknown> {
|
||||
const text = await readResponseTextWithLimit(response, {
|
||||
maxBytes: MAX_TWEAKCN_THEME_BYTES,
|
||||
tooLargeMessage: "tweakcn theme payload is too large.",
|
||||
missingBodyMessage: "tweakcn returned an unreadable theme payload.",
|
||||
});
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new Error("tweakcn returned invalid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function importCustomThemeFromUrl(
|
||||
input: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ImportedCustomTheme> {
|
||||
const resolution = normalizeTweakcnThemeUrl(input);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TWEAKCN_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetchImpl(resolution.fetchUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
assertTweakcnResponseUrl(response.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`tweakcn import failed (${response.status}).`);
|
||||
}
|
||||
const payload = await readJsonResponseWithLimit(response);
|
||||
return normalizeImportedCustomTheme(payload, resolution);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error("tweakcn import timed out.", { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCustomThemeStyles(theme: ImportedCustomTheme) {
|
||||
const light = normalizeStoredTokenMap(theme.light);
|
||||
const dark = normalizeStoredTokenMap(theme.dark);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import { importCustomThemeFromUrl } from "../app/custom-theme.ts";
|
||||
import { importCustomThemeFromUrl } from "../pages/config/custom-theme-import.ts";
|
||||
import {
|
||||
controlUiBundledGatewayUrl,
|
||||
controlUiBundledSettingsStorageKey,
|
||||
@@ -777,9 +777,10 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("announces a failed custom-theme import", async () => {
|
||||
it("announces a failed custom-theme import, then persists a successful retry across reload", async () => {
|
||||
await suite.withPage(
|
||||
{
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1440 },
|
||||
@@ -788,11 +789,17 @@ suite.define(() => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"config.get": configResponse({}, "custom-theme-invalid-1"),
|
||||
"config.patch": { ok: true },
|
||||
},
|
||||
});
|
||||
await page.route("https://tweakcn.com/r/themes/network-failure", async (route) => {
|
||||
await route.fulfill({ status: 503 });
|
||||
});
|
||||
let successfulImports = 0;
|
||||
await page.route("https://tweakcn.com/r/themes/retry-theme", async (route) => {
|
||||
successfulImports += 1;
|
||||
await route.fulfill({ json: createTweakcnThemePayload() });
|
||||
});
|
||||
|
||||
const response = await page.goto(`${suite.server.baseUrl}settings/appearance`);
|
||||
expect(response?.status()).toBe(200);
|
||||
@@ -809,6 +816,40 @@ suite.define(() => {
|
||||
.toBe("tweakcn import failed (503).");
|
||||
expect(await gateway.getRequests("config.patch")).toHaveLength(0);
|
||||
await captureViewport(page, "07-custom-theme-import-error-announced.png");
|
||||
|
||||
await gateway.setMethodResponse(
|
||||
"config.get",
|
||||
configResponse({ theme: "custom" }, "custom-theme-imported-2"),
|
||||
);
|
||||
await importer.locator("input").fill("https://tweakcn.com/themes/retry-theme");
|
||||
await importer.locator("button.primary").click();
|
||||
await expect.poll(() => importer.getByRole("status").textContent()).toContain("Imported");
|
||||
await expect.poll(() => page.locator("html").getAttribute("data-theme")).toBe("custom");
|
||||
const importedSettings = await readPersistedSettings(page);
|
||||
expect(importedSettings).toMatchObject({
|
||||
customTheme: { themeId: "retry-theme", label: "Light Green" },
|
||||
theme: "custom",
|
||||
});
|
||||
const importedAccent = await readAccentPresentation(page);
|
||||
expect(importedAccent.accent).toBe(createTweakcnThemePayload().cssVars.dark.accent);
|
||||
await waitForRequestCount(gateway, "config.patch", 1);
|
||||
const [themePatch] = await gateway.getRequests("config.patch");
|
||||
expect(patchPrefs(themePatch!)).toEqual({ theme: "custom" });
|
||||
await captureViewport(page, "08-custom-theme-imported.png");
|
||||
|
||||
await page.reload();
|
||||
await waitForControlUiSettingsTakeover(page);
|
||||
await expect.poll(() => page.locator("html").getAttribute("data-theme")).toBe("custom");
|
||||
await expect
|
||||
.poll(() => importer.locator(".settings-theme-import__meta-value").textContent())
|
||||
.toContain("Light Green");
|
||||
expect((await readPersistedSettings(page)).customTheme).toEqual(
|
||||
importedSettings.customTheme,
|
||||
);
|
||||
expect(await readAccentPresentation(page)).toEqual(importedAccent);
|
||||
expect(successfulImports).toBe(1);
|
||||
expect(await gateway.getRequests("config.patch")).toHaveLength(0);
|
||||
await captureViewport(page, "09-custom-theme-reloaded.png");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ApplicationContext, ApplicationThemeServerSelection } from "../../app/context.ts";
|
||||
import * as customTheme from "../../app/custom-theme.ts";
|
||||
import type { ImportedCustomTheme } from "../../app/custom-theme.ts";
|
||||
import { loadSettings, patchSettings, type UiSettings } from "../../app/settings.ts";
|
||||
import type { ThemeName } from "../../app/theme.ts";
|
||||
@@ -10,6 +9,7 @@ import { createImportedCustomThemeFixture } from "../../test-helpers/custom-them
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import { ConfigPage } from "./config-page.ts";
|
||||
import type { CustomThemeImportOwner } from "./custom-theme-import-owner.ts";
|
||||
import * as customTheme from "./custom-theme-import.ts";
|
||||
|
||||
const importCustomThemeFromUrl = vi.fn<typeof customTheme.importCustomThemeFromUrl>();
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type ApplicationContext,
|
||||
type ApplicationGatewaySnapshot,
|
||||
} from "../../app/context.ts";
|
||||
import { importCustomThemeFromUrl } from "../../app/custom-theme.ts";
|
||||
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
|
||||
import {
|
||||
resetServerUiPref,
|
||||
@@ -73,6 +72,7 @@ import {
|
||||
type ConfigPageId,
|
||||
} from "./config-sections.ts";
|
||||
import * as themeImport from "./custom-theme-import-owner.ts";
|
||||
import { importCustomThemeFromUrl } from "./custom-theme-import.ts";
|
||||
import { renderMcp } from "./mcp.ts";
|
||||
import { renderMemoryPage } from "./memory-page.ts";
|
||||
import { narrowMemorySchema } from "./memory-schema.ts";
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { asNullableRecord as readThemeRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
THEME_ID_PATTERN,
|
||||
describeThemeLabel,
|
||||
makeTokenMap,
|
||||
requireSafeCssValue,
|
||||
requireSafeFontFamilyValue,
|
||||
requireThemeId,
|
||||
type ImportedCustomTheme,
|
||||
} from "../../app/custom-theme.ts";
|
||||
import { readResponseTextWithLimit } from "../../lib/response-body.ts";
|
||||
|
||||
const TWEAKCN_HOSTS = new Set(["tweakcn.com", "www.tweakcn.com"]);
|
||||
const MAX_TWEAKCN_THEME_BYTES = 200_000;
|
||||
const TWEAKCN_FETCH_TIMEOUT_MS = 10_000;
|
||||
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 SAFE_COLOR_KEYWORDS = new Set(["black", "white", "transparent", "currentcolor"]);
|
||||
const SAFE_COLOR_FUNCTION_PATTERN =
|
||||
/^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\([a-z0-9+\-.,/%\s]+\)$/i;
|
||||
const SAFE_HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
|
||||
// resolveModeVar validates every imported token; app/custom-theme.ts separately
|
||||
// validates stored palettes before applying them during startup.
|
||||
type TweakcnThemeResolution = {
|
||||
sourceUrl: string;
|
||||
fetchUrl: string;
|
||||
themeId: string;
|
||||
};
|
||||
|
||||
function normalizeThemeIdFromPath(pathname: string): string | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const themeId = segments.at(-1);
|
||||
if (!themeId) {
|
||||
return null;
|
||||
}
|
||||
if (segments.length === 2 && segments[0] === "themes") {
|
||||
requireThemeId(themeId);
|
||||
return themeId;
|
||||
}
|
||||
if (segments.length === 3 && segments[0] === "r" && segments[1] === "themes") {
|
||||
requireThemeId(themeId);
|
||||
return themeId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePastedThemeInput(input: string): string {
|
||||
const normalized = normalizeOptionalString(input);
|
||||
if (!normalized) {
|
||||
throw new Error("Paste a tweakcn theme link to import.");
|
||||
}
|
||||
const inputValue = normalized.replace(/[.,;:]+$/, "");
|
||||
if (THEME_ID_PATTERN.test(inputValue)) {
|
||||
return `https://tweakcn.com/themes/${inputValue}`;
|
||||
}
|
||||
if (inputValue.startsWith("/themes/") || inputValue.startsWith("/r/themes/")) {
|
||||
return `https://tweakcn.com${inputValue}`;
|
||||
}
|
||||
if (/^(?:www\.)?tweakcn\.com\//i.test(inputValue)) {
|
||||
return `https://${inputValue}`;
|
||||
}
|
||||
const embeddedUrl = inputValue
|
||||
.match(/https?:\/\/(?:www\.)?tweakcn\.com\/[^\s<>"')]+/i)?.[0]
|
||||
?.replace(/[.,;:]+$/, "");
|
||||
return embeddedUrl ?? inputValue;
|
||||
}
|
||||
|
||||
function normalizeThemeIdFromUrl(parsed: URL): string {
|
||||
const pathThemeId = normalizeThemeIdFromPath(parsed.pathname);
|
||||
if (pathThemeId) {
|
||||
return pathThemeId;
|
||||
}
|
||||
const queryThemeId =
|
||||
parsed.searchParams.get("theme") ??
|
||||
parsed.searchParams.get("themeId") ??
|
||||
parsed.searchParams.get("id");
|
||||
if (queryThemeId) {
|
||||
requireThemeId(queryThemeId);
|
||||
return queryThemeId;
|
||||
}
|
||||
throw new Error("Unsupported tweakcn link. Expected a theme share URL.");
|
||||
}
|
||||
|
||||
function requireSafeExternalColorValue(value: unknown, label: string) {
|
||||
const normalized = requireSafeCssValue(value, label);
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (
|
||||
SAFE_COLOR_KEYWORDS.has(lowered) ||
|
||||
SAFE_HEX_COLOR_PATTERN.test(normalized) ||
|
||||
SAFE_COLOR_FUNCTION_PATTERN.test(normalized)
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
|
||||
function requireSafeExternalModeValue(value: unknown, label: string) {
|
||||
if (label === "font-sans" || label === "font-mono") {
|
||||
return requireSafeFontFamilyValue(value, label);
|
||||
}
|
||||
return requireSafeExternalColorValue(value, label);
|
||||
}
|
||||
|
||||
function resolveModeVar(
|
||||
theme: Record<string, unknown>,
|
||||
shared: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
fallback?: string,
|
||||
) {
|
||||
const themeValue = normalizeOptionalString(theme[key]);
|
||||
if (themeValue) {
|
||||
return requireSafeExternalModeValue(themeValue, key);
|
||||
}
|
||||
const sharedValue = normalizeOptionalString(shared?.[key]);
|
||||
if (sharedValue) {
|
||||
return requireSafeExternalModeValue(sharedValue, key);
|
||||
}
|
||||
if (fallback != null) {
|
||||
return key === "font-sans" || key === "font-mono"
|
||||
? requireSafeFontFamilyValue(fallback, key)
|
||||
: requireSafeCssValue(fallback, key);
|
||||
}
|
||||
throw new Error(`tweakcn theme is missing required token: ${key}`);
|
||||
}
|
||||
|
||||
function normalizeModeTokenMap(
|
||||
mode: "light" | "dark",
|
||||
theme: Record<string, unknown>,
|
||||
shared: Record<string, unknown> | undefined,
|
||||
): ImportedCustomTheme["light"] {
|
||||
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 normalizeTweakcnThemeUrl(input: string): TweakcnThemeResolution {
|
||||
const normalized = normalizePastedThemeInput(input);
|
||||
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 = normalizeThemeIdFromUrl(parsed);
|
||||
return {
|
||||
themeId,
|
||||
sourceUrl: `https://tweakcn.com/themes/${themeId}`,
|
||||
fetchUrl: `https://tweakcn.com/r/themes/${themeId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeImportedCustomTheme(
|
||||
payload: unknown,
|
||||
resolution: Pick<TweakcnThemeResolution, "sourceUrl" | "themeId">,
|
||||
): ImportedCustomTheme {
|
||||
const record = readThemeRecord(payload);
|
||||
const cssVars = readThemeRecord(record?.cssVars);
|
||||
const light = readThemeRecord(cssVars?.light);
|
||||
const dark = readThemeRecord(cssVars?.dark);
|
||||
const shared = cssVars?.theme === undefined ? undefined : readThemeRecord(cssVars.theme);
|
||||
if (!record || !cssVars || !light || !dark || shared === null) {
|
||||
throw new Error("tweakcn returned an invalid theme payload.");
|
||||
}
|
||||
return {
|
||||
sourceUrl: resolution.sourceUrl,
|
||||
themeId: resolution.themeId,
|
||||
label: describeThemeLabel(normalizeOptionalString(record.name)),
|
||||
importedAt: new Date().toISOString(),
|
||||
light: normalizeModeTokenMap("light", light, shared),
|
||||
dark: normalizeModeTokenMap("dark", dark, shared),
|
||||
};
|
||||
}
|
||||
|
||||
function assertTweakcnResponseUrl(value: string | undefined) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error("Unexpected tweakcn import response URL.");
|
||||
}
|
||||
if (parsed.protocol !== "https:" || !TWEAKCN_HOSTS.has(parsed.hostname)) {
|
||||
throw new Error("Unexpected redirect during tweakcn import.");
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonResponseWithLimit(response: Response): Promise<unknown> {
|
||||
const text = await readResponseTextWithLimit(response, {
|
||||
maxBytes: MAX_TWEAKCN_THEME_BYTES,
|
||||
tooLargeMessage: "tweakcn theme payload is too large.",
|
||||
missingBodyMessage: "tweakcn returned an unreadable theme payload.",
|
||||
});
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new Error("tweakcn returned invalid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function importCustomThemeFromUrl(
|
||||
input: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ImportedCustomTheme> {
|
||||
const resolution = normalizeTweakcnThemeUrl(input);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TWEAKCN_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetchImpl(resolution.fetchUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
assertTweakcnResponseUrl(response.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`tweakcn import failed (${response.status}).`);
|
||||
}
|
||||
const payload = await readJsonResponseWithLimit(response);
|
||||
return normalizeImportedCustomTheme(payload, resolution);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error("tweakcn import timed out.", { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user