Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
639
ui/src/app/custom-theme.ts
Normal file
639
ui/src/app/custom-theme.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
// Control UI module implements custom theme behavior.
|
||||
import { z } from "zod";
|
||||
import { normalizeOptionalString } from "../lib/string-coerce.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}$/;
|
||||
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(",
|
||||
"image-set(",
|
||||
"-webkit-image-set(",
|
||||
"cross-fade(",
|
||||
"element(",
|
||||
"-moz-element(",
|
||||
"paint(",
|
||||
"@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 = [
|
||||
"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>;
|
||||
|
||||
const REQUIRED_TWEAKCN_MODE_VARS = [
|
||||
"background",
|
||||
"foreground",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"muted",
|
||||
"muted-foreground",
|
||||
"accent",
|
||||
"accent-foreground",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
] as const;
|
||||
type RequiredTweakcnModeVar = (typeof REQUIRED_TWEAKCN_MODE_VARS)[number];
|
||||
|
||||
export type ImportedCustomTheme = {
|
||||
sourceUrl: string;
|
||||
themeId: string;
|
||||
label: string;
|
||||
importedAt: string;
|
||||
light: ThemeTokenMap;
|
||||
dark: ThemeTokenMap;
|
||||
};
|
||||
|
||||
const cssTokenSchema = z.string().max(MAX_CSS_TOKEN_LENGTH);
|
||||
|
||||
function createStringShape<const T extends readonly string[]>(keys: T) {
|
||||
return Object.fromEntries(keys.map((key) => [key, cssTokenSchema])) as Record<
|
||||
T[number],
|
||||
typeof cssTokenSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const tweakcnThemeSchema = z.object({
|
||||
name: z.string().max(80).optional(),
|
||||
cssVars: z.object({
|
||||
theme: z
|
||||
.object({
|
||||
"font-sans": cssTokenSchema.optional(),
|
||||
"font-mono": cssTokenSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
light: z.object(createStringShape(REQUIRED_TWEAKCN_MODE_VARS)),
|
||||
dark: z.object(createStringShape(REQUIRED_TWEAKCN_MODE_VARS)),
|
||||
}),
|
||||
});
|
||||
|
||||
const importedCustomThemeSchema = z.object({
|
||||
sourceUrl: z.string(),
|
||||
themeId: z.string(),
|
||||
label: z.string(),
|
||||
importedAt: z.string(),
|
||||
light: z.object(createStringShape(MODE_TOKEN_ORDER)),
|
||||
dark: z.object(createStringShape(MODE_TOKEN_ORDER)),
|
||||
});
|
||||
|
||||
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 | null {
|
||||
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];
|
||||
}
|
||||
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) {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
if (normalized.length > MAX_CSS_TOKEN_LENGTH) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (FORBIDDEN_CSS_VALUE_PARTS.some((part) => lowered.includes(part))) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
if (normalized.includes("/*") || normalized.includes("*/") || normalized.includes("\\")) {
|
||||
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 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 (
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
(code >= 0x41 && code <= 0x5a) ||
|
||||
(code >= 0x61 && code <= 0x7a) ||
|
||||
char === " " ||
|
||||
SAFE_FONT_FAMILY_PUNCTUATION.has(char)
|
||||
);
|
||||
}
|
||||
|
||||
function requireSafeFontFamilyValue(value: unknown, label: string) {
|
||||
const normalized = requireSafeCssValue(value, label);
|
||||
if (
|
||||
normalized.includes("(") ||
|
||||
normalized.includes(")") ||
|
||||
!Array.from(normalized).every(isSafeFontFamilyCharacter)
|
||||
) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
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 {
|
||||
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 =
|
||||
key === "font-body" || key === "font-display" || key === "mono"
|
||||
? requireSafeFontFamilyValue(value[key], key)
|
||||
: requireSafeCssValue(value[key], key);
|
||||
entries.push([key, normalized]);
|
||||
}
|
||||
return makeTokenMap(entries);
|
||||
}
|
||||
|
||||
function resolveModeVar(
|
||||
theme: Record<string, string | undefined>,
|
||||
shared: Record<string, string | undefined> | 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<RequiredTweakcnModeVar, string>,
|
||||
shared: Record<string, string | undefined> | 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 = 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 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),
|
||||
};
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentLength(headers: Headers): number | null {
|
||||
const raw = headers.get("content-length");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
async function readResponseTextWithLimit(response: Response): Promise<string> {
|
||||
const contentLength = parseContentLength(response.headers);
|
||||
if (contentLength != null && contentLength > MAX_TWEAKCN_THEME_BYTES) {
|
||||
throw new Error("tweakcn theme payload is too large.");
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("tweakcn returned an unreadable theme payload.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
let text = "";
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) {
|
||||
break;
|
||||
}
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > MAX_TWEAKCN_THEME_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new Error("tweakcn theme payload is too large.");
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonResponseWithLimit(response: Response): Promise<unknown> {
|
||||
const text = await readResponseTextWithLimit(response);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCustomThemeStyles(theme: ImportedCustomTheme) {
|
||||
const light = normalizeStoredTokenMap(theme.light);
|
||||
const dark = normalizeStoredTokenMap(theme.dark);
|
||||
if (!light || !dark) {
|
||||
throw new Error("Stored custom theme is missing required tokens.");
|
||||
}
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user