功能:完善自定义主题与独立登录会话

This commit is contained in:
董宏凯 committed 2026-07-26 18:19:50 +08:00
1 parent 3779bb69ef
commit f1e53ef5c0
23 files changed
+1742 -196

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90",
"cursor-pointer bg-primary text-[var(--button-foreground,var(--primary-foreground))] hover:bg-primary/90",
destructive:
"cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
@@ -47,10 +47,18 @@ type AgentAvatarTheme =
| "lightyellow"
| "light"
| "lightblue"
| "lightgreen";
| "lightgreen"
| `custom-theme-${string}`;
/** Returns a stable avatar gradient and readable icon color for each theme. */
export function agentColor(id: string, theme: AgentAvatarTheme) {
if (theme.startsWith("custom-theme-")) {
return {
from: "var(--accent)",
to: "var(--brand-200)",
foreground: "var(--accent-foreground)",
};
}
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
@@ -1,7 +1,7 @@
"use client";
import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
@@ -84,8 +84,23 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
const { theme, setTheme } = useTheme();
const { data: appearance } = useAppearanceSettings();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const swatches = useMemo(
() => [
...THEME_SWATCHES,
...(appearance?.custom_themes ?? [])
.filter((preset) => preset.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((preset) => ({
id: preset.id as ThemeType,
label: preset.name,
color: preset.primary,
})),
],
[appearance?.custom_themes],
);
const cancelClose = () => {
if (closeTimer.current) {
@@ -124,9 +139,9 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
</Button>
{open ? (
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
{THEME_SWATCHES.map((swatch) => {
<div className="relative flex max-w-64 flex-wrap items-center justify-center gap-2 rounded-lg border border-border bg-popover px-2.5 py-1.5 shadow-lg">
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-border bg-popover" />
{swatches.map((swatch) => {
const active = theme === swatch.id;
return (
<button
@@ -1172,7 +1172,7 @@ export function InputBox({
className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--primary-foreground)] dark:hover:opacity-90 dark:disabled:bg-muted",
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
@@ -11,6 +11,7 @@ import {
ListIcon,
MoonIcon,
PanelLeftIcon,
PaletteIcon,
Plus,
SnowflakeIcon,
SunIcon,
@@ -20,7 +21,7 @@ import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button";
@@ -63,9 +64,10 @@ import {
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { SettingsSection } from "./settings-section";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
import { SettingsSection } from "./settings-section";
const languageOptions: { value: Locale; label: string }[] = [
{ value: "en-US", label: enUS.locale.localName },
@@ -176,9 +178,21 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
const globalDefaultLabel = useMemo(() => {
const customTheme = (appearance?.custom_themes ?? []).find(
(preset) => preset.id === globalDefault,
);
if (customTheme) return customTheme.name;
if (globalDefault === "light") return "浅色";
if (globalDefault === "dark") return "深色";
if (globalDefault === "lightgreen") return "绿色";
if (globalDefault === "iceblue") return "冰川蓝";
if (globalDefault === "lightyellow") return "浅黄色";
return "浅蓝";
}, [appearance?.custom_themes, globalDefault]);
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords =
@@ -428,18 +442,34 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
description={t.settings.appearance.themeDescription}
>
<div className="grid gap-3 lg:grid-cols-2">
{themeOptions.map((option) => (
<ThemePreviewCard
{themeOptions.map((option) => (
<ThemePreviewCard
key={option.id}
icon={option.icon}
label={option.label}
description={option.description}
active={currentTheme === option.id}
mode={option.id as "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow"}
onSelect={(value) => setTheme(value)}
/>
))}
</div>
onSelect={(value) => setTheme(value)}
/>
))}
{(appearance?.custom_themes ?? [])
.filter((preset) => preset.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((preset) => (
<ThemePreviewCard
key={preset.id}
icon={PaletteIcon}
label={preset.name}
description={`自定义主题 · 主色 ${preset.primary}`}
active={currentTheme === preset.id}
mode={preset.id}
previewDark={preset.mode === "dark"}
accentColor={preset.primary}
onSelect={(value) => setTheme(value)}
/>
))}
</div>
</SettingsSection>
<Separator />
@@ -617,26 +647,40 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<SunIcon className="mr-1.5 size-4" />
浅黄色
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalDefault === "light"
? "浅色"
: globalDefault === "dark"
? "深色"
: globalDefault === "lightgreen"
? "绿色"
: globalDefault === "iceblue"
? "冰川蓝"
: globalDefault === "lightyellow"
? "浅黄色"
: "浅蓝"}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
document
.getElementById("custom-theme-library")
?.scrollIntoView({ behavior: "smooth", block: "start" })
}
>
<PaletteIcon className="mr-1.5 size-4" />
管理自定义主题
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:{globalDefaultLabel}
</p>
</div>
</SettingsSection>
</>
)}
{isAdmin && (
</>
)}
{isAdmin && (
<div id="custom-theme-library" className="scroll-mt-4">
<Separator />
<SettingsSection
title="自定义主题库(管理员)"
description="管理员可按品牌主色创建主题,并设置为系统默认;用户只会看到已启用的主题并可自行切换。"
>
<CustomThemeLibrarySettings />
</SettingsSection>
</div>
)}
{isAdmin && (
<>
<Separator />
<SettingsSection
@@ -1137,19 +1181,24 @@ function CompactShortcutSettings({
function ThemePreviewCard({
icon: Icon,
label,
description,
active,
mode,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow";
onSelect: (mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow") => void;
}) {
const previewMode = mode;
description,
active,
mode,
previewDark,
accentColor,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: ThemeType;
previewDark?: boolean;
accentColor?: string;
onSelect: (mode: ThemeType) => void;
}) {
const previewMode = mode;
const isDarkPreview = previewDark ?? previewMode === "dark";
return (
<button
type="button"
@@ -1175,7 +1224,7 @@ function ThemePreviewCard({
<div
className={cn(
"relative overflow-hidden rounded-md border text-xs transition-colors",
previewMode === "dark"
isDarkPreview
? "border-neutral-800 bg-neutral-900 text-neutral-200"
: previewMode === "lightblue"
? "border-blue-200 bg-white text-slate-900"
@@ -1192,18 +1241,21 @@ function ThemePreviewCard({
<div
className={cn(
"h-2 w-2 rounded-full",
previewMode === "dark"
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: previewMode === "lightgreen"
? "bg-[#07c05f]"
: previewMode === "iceblue"
? "bg-[#9BCEF1]"
: previewMode === "lightyellow"
? "bg-[#F3FBD9]"
: "bg-[#cf1322]",
)}
accentColor
? ""
: isDarkPreview
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: previewMode === "lightgreen"
? "bg-[#07c05f]"
: previewMode === "iceblue"
? "bg-[#9BCEF1]"
: previewMode === "lightyellow"
? "bg-[#F3FBD9]"
: "bg-[#cf1322]",
)}
style={accentColor ? { backgroundColor: accentColor } : undefined}
/>
<div className="h-2 w-10 rounded-full bg-current/20" />
<div className="h-2 w-6 rounded-full bg-current/15" />
@@ -0,0 +1,753 @@
"use client";
import {
ChevronDownIcon,
ChevronUpIcon,
CheckIcon,
MoonIcon,
PaletteIcon,
PlusIcon,
RotateCcwIcon,
SaveIcon,
SlidersHorizontalIcon,
SunIcon,
Trash2Icon,
} from "lucide-react";
import { useEffect, useId, useMemo, useState, type CSSProperties } from "react";
import { ColorPickerPanel } from "tdesign-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type {
CustomThemeColorOverrides,
CustomThemePreset,
} from "@/core/system-settings/api";
import {
useAppearanceSettings,
useUpdateAppearanceSettings,
} from "@/core/system-settings/hooks";
import {
CUSTOM_THEME_ADVANCED_KEYS,
getCustomThemeAutomaticTokens,
getCustomThemeTokens,
normalizeHexColor,
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
const SUGGESTED_COLORS = [
"#9BCEF1",
"#F3FBD9",
"#7AB8F5",
"#8ED1B2",
"#C6B4F6",
"#F3C5A6",
"#F3B1B7",
"#63C4C8",
];
const TEXT_COLORS = ["#202A3A", "#172033", "#2D587A", "#4C6330", "#F4F7FB", "#FFFFFF"];
const ADVANCED_COLOR_FIELDS: Array<{
key: CustomThemeAdvancedKey;
label: string;
description: string;
token: string;
}> = [
{
key: "button_hover",
label: "按钮悬停色",
description: "主按钮悬停时的背景色",
token: "--theme-button-hover",
},
{
key: "selected_background",
label: "选中背景",
description: "左侧菜单、卡片选中时的浅背景",
token: "--theme-selected-background",
},
{
key: "border",
label: "边框色",
description: "卡片、输入框和分隔线",
token: "--border",
},
{
key: "muted",
label: "弱化背景",
description: "次级区域与弱化容器",
token: "--muted",
},
{
key: "muted_text",
label: "辅助文字",
description: "说明、占位和次要信息文字",
token: "--muted-foreground",
},
{
key: "destructive",
label: "删除/危险色",
description: "删除按钮、错误与危险操作的颜色",
token: "--destructive",
},
{
key: "brand_title",
label: "栏目标题文字",
description: "业务链、分析记录等带图标栏目标题",
token: "--brand-title-foreground",
},
{
key: "primary_foreground",
label: "主色块文字(Logo/状态标签)",
description: "Logo 的 C、启用状态和默认标签;不影响主按钮文字",
token: "--primary-foreground",
},
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
return `custom-theme-${suffix}`;
}
function newCustomTheme(order: number): CustomThemePreset {
return {
id: makeThemeId(),
name: `自定义主题 ${order + 1}`,
primary: "#9BCEF1",
text_color: null,
mode: "light",
overrides: {},
enabled: true,
sort_order: order,
};
}
function normalizeColorOverrides(
overrides: CustomThemeColorOverrides | undefined,
): CustomThemeColorOverrides {
const normalized: CustomThemeColorOverrides = {};
for (const key of CUSTOM_THEME_ADVANCED_KEYS) {
const value = overrides?.[key];
if (typeof value === "string" && value.trim()) {
normalized[key] = normalizeHexColor(value);
}
}
return normalized;
}
function normalizeThemeDrafts(
themes: CustomThemePreset[] | undefined,
): CustomThemePreset[] {
return [...(themes ?? [])]
.sort((a, b) => a.sort_order - b.sort_order)
.map((theme, index) => ({
...theme,
id: theme.id || makeThemeId(),
name: theme.name || `自定义主题 ${index + 1}`,
primary: normalizeHexColor(theme.primary),
text_color:
typeof theme.text_color === "string" && theme.text_color.trim()
? normalizeHexColor(theme.text_color)
: null,
mode: theme.mode === "dark" ? "dark" : "light",
overrides: normalizeColorOverrides(theme.overrides),
enabled: theme.enabled !== false,
sort_order: index,
}));
}
/**
* Render the TDesign panel inside the settings dialog instead of its default
* body-level popup. The dialog otherwise treats clicks inside that portal as
* outside interactions and closes the picker while a colour is being chosen.
*/
function InlineThemeColorPicker({
value,
fallback,
swatchColors,
onCommit,
ariaLabel = "选择颜色",
}: {
value: string;
fallback?: string;
swatchColors: string[];
onCommit: (value: string) => void;
ariaLabel?: string;
}) {
const normalizedValue = normalizeHexColor(value, fallback);
const [pendingValue, setPendingValue] = useState(normalizedValue);
const [open, setOpen] = useState(false);
const panelId = useId();
useEffect(() => {
if (!open) setPendingValue(normalizedValue);
}, [normalizedValue, open]);
return (
<div className="w-fit">
<button
type="button"
className="flex h-10 min-w-32 items-center gap-2 rounded-md border bg-background px-2 font-mono text-sm shadow-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={ariaLabel}
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((visible) => !visible)}
>
<span
className="size-7 rounded border"
style={{ backgroundColor: pendingValue }}
aria-hidden
/>
{pendingValue}
</button>
{open && (
<div
id={panelId}
className="mt-2 w-fit rounded-lg border bg-background p-2 shadow-lg"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<ColorPickerPanel
value={pendingValue}
format="HEX"
colorModes={["monochrome"]}
enableAlpha={false}
swatchColors={swatchColors}
onChange={(color) => setPendingValue(normalizeHexColor(String(color), fallback))}
/>
<div className="mt-2 flex justify-end gap-2 border-t pt-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setPendingValue(normalizedValue);
setOpen(false);
}}
>
取消
</Button>
<Button
type="button"
size="sm"
onClick={() => {
setOpen(false);
onCommit(pendingValue);
}}
>
应用颜色
</Button>
</div>
</div>
)}
</div>
);
}
function CustomThemePreview({ theme }: { theme: CustomThemePreset }) {
const tokens = getCustomThemeTokens(theme) as CSSProperties;
const buttonContrast = customThemeTextContrast(theme);
const isButtonContrastLow = buttonContrast < 4.5;
return (
<div
className="overflow-hidden rounded-lg border"
style={tokens}
aria-label={`${theme.name} 的颜色预览`}
>
<div className="flex items-center gap-2 border-b border-[var(--border)] bg-[var(--background)] px-3 py-2">
<span
className="flex size-5 items-center justify-center rounded-md bg-[var(--primary)] text-[10px] font-bold text-[var(--primary-foreground)] ring-2 ring-[var(--ring)]"
aria-hidden
>
C
</span>
<span className="text-xs font-semibold text-[var(--foreground)]">
实时预览
</span>
<span
className={cn(
"ml-auto text-[11px]",
isButtonContrastLow
? "text-amber-700 dark:text-amber-300"
: "text-[var(--muted-foreground)]",
)}
>
主按钮对比度 {buttonContrast.toFixed(1)}:1
</span>
</div>
<div className="grid gap-2 bg-[var(--background)] p-3 sm:grid-cols-[1fr_auto]">
<div className="rounded-md border border-[var(--border)] bg-[var(--card)] p-2.5">
<div className="text-xs font-semibold text-[var(--brand-title-foreground)]">
{theme.name || "未命名主题"}
</div>
<div className="mt-1 text-[11px] text-[var(--muted-foreground)]">
页面正文自动生成;主按钮文字可单独指定。
</div>
</div>
<button
type="button"
className="rounded-md bg-[var(--primary)] px-3 py-2 text-xs font-semibold text-[var(--button-foreground,var(--primary-foreground))] shadow-sm"
tabIndex={-1}
>
主按钮
</button>
{isButtonContrastLow && (
<p className="sm:col-span-2 text-[11px] text-amber-700 dark:text-amber-300">
当前按钮文字会按您的颜色设置展示,但对比度低于 4.5:1,建议改用更深的主色或恢复自动。
</p>
)}
</div>
</div>
);
}
function AdvancedThemeColorControls({
theme,
onPatch,
}: {
theme: CustomThemePreset;
onPatch: (patch: Partial<CustomThemePreset>) => void;
}) {
const automaticTokens = getCustomThemeAutomaticTokens({
primary: theme.primary,
mode: theme.mode,
});
const setOverride = (key: CustomThemeAdvancedKey, value: string) => {
const overrides = { ...(theme.overrides ?? {}) };
if (!value.trim()) {
delete overrides[key];
} else {
overrides[key] = value;
}
onPatch({ overrides });
};
return (
<div className="space-y-3 rounded-lg border border-dashed bg-muted/15 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="text-sm font-medium">更多配色</div>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
默认由主色自动推导。仅在品牌规范有明确要求时,选择单项覆盖;“恢复自动”会重新跟随主色。
</p>
</div>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
已覆盖 {Object.keys(theme.overrides ?? {}).length} 项
</span>
</div>
<div className="grid gap-3 lg:grid-cols-2">
{ADVANCED_COLOR_FIELDS.map((field) => {
const explicit = theme.overrides?.[field.key];
const automatic = automaticTokens[field.token] ?? "#0062D9";
const value = explicit ?? automatic;
return (
<div key={field.key} className="rounded-md border bg-background p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<div className="text-xs font-semibold">{field.label}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{field.description}
</div>
</div>
{explicit ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setOverride(field.key, "")}
>
<RotateCcwIcon className="mr-1 size-3.5" />
恢复自动
</Button>
) : (
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
自动
</span>
)}
</div>
<div className="flex flex-wrap items-end gap-2">
<InlineThemeColorPicker
value={value}
swatchColors={SUGGESTED_COLORS}
onCommit={(color) => setOverride(field.key, color)}
ariaLabel={`${theme.name || "自定义主题"}${field.label}选色器`}
/>
<Input
value={value}
maxLength={7}
className="h-8 w-28 font-mono text-xs uppercase"
onChange={(event) => setOverride(field.key, event.target.value)}
onBlur={(event) =>
setOverride(
field.key,
event.target.value.trim()
? normalizeHexColor(event.target.value)
: "",
)
}
aria-label={`${theme.name || "自定义主题"}${field.label}十六进制值`}
/>
</div>
{!explicit && (
<p className="mt-2 text-[11px] text-muted-foreground">
当前自动值:{automatic}
</p>
)}
</div>
);
})}
</div>
</div>
);
}
export function CustomThemeLibrarySettings() {
const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings();
const [drafts, setDrafts] = useState<CustomThemePreset[]>([]);
const [advancedThemeIds, setAdvancedThemeIds] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
setDrafts(normalizeThemeDrafts(appearance?.custom_themes));
}, [appearance?.custom_themes]);
const hasChanges = useMemo(
() =>
JSON.stringify(normalizeThemeDrafts(appearance?.custom_themes)) !==
JSON.stringify(normalizeThemeDrafts(drafts)),
[appearance?.custom_themes, drafts],
);
const patchTheme = (id: string, patch: Partial<CustomThemePreset>) => {
setDrafts((items) =>
items.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
};
const toggleAdvancedTheme = (id: string) => {
setAdvancedThemeIds((ids) => {
const next = new Set(ids);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSave = async () => {
const cleaned = normalizeThemeDrafts(drafts).map((theme) => ({
...theme,
name: theme.name.trim(),
primary: normalizeHexColor(theme.primary),
}));
if (cleaned.some((theme) => !theme.name)) {
toast.error("请为每个主题填写名称");
return;
}
const names = cleaned.map((theme) => theme.name.toLocaleLowerCase());
if (new Set(names).size !== names.length) {
toast.error("主题名称不能重复");
return;
}
try {
await updateAppearance.mutateAsync({ custom_themes: cleaned });
toast.success("自定义主题库已保存,所有用户可在主题切换器中选择已启用主题");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存主题库失败");
}
};
const handleSetDefault = async (theme: CustomThemePreset) => {
if (!theme.enabled || appearance?.default_theme === theme.id) return;
if (!(appearance?.custom_themes ?? []).some((preset) => preset.id === theme.id)) {
toast.info("请先保存主题库,再将新主题设为系统默认");
return;
}
try {
await updateAppearance.mutateAsync({ default_theme: theme.id });
toast.success(`已将“${theme.name || "自定义主题"}”设为系统默认主题`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "设置系统默认主题失败");
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
默认只需设置主色、主按钮文字与明暗模式;系统会自动生成页面正文、背景、边框、弱化、选中和悬停色。点击“更多”后才按需覆盖个别交互色,停用后不再向用户展示,最多可保存 {MAX_CUSTOM_THEMES} 个。
</p>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
setDrafts((items) =>
items.length >= MAX_CUSTOM_THEMES
? items
: [...items, newCustomTheme(items.length)],
)
}
disabled={drafts.length >= MAX_CUSTOM_THEMES || updateAppearance.isPending}
>
<PlusIcon className="mr-1.5 size-4" />
添加主题
</Button>
<Button
type="button"
size="sm"
onClick={() => void handleSave()}
disabled={!hasChanges || updateAppearance.isPending}
>
<SaveIcon className="mr-1.5 size-4" />
保存主题库
</Button>
</div>
</div>
{drafts.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-5 py-8 text-center">
<PaletteIcon className="mx-auto mb-2 size-5 text-muted-foreground" />
<div className="text-sm font-medium">还没有自定义主题</div>
<p className="mt-1 text-xs text-muted-foreground">
添加后,用户可以从右上角主题切换器中自行选择。
</p>
</div>
) : (
<div className="space-y-4">
{drafts.map((theme, index) => {
const isDefault = appearance?.default_theme === theme.id;
const advancedOpen = advancedThemeIds.has(theme.id);
const automaticTokens = getCustomThemeAutomaticTokens({
primary: theme.primary,
mode: theme.mode,
});
const textColor = theme.text_color ?? automaticTokens["--foreground"] ?? "#202A3A";
return (
<div key={theme.id} className="space-y-4 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<PaletteIcon className="size-4 text-primary" />
<span className="text-sm font-semibold">主题 {index + 1}</span>
{isDefault && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<CheckIcon className="size-3" />
系统默认
</span>
)}
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={theme.enabled}
onCheckedChange={(enabled) => patchTheme(theme.id, { enabled })}
aria-label={`启用${theme.name || "自定义主题"}`}
/>
{theme.enabled ? "启用" : "停用"}
</label>
<Button
type="button"
size="sm"
variant={isDefault ? "default" : "outline"}
onClick={() => void handleSetDefault(theme)}
disabled={!theme.enabled || isDefault || hasChanges || updateAppearance.isPending}
>
设为默认
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => toggleAdvancedTheme(theme.id)}
aria-expanded={advancedOpen}
aria-controls={`${theme.id}-advanced-colors`}
>
<SlidersHorizontalIcon className="mr-1.5 size-4" />
更多
{advancedOpen ? (
<ChevronUpIcon className="ml-1 size-3.5" />
) : (
<ChevronDownIcon className="ml-1 size-3.5" />
)}
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() =>
setDrafts((items) =>
items
.filter((item) => item.id !== theme.id)
.map((item, order) => ({ ...item, sort_order: order })),
)
}
title="删除主题"
aria-label={`删除${theme.name || "自定义主题"}`}
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(180px,0.55fr)_150px_150px]">
<label className="space-y-1.5">
<span className="text-xs font-medium">主题名称</span>
<Input
value={theme.name}
maxLength={32}
onChange={(event) => patchTheme(theme.id, { name: event.target.value })}
placeholder="例如:品牌浅蓝"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium">基础模式</span>
<Select
value={theme.mode}
onValueChange={(value) =>
patchTheme(theme.id, {
mode: value === "dark" ? "dark" : "light",
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">
<span className="inline-flex items-center gap-1.5"><SunIcon className="size-3.5" />浅色</span>
</SelectItem>
<SelectItem value="dark">
<span className="inline-flex items-center gap-1.5"><MoonIcon className="size-3.5" />深色</span>
</SelectItem>
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium">主色值</span>
<Input
value={theme.primary}
maxLength={7}
className="font-mono uppercase"
onChange={(event) => patchTheme(theme.id, { primary: event.target.value })}
onBlur={() =>
patchTheme(theme.id, {
primary: normalizeHexColor(theme.primary),
})
}
aria-label={`${theme.name || "自定义主题"}主色十六进制值`}
/>
</label>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">主按钮文字</span>
{theme.text_color ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[11px]"
onClick={() => patchTheme(theme.id, { text_color: null })}
>
自动
</Button>
) : (
<span className="text-[11px] text-muted-foreground">自动</span>
)}
</div>
<Input
value={textColor}
maxLength={7}
className="font-mono uppercase"
onChange={(event) =>
patchTheme(theme.id, { text_color: event.target.value })
}
onBlur={(event) =>
patchTheme(theme.id, {
text_color: event.target.value.trim()
? normalizeHexColor(event.target.value)
: null,
})
}
aria-label={`${theme.name || "自定义主题"}主按钮文字十六进制值`}
/>
</div>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-muted/15 p-3">
<div className="space-y-1.5">
<span className="text-xs font-medium">选择主色</span>
<InlineThemeColorPicker
value={theme.primary}
swatchColors={SUGGESTED_COLORS}
onCommit={(primary) => patchTheme(theme.id, { primary })}
ariaLabel={`${theme.name || "自定义主题"}主色选色器`}
/>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium">选择主按钮文字</span>
<InlineThemeColorPicker
value={textColor}
fallback="#202A3A"
swatchColors={TEXT_COLORS}
onCommit={(text_color) => patchTheme(theme.id, { text_color })}
ariaLabel={`${theme.name || "自定义主题"}主按钮文字选色器`}
/>
</div>
<div className="flex flex-wrap gap-1.5 pb-0.5">
{SUGGESTED_COLORS.map((color) => (
<button
key={color}
type="button"
className={cn(
"size-7 rounded-md border shadow-sm transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
normalizeHexColor(theme.primary) === color
? "ring-2 ring-primary ring-offset-2"
: "border-border",
)}
style={{ backgroundColor: color }}
onClick={() => patchTheme(theme.id, { primary: color })}
title={`使用 ${color}`}
aria-label={`使用主色 ${color}`}
/>
))}
</div>
</div>
{advancedOpen && (
<div id={`${theme.id}-advanced-colors`}>
<AdvancedThemeColorControls
theme={theme}
onPatch={(patch) => patchTheme(theme.id, patch)}
/>
</div>
)}
<CustomThemePreview theme={{ ...theme, primary: normalizeHexColor(theme.primary) }} />
</div>
);
})}
</div>
)}
</div>
);
}
@@ -184,7 +184,7 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
{/* <SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild
@@ -192,10 +192,10 @@ export function WorkspaceNavChatList({
>
<Link href="/page/workspace/roundtable/position">
<BriefcaseBusiness />
<span>岗位会商</span>
<span>岗位会商1</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenuItem> */}
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
+80 -32
View File
@@ -8,14 +8,20 @@ import React, {
import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
import {
BUILTIN_THEME_IDS,
type BuiltinThemeId,
type CustomThemePreset,
} from '@/core/system-settings/api';
import { getCustomThemeTokens, isCustomThemeId } from '@/core/theme/custom-theme';
import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/position-roundtable-demo-config';
// 主题类型
export type ThemeType = 'dark' | 'light' | 'lightblue' | 'lightgreen' | 'iceblue' | 'lightyellow';
export type ThemeType = BuiltinThemeId | `custom-theme-${string}`;
// 主题配置
export interface ThemeConfig {
type: ThemeType;
type: BuiltinThemeId;
name: string;
colors: {
background: string;
@@ -38,7 +44,7 @@ export interface ThemeConfig {
}
// 主题配置
const themes: Record<ThemeType, ThemeConfig> = {
const themes: Record<BuiltinThemeId, ThemeConfig> = {
dark: {
type: 'dark',
name: '深色主题',
@@ -183,16 +189,30 @@ const STORAGE_KEY = 'strategy-theme';
// admin changes. When the flag is present we never override the user.
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
const CUSTOM_THEME_TOKEN_NAMES = Object.keys(
getCustomThemeTokens({ primary: '#0062D9', mode: 'light' }),
);
function isBuiltinTheme(value: string | null | undefined): value is BuiltinThemeId {
return typeof value === 'string' && (BUILTIN_THEME_IDS as readonly string[]).includes(value);
}
function getEnabledCustomTheme(
id: string | null | undefined,
presets: CustomThemePreset[] | undefined,
): CustomThemePreset | undefined {
return isCustomThemeId(id)
? presets?.find((preset) => preset.id === id && preset.enabled)
: undefined;
}
function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') {
return 'light';
}
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'dark') return 'dark';
if (saved === 'lightblue') return 'lightblue';
if (saved === 'lightgreen') return 'lightgreen';
if (saved === 'iceblue') return 'iceblue';
if (saved === 'lightyellow') return 'lightyellow';
if (isBuiltinTheme(saved)) return saved;
if (isCustomThemeId(saved)) return saved;
return 'light';
}
@@ -249,15 +269,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
if (
current &&
current !== 'light' &&
current !== 'dark' &&
current !== 'lightblue' &&
current !== 'lightgreen' &&
current !== 'iceblue' &&
current !== 'lightyellow'
) {
if (current && !isBuiltinTheme(current) && !isCustomThemeId(current)) {
// Garbage value — wipe it so the boot-sync below can take over.
localStorage.removeItem(STORAGE_KEY);
}
@@ -288,25 +300,47 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
const effectiveTheme = forcedTheme ?? resolvedTheme;
const theme: ThemeType = mounted
? effectiveTheme === 'dark'
? 'dark'
: effectiveTheme === 'lightblue'
? 'lightblue'
: effectiveTheme === 'lightgreen'
? 'lightgreen'
: effectiveTheme === 'iceblue'
? 'iceblue'
: effectiveTheme === 'lightyellow'
? 'lightyellow'
: 'light'
: readPersistedTheme();
const persistedTheme = readPersistedTheme();
const requestedTheme = (mounted ? effectiveTheme : persistedTheme) ?? 'light';
const customTheme = getEnabledCustomTheme(
requestedTheme,
appearance?.custom_themes,
);
const theme: ThemeType = isBuiltinTheme(requestedTheme)
? requestedTheme
: customTheme?.id ?? 'light';
useEffect(() => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// next-themes adds a class for every value passed to setTheme(). Custom
// themes use data attributes and CSS variables instead, so remove its
// transient class before applying the generated token set.
for (const className of [...root.classList]) {
if (isCustomThemeId(className)) {
root.classList.remove(className);
}
}
for (const tokenName of CUSTOM_THEME_TOKEN_NAMES) {
root.style.removeProperty(tokenName);
}
root.removeAttribute('data-custom-theme');
if (customTheme) {
for (const [tokenName, tokenValue] of Object.entries(
getCustomThemeTokens(customTheme),
)) {
root.style.setProperty(tokenName, tokenValue);
}
root.setAttribute('data-theme', customTheme.id);
root.setAttribute('data-custom-theme', 'true');
root.setAttribute('theme-mode', customTheme.mode);
root.classList.toggle('dark', customTheme.mode === 'dark');
return;
}
if (theme === 'dark') {
root.setAttribute('theme-mode', 'dark');
root.classList.add('dark');
@@ -327,7 +361,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
} else {
root.removeAttribute('data-theme');
}
}, [theme]);
}, [customTheme, theme]);
const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark');
@@ -339,7 +373,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
markExplicitChoice();
};
const themeConfig = themes[theme];
const themeConfig = themes[isBuiltinTheme(theme) ? theme : 'light'];
return (
<ThemeContext.Provider
@@ -365,6 +399,20 @@ export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string =
return themeConfig.colors[colorKey];
};
/** Resolve custom runtime themes to the two visual modes expected by older
* integrations (reports, canvases and exported artifacts). */
export function themeToBaseMode(theme: ThemeType): 'light' | 'dark' {
if (theme === 'dark') return 'dark';
if (
isCustomThemeId(theme) &&
typeof document !== 'undefined' &&
document.documentElement.getAttribute('theme-mode') === 'dark'
) {
return 'dark';
}
return 'light';
}
// 组合类名工具函数
export const cn = (...classes: (string | undefined | null | false)[]): string => {
return classes.filter(Boolean).join(' ');
+15 -6
View File
@@ -4,6 +4,7 @@ import fallbackLogoLightblue from "@/assets/img/brand-logo-lightblue.png";
import { readRuntimeEnv } from "@/env";
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow" | "dark";
type BrandAssetTheme = BrandTheme | `custom-theme-${string}`;
const headerFallbackAssets: Record<BrandTheme, string> = {
light: fallbackLogoLight,
@@ -61,18 +62,26 @@ function readRuntimeBrandAsset(key: string): string | undefined {
}
}
export function getHeaderBrandAsset(theme: BrandTheme): string {
return readRuntimeBrandAsset(headerRuntimeKeys[theme]) ?? headerFallbackAssets[theme];
function resolveBrandTheme(theme: BrandAssetTheme): BrandTheme {
if (!theme.startsWith("custom-theme-")) return theme as BrandTheme;
return typeof document !== "undefined" && document.documentElement.getAttribute("theme-mode") === "dark"
? "dark"
: "light";
}
export function getHeaderBrandFallback(theme: BrandTheme): string {
return headerFallbackAssets[theme];
export function getHeaderBrandAsset(theme: BrandAssetTheme): string {
const resolved = resolveBrandTheme(theme);
return readRuntimeBrandAsset(headerRuntimeKeys[resolved]) ?? headerFallbackAssets[resolved];
}
export function getHeaderBrandFallback(theme: BrandAssetTheme): string {
return headerFallbackAssets[resolveBrandTheme(theme)];
}
/**
* Welcome content deliberately has no bundled image fallback. An empty
* runtime URL keeps the original CM text + gradient treatment.
*/
export function getWelcomeBrandAsset(theme: BrandTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[theme]);
export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]);
}
@@ -65,12 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
{
id: "position-roundtable",
label: "岗位会商",
icon: Briefcase,
path: "/page/strategy/qa/position-roundtable",
},
// {
// id: "position-roundtable",
// label: "岗位会商",
// icon: Briefcase,
// path: "/page/strategy/qa/position-roundtable",
// },
{
id: "dashboard-agent",
label: "大屏绘制",
+36 -1
View File
@@ -276,7 +276,40 @@ export async function updatePublishSquares(
return data;
}
export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow";
export const BUILTIN_THEME_IDS = [
"light",
"dark",
"lightblue",
"lightgreen",
"iceblue",
"lightyellow",
] as const;
export type BuiltinThemeId = (typeof BUILTIN_THEME_IDS)[number];
export type ThemeMode = BuiltinThemeId | `custom-theme-${string}`;
export type CustomThemeBaseMode = "light" | "dark";
export interface CustomThemeColorOverrides {
button_hover?: string | null;
selected_background?: string | null;
border?: string | null;
muted?: string | null;
muted_text?: string | null;
destructive?: string | null;
brand_title?: string | null;
primary_foreground?: string | null;
}
export interface CustomThemePreset {
id: `custom-theme-${string}`;
name: string;
primary: string;
text_color?: string | null;
mode: CustomThemeBaseMode;
overrides?: CustomThemeColorOverrides;
enabled: boolean;
sort_order: number;
}
export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
@@ -304,6 +337,7 @@ export interface AppearanceSettings {
default_agent_view: ViewMode;
default_layout_mode: LayoutMode;
compact_shortcut_buttons: CompactShortcutButton[];
custom_themes: CustomThemePreset[];
}
export interface AppearanceUpdate {
@@ -312,6 +346,7 @@ export interface AppearanceUpdate {
default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode;
compact_shortcut_buttons?: CompactShortcutButton[];
custom_themes?: CustomThemePreset[];
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
+284
View File
@@ -0,0 +1,284 @@
import type {
CustomThemeColorOverrides,
CustomThemePreset,
} from "@/core/system-settings/api";
export const CUSTOM_THEME_PREFIX = "custom-theme-";
type Rgb = { r: number; g: number; b: number };
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
const BLACK: Rgb = { r: 0, g: 0, b: 0 };
export const CUSTOM_THEME_ADVANCED_KEYS = [
"button_hover",
"selected_background",
"border",
"muted",
"muted_text",
"destructive",
"brand_title",
"primary_foreground",
] as const;
export type CustomThemeAdvancedKey = (typeof CUSTOM_THEME_ADVANCED_KEYS)[number];
export type CustomThemeTokenInput = Pick<
CustomThemePreset,
"primary" | "mode"
> &
Partial<Pick<CustomThemePreset, "text_color" | "overrides">>;
export function isCustomThemeId(value: string | null | undefined): value is `custom-theme-${string}` {
return typeof value === "string" && value.startsWith(CUSTOM_THEME_PREFIX);
}
export function normalizeHexColor(value: string, fallback = "#0062D9"): string {
const normalized = value.trim().toUpperCase();
return HEX_COLOR.test(normalized) ? normalized : fallback;
}
function optionalHexColor(value: string | null | undefined): string | undefined {
if (!value?.trim() || !HEX_COLOR.test(value.trim())) return undefined;
return value.trim().toUpperCase();
}
function toRgb(value: string): Rgb {
const hex = normalizeHexColor(value).slice(1);
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
};
}
function toHex({ r, g, b }: Rgb): string {
const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
return `#${channel(r)}${channel(g)}${channel(b)}`.toUpperCase();
}
function mix(from: Rgb, to: Rgb, amount: number): Rgb {
const ratio = Math.min(1, Math.max(0, amount));
return {
r: from.r + (to.r - from.r) * ratio,
g: from.g + (to.g - from.g) * ratio,
b: from.b + (to.b - from.b) * ratio,
};
}
function relativeLuminance({ r, g, b }: Rgb): number {
const linear = [r, g, b].map((channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
}
function contrastRatio(a: Rgb, b: Rgb): number {
const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (lighter! + 0.05) / (darker! + 0.05);
}
function readableForeground(background: Rgb): string {
return contrastRatio(background, BLACK) >= contrastRatio(background, WHITE) ? "#172033" : "#FFFFFF";
}
function ensureContrast(color: Rgb, background: Rgb, toward: Rgb, minimum = 4.5): Rgb {
let candidate = color;
for (let step = 1; step <= 10 && contrastRatio(candidate, background) < minimum; step += 1) {
candidate = mix(candidate, toward, 0.18);
}
return candidate;
}
function rgbChannels(color: Rgb): string {
return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`;
}
/**
* Produce the semantic CSS tokens consumed by Tailwind/shadcn and TDesign.
* Inputs are restricted to a safe six-digit primary colour, optional primary
* button text colour, a small set of interaction overrides and a light/dark surface
* family, so callers never interpolate arbitrary CSS values.
*/
export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<string, string> {
const primary = toRgb(theme.primary);
const isDark = theme.mode === "dark";
const overrides: CustomThemeColorOverrides = theme.overrides ?? {};
const background = isDark ? mix({ r: 13, g: 19, b: 32 }, primary, 0.12) : mix(WHITE, primary, 0.035);
const card = isDark ? mix({ r: 18, g: 27, b: 44 }, primary, 0.12) : WHITE;
// Normal page copy stays automatic so selecting white for a teal/blue
// action does not accidentally make a light-page's body text white.
const preferredForeground = toRgb(isDark ? "#F4F7FB" : "#202A3A");
const foregroundRgb = ensureContrast(
preferredForeground,
background,
toRgb(readableForeground(background)),
);
const foreground = toHex(foregroundRgb);
const defaultMutedForeground = toRgb(isDark ? "#AAB4C2" : "#667085");
const mutedForegroundRgb = optionalHexColor(overrides.muted_text)
? ensureContrast(
toRgb(overrides.muted_text!),
background,
toRgb(readableForeground(background)),
3,
)
: defaultMutedForeground;
const mutedForeground = toHex(mutedForegroundRgb);
const defaultAccent = isDark ? mix(background, primary, 0.3) : mix(WHITE, primary, 0.14);
const accent = optionalHexColor(overrides.selected_background)
? toRgb(overrides.selected_background!)
: defaultAccent;
const accentHover = isDark ? mix(accent, primary, 0.2) : mix(accent, primary, 0.14);
const defaultBorder = isDark ? mix(background, WHITE, 0.16) : mix(WHITE, primary, 0.28);
const border = optionalHexColor(overrides.border) ? toRgb(overrides.border!) : defaultBorder;
const input = isDark ? mix(background, WHITE, 0.22) : mix(WHITE, primary, 0.38);
const action = ensureContrast(
mix(primary, isDark ? WHITE : BLACK, isDark ? 0.2 : 0.3),
isDark ? background : WHITE,
isDark ? WHITE : BLACK,
);
const actionHover = isDark ? mix(action, WHITE, 0.12) : mix(action, BLACK, 0.12);
const actionActive = isDark ? mix(action, WHITE, 0.22) : mix(action, BLACK, 0.22);
const defaultPrimaryHover = isDark ? mix(primary, WHITE, 0.1) : mix(primary, BLACK, 0.1);
const primaryHover = optionalHexColor(overrides.button_hover)
? toRgb(overrides.button_hover!)
: defaultPrimaryHover;
const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
const brandTitle = optionalHexColor(overrides.brand_title) ?? foreground;
const defaultSecondary = isDark ? mix(background, WHITE, 0.08) : mix(WHITE, primary, 0.075);
const secondary = optionalHexColor(overrides.muted) ? toRgb(overrides.muted!) : defaultSecondary;
const sidebar = isDark ? mix(background, BLACK, 0.08) : mix(WHITE, primary, 0.018);
const ring = rgbChannels(primary);
const destructive = optionalHexColor(overrides.destructive) ?? (isDark ? "#FF777B" : "#CF4A4A");
const primaryHex = toHex(primary);
const actionHex = toHex(action);
const actionHoverHex = toHex(actionHover);
const actionActiveHex = toHex(actionActive);
const accentHex = toHex(accent);
const accentHoverHex = toHex(accentHover);
const backgroundHex = toHex(background);
const cardHex = toHex(card);
const borderHex = toHex(border);
const inputHex = toHex(input);
const secondaryHex = toHex(secondary);
const primaryHoverHex = toHex(primaryHover);
const primaryActiveHex = toHex(primaryActive);
const sidebarHex = toHex(sidebar);
return {
"--workspace-brand": primaryHex,
"--workspace-brand-foreground": primaryForeground,
"--brand-200": accentHex,
"--brand-900": actionHex,
"--brand-950": actionActiveHex,
"--theme-button-hover": primaryHoverHex,
"--theme-selected-background": accentHex,
"--background": backgroundHex,
"--foreground": foreground,
"--card": cardHex,
"--card-foreground": foreground,
"--popover": cardHex,
"--popover-foreground": foreground,
"--primary": primaryHex,
"--primary-foreground": primaryForeground,
"--button-foreground": buttonForeground,
"--brand-title-foreground": brandTitle,
"--secondary": secondaryHex,
"--secondary-foreground": actionHex,
"--muted": secondaryHex,
"--muted-foreground": mutedForeground,
"--accent": accentHex,
"--accent-foreground": actionHex,
"--destructive": destructive,
"--border": borderHex,
"--input": inputHex,
"--ring": `rgb(${ring} / 0.48)`,
"--chart-1": actionHex,
"--chart-2": toHex(mix(primary, { r: 20, g: 145, b: 150 }, 0.45)),
"--chart-3": toHex(mix(primary, { r: 108, g: 96, b: 175 }, 0.45)),
"--chart-4": toHex(mix(primary, { r: 220, g: 137, b: 54 }, 0.45)),
"--chart-5": toHex(mix(primary, { r: 57, g: 139, b: 110 }, 0.45)),
"--sidebar": sidebarHex,
"--sidebar-foreground": foreground,
"--sidebar-primary": primaryHex,
"--sidebar-primary-foreground": primaryForeground,
"--sidebar-accent": accentHex,
"--sidebar-accent-foreground": actionHex,
"--sidebar-border": borderHex,
"--sidebar-ring": `rgb(${ring} / 0.48)`,
"--legacy-accent-rgb": rgbChannels(accent),
"--legacy-background-rgb": rgbChannels(background),
"--legacy-border-rgb": rgbChannels(border),
"--legacy-brand-200-rgb": rgbChannels(accent),
"--legacy-card-rgb": rgbChannels(card),
"--legacy-destructive-rgb": rgbChannels(toRgb(destructive)),
"--legacy-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-input-rgb": rgbChannels(input),
"--legacy-muted-rgb": rgbChannels(secondary),
"--legacy-muted-foreground-rgb": rgbChannels(mutedForegroundRgb),
"--legacy-primary-rgb": rgbChannels(primary),
"--legacy-primary-foreground-rgb": rgbChannels(toRgb(primaryForeground)),
"--legacy-ring-rgb": ring,
"--legacy-secondary-rgb": rgbChannels(secondary),
"--legacy-sidebar-accent-rgb": rgbChannels(accent),
"--legacy-sidebar-border-rgb": rgbChannels(border),
"--legacy-sidebar-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-workspace-brand-rgb": rgbChannels(primary),
"--td-brand-color-1": backgroundHex,
"--td-brand-color-2": secondaryHex,
"--td-brand-color-3": accentHex,
"--td-brand-color-4": accentHoverHex,
"--td-brand-color-5": primaryHex,
"--td-brand-color-6": primaryHex,
"--td-brand-color-7": actionHex,
"--td-brand-color-8": actionHoverHex,
"--td-brand-color-9": actionActiveHex,
"--td-brand-color-10": actionActiveHex,
"--td-brand-color": primaryHex,
"--td-brand-color-hover": primaryHoverHex,
"--td-brand-color-focus": accentHex,
"--td-brand-color-active": primaryActiveHex,
"--td-brand-color-disabled": accentHoverHex,
"--td-brand-color-light": accentHex,
"--td-brand-color-light-hover": accentHoverHex,
"--td-text-color-primary": foreground,
"--td-text-color-secondary": mutedForeground,
"--td-text-color-brand": actionHex,
"--td-text-color-link": actionHex,
"--td-text-color-anti": buttonForeground,
"--td-bg-color-page": backgroundHex,
"--td-bg-color-container": cardHex,
"--td-bg-color-container-hover": secondaryHex,
"--td-bg-color-container-active": accentHex,
"--td-bg-color-secondarycontainer": secondaryHex,
"--td-bg-color-secondarycontainer-hover": accentHex,
"--td-bg-color-secondarycontainer-active": accentHoverHex,
"--td-border-level-1-color": borderHex,
"--td-component-stroke": borderHex,
"--td-border-level-2-color": inputHex,
"--td-component-border": inputHex,
};
}
/** Tokens produced from the automatic palette only, used by the editor to
* show which advanced values are currently inheriting from the system. */
export function getCustomThemeAutomaticTokens(
theme: Pick<CustomThemePreset, "primary" | "mode">,
): Record<string, string> {
return getCustomThemeTokens(theme);
}
export function customThemeContrast(primary: string): number {
const color = toRgb(primary);
return Math.max(contrastRatio(color, BLACK), contrastRatio(color, WHITE));
}
export function customThemeTextContrast(theme: CustomThemeTokenInput): number {
const tokens = getCustomThemeTokens(theme);
return contrastRatio(toRgb(tokens["--button-foreground"]!), toRgb(tokens["--primary"]!));
}
@@ -28,7 +28,7 @@ import {
getNodeTitle,
} from "@/components/knowledge-graph/graphUtils";
import "@/components/knowledge-graph/graphStyles.css";
import { useTheme } from "@/contexts/ThemeContext";
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
import {
useGraphBlacklist,
useGraphBlacklistActions,
@@ -799,7 +799,7 @@ export default function KnowledgeGraphPage() {
searchMatchIds={searchMatchIds}
layoutType={layoutType}
showLabels={showLabels}
theme={theme}
theme={themeToBaseMode(theme)}
onSelectNode={handleSelectNode}
onTooltip={setTooltip}
onZoomChange={setZoomLevel}
@@ -35,6 +35,7 @@ import { toast } from "sonner";
import { Pagination } from "@/components/ui/pagination";
import { cn } from "@/lib/utils";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { isCustomThemeId } from "@/core/theme/custom-theme";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -65,6 +66,17 @@ function hashIndex(str: string, mod: number): number {
}
function getLightAccent(theme: ThemeType) {
if (isCustomThemeId(theme)) {
return {
dot: "bg-[var(--primary)]",
badge: "border-[var(--border)] bg-[var(--accent)] text-[var(--accent-foreground)]",
focus: "focus:border-[var(--brand-900)] focus:ring-[var(--ring)]",
text: "text-[var(--accent-foreground)]",
hoverText: "group-hover:text-[var(--accent-foreground)]",
link: "text-[var(--accent-foreground)] hover:text-[var(--brand-950)]",
button: "bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] shadow-sm hover:bg-[var(--theme-button-hover)]",
};
}
if (theme === "lightgreen") {
return {
dot: "bg-[#07C05F]",
+17 -6
View File
@@ -90,12 +90,14 @@ export default function LoginPage() {
() => readEmbedParam(searchParams.toString()) === 'on',
[searchParams],
);
// External consumers may still send `from=iframe` for the legacy protocol
// chat. `chatMode=full` is an explicit escape hatch: always use the regular
// full ChatPage instead, which is the route expected by ordinary iframe
// nesting and browser deep links.
// Wujie is an application container, not the Brainy iframe protocol. It
// must therefore land on the regular full ChatPage even when an old portal
// URL still carries `from=iframe`. Plain embeds can request the same route
// explicitly with `chatMode=full`.
const fullChatRequested = useMemo(
() => searchParams.get('chatMode')?.trim().toLowerCase() === FULL_CHAT_MODE,
() =>
isWujieEnv() ||
searchParams.get('chatMode')?.trim().toLowerCase() === FULL_CHAT_MODE,
[searchParams],
);
const redirectPath = useMemo(
@@ -148,6 +150,15 @@ export default function LoginPage() {
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
const [pathOnly, ownQuery = ''] = normalized.split('?');
const target = new URLSearchParams(ownQuery);
// Keep the Wujie/full-chat decision on the target URL. The destination
// route uses it to retain the full agent/history workspace sidebar.
if (
fullChatRequested &&
pathOnly === DEFAULT_REDIRECT_PATH &&
!target.has('chatMode')
) {
target.set('chatMode', FULL_CHAT_MODE);
}
// 登录流程自身消费的参数,不透传到目标页(尤以鉴权类为甚)。
const LOGIN_OWNED = new Set([
'redirect', 'route', 'ticket', 'userId', 'env', 'theme', 'yUserId',
@@ -160,7 +171,7 @@ export default function LoginPage() {
});
const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly;
}, [searchParams]);
}, [fullChatRequested, searchParams]);
const externalParams = useMemo(
() => ({
theme: searchParams.get('theme') ?? '',
+63 -24
View File
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { isWujieEnv } from '@/core/embed/wujie';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
@@ -254,6 +255,66 @@ function NewChatEntryRoute({
);
}
/**
* `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy
* Wujie links still target it (or carry `from=iframe` during login), but Wujie
* needs the normal full chat workspace instead. Redirect it before mounting
* IframeChatPage; `chatMode=iframe` remains an explicit opt-in escape hatch
* for a Wujie integration that really implements that protocol.
*/
function IframeChatEntryRoute({
layoutProps,
hasThreadParam,
}: {
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
compactActionsHidden: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
hasThreadParam: boolean;
}) {
const location = useLocation();
const { thread_id } = useParams<{ thread_id: string }>();
const requestedMode = new URLSearchParams(location.search)
.get('chatMode')
?.trim()
.toLowerCase();
if (isWujieEnv() && requestedMode !== 'iframe') {
const targetParams = new URLSearchParams(location.search);
targetParams.set('chatMode', 'full');
const targetPath = hasThreadParam && thread_id
? `/page/workspace/chats/${thread_id}`
: '/page/workspace/chats/new';
const targetSearch = targetParams.toString();
return (
<Navigate
to={targetSearch ? `${targetPath}?${targetSearch}` : targetPath}
replace
/>
);
}
return (
<WorkspaceLayout
{...layoutProps}
pageSidebarHidden
workspaceSidebarHidden
wrapperOverflowHidden={hasThreadParam}
insetOverflowHidden={hasThreadParam}
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
);
}
export default function WorkspaceRoutes({
pageSidebarCollapsed = false,
pageSidebarHidden = false,
@@ -312,33 +373,11 @@ export default function WorkspaceRoutes({
/>
<Route
path="chats_iframe/new"
element={
<WorkspaceLayout
{...layoutProps}
pageSidebarHidden
workspaceSidebarHidden
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
/>
<Route
path="chats_iframe/:thread_id"
element={
<WorkspaceLayout
{...layoutProps}
pageSidebarHidden
workspaceSidebarHidden
wrapperOverflowHidden
insetOverflowHidden
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />}
/>
<Route
path="agents"
@@ -17,7 +17,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useTheme } from "@/contexts/ThemeContext";
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
import { apiFetch } from "@/core/api";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
@@ -261,7 +261,8 @@ export function useStep3Report(
options;
// 页面主题:浅色沿用浅红效果,深色让报告智能体按「深蓝 UI 规范」绘制。
const { theme } = useTheme();
const { theme: activeTheme } = useTheme();
const theme = themeToBaseMode(activeTheme);
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
const onReportSavedRef = useRef(onReportSaved);
@@ -2996,7 +2996,7 @@ function RoundtableEntryRail({
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Target className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前行动</h2>
<h2 className="theme-brand-title text-sm font-semibold tracking-wide text-slate-900">当前行动</h2>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">行动名称</p>
@@ -3011,7 +3011,7 @@ function RoundtableEntryRail({
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Clipboard className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前任务</h2>
<h2 className="theme-brand-title text-sm font-semibold tracking-wide text-slate-900">当前任务</h2>
</div>
<div className="space-y-2">
<div>
@@ -3036,7 +3036,7 @@ function RoundtableEntryRail({
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Layers className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">业务链</h2>
<h2 className="theme-brand-title text-sm font-semibold tracking-wide text-slate-900">业务链</h2>
<span className="ml-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-100 text-sm font-bold tabular-nums leading-none text-neutral-500">
{chains.length}
</span>
@@ -3249,7 +3249,7 @@ function RoundtableEntryRail({
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<History className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">分析记录</h2>
<h2 className="theme-brand-title text-sm font-semibold tracking-wide text-slate-900">分析记录</h2>
{/* 按业务链筛选 —— 仅当存在多于 1 个可选业务链时才显示图标按钮。 */}
{chainFilterOptions.length > 1 && (
<DropdownMenu>
+114
View File
@@ -1845,3 +1845,117 @@ html.lightyellow .gq-history-item--active .bg-primary {
.lightyellow .bg-primary\/70 { background-color: rgba(243, 251, 217, 0.70); }
.lightyellow .bg-primary\/90 { background-color: rgba(243, 251, 217, 0.90); }
}
/* Runtime-created themes deliberately use semantic variables rather than a
generated class per colour. These compatibility rules keep the selected,
hovered and compact-navigation states readable on Chrome 109 as well. */
html[data-custom-theme="true"] .agent-avatar {
background-color: var(--accent);
color: var(--accent-foreground);
box-shadow: inset 0 0 0 1px rgb(var(--legacy-workspace-brand-rgb) / 0.2);
}
html[data-custom-theme="true"] .agent-sidebar-card--active,
html[data-custom-theme="true"] .gq-sidebar .agent-sidebar-card--active,
html[data-custom-theme="true"] .gq-history-item--active {
border-color: rgb(var(--legacy-workspace-brand-rgb) / 0.52) !important;
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
}
html[data-custom-theme="true"] .workspace-compact-new-chat,
html[data-custom-theme="true"] .workspace-nav-new-chat {
border-color: rgb(var(--legacy-workspace-brand-rgb) / 0.38) !important;
color: var(--accent-foreground) !important;
}
html[data-custom-theme="true"] .workspace-compact-new-chat :is(svg, span),
html[data-custom-theme="true"] .workspace-nav-new-chat :is(svg, span),
html[data-custom-theme="true"] .workspace-nav-item:hover :is(svg, span),
html[data-custom-theme="true"] .workspace-nav-item[data-active="true"] :is(svg, span),
html[data-custom-theme="true"] .gq-history-item--active :is(svg, span),
html[data-custom-theme="true"] .gq-sidebar .agent-sidebar-card--active .text-primary {
color: var(--accent-foreground) !important;
}
html[data-custom-theme="true"] .workspace-compact-new-chat:hover,
html[data-custom-theme="true"] .workspace-compact-new-chat[data-active="true"],
html[data-custom-theme="true"] .workspace-nav-new-chat:hover,
html[data-custom-theme="true"] .workspace-nav-new-chat[data-active="true"],
html[data-custom-theme="true"] .workspace-nav-item:hover,
html[data-custom-theme="true"] .workspace-nav-item[data-active="true"] {
border-color: rgb(var(--legacy-workspace-brand-rgb) / 0.6) !important;
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
box-shadow: inset 3px 0 0 var(--brand-900);
}
html[data-custom-theme="true"] .gq-new-chat-button {
border-color: var(--brand-900) !important;
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
}
html[data-custom-theme="true"] .gq-new-chat-button:hover {
background-color: var(--theme-button-hover) !important;
}
/* The shared primary Button uses Tailwind's hover:bg-primary/90 utility.
Let a custom theme's optional button-hover override reach those buttons
too, without changing the fixed built-in theme palettes. */
html[data-custom-theme="true"] [class~="hover:bg-primary/90"]:hover {
background-color: var(--theme-button-hover) !important;
}
/* Keep the user-selected primary button text local to actions. Brand marks,
badges and other non-button primary surfaces retain their automatic, legible
foreground. */
html[data-custom-theme="true"] :is(button, a)[class~="bg-primary"] {
color: var(--button-foreground, var(--primary-foreground)) !important;
}
/* Scoped brand-title styling: built-in themes continue using their existing
title classes, while custom themes can tune the icon-adjacent headings. */
html[data-custom-theme="true"] .theme-brand-title {
color: var(--brand-title-foreground) !important;
}
/* The destructive token intentionally has its own advanced setting. */
html[data-custom-theme="true"] [class~="hover:bg-destructive/90"]:hover {
background-color: rgb(var(--legacy-destructive-rgb) / 0.9) !important;
}
html[data-custom-theme="true"] .gq-sidebar .agent-sidebar-card--active .bg-primary,
html[data-custom-theme="true"] .gq-history-item--active .bg-primary {
background-color: var(--brand-900) !important;
}
@supports not (color: oklch(1 0 0)) {
html[data-custom-theme="true"] .bg-primary\/5 { background-color: rgb(var(--legacy-primary-rgb) / 0.05); }
html[data-custom-theme="true"] .bg-primary\/8 { background-color: rgb(var(--legacy-primary-rgb) / 0.08); }
html[data-custom-theme="true"] .bg-primary\/10 { background-color: rgb(var(--legacy-primary-rgb) / 0.10); }
html[data-custom-theme="true"] .bg-primary\/14 { background-color: rgb(var(--legacy-primary-rgb) / 0.14); }
html[data-custom-theme="true"] .bg-primary\/15 { background-color: rgb(var(--legacy-primary-rgb) / 0.15); }
html[data-custom-theme="true"] .bg-primary\/20 { background-color: rgb(var(--legacy-primary-rgb) / 0.20); }
html[data-custom-theme="true"] .bg-primary\/25 { background-color: rgb(var(--legacy-primary-rgb) / 0.25); }
html[data-custom-theme="true"] .bg-primary\/30 { background-color: rgb(var(--legacy-primary-rgb) / 0.30); }
html[data-custom-theme="true"] .bg-primary\/60 { background-color: rgb(var(--legacy-primary-rgb) / 0.60); }
html[data-custom-theme="true"] .bg-primary\/70 { background-color: rgb(var(--legacy-primary-rgb) / 0.70); }
html[data-custom-theme="true"] .bg-primary\/90 { background-color: rgb(var(--legacy-primary-rgb) / 0.90); }
html[data-custom-theme="true"] .bg-destructive\/5 { background-color: rgb(var(--legacy-destructive-rgb) / 0.05); }
html[data-custom-theme="true"] .border-destructive\/30 { border-color: rgb(var(--legacy-destructive-rgb) / 0.30); }
html[data-custom-theme="true"] .border-primary\/15 { border-color: rgb(var(--legacy-primary-rgb) / 0.15); }
html[data-custom-theme="true"] .border-primary\/20 { border-color: rgb(var(--legacy-primary-rgb) / 0.20); }
html[data-custom-theme="true"] .border-primary\/25 { border-color: rgb(var(--legacy-primary-rgb) / 0.25); }
html[data-custom-theme="true"] .border-primary\/30 { border-color: rgb(var(--legacy-primary-rgb) / 0.30); }
html[data-custom-theme="true"] .border-primary\/35 { border-color: rgb(var(--legacy-primary-rgb) / 0.35); }
html[data-custom-theme="true"] .border-primary\/40 { border-color: rgb(var(--legacy-primary-rgb) / 0.40); }
html[data-custom-theme="true"] .border-primary\/50 { border-color: rgb(var(--legacy-primary-rgb) / 0.50); }
html[data-custom-theme="true"] .border-primary\/60 { border-color: rgb(var(--legacy-primary-rgb) / 0.60); }
html[data-custom-theme="true"] .ring-primary\/15 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.15); }
html[data-custom-theme="true"] .ring-primary\/20 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.20); }
html[data-custom-theme="true"] .ring-primary\/30 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.30); }
html[data-custom-theme="true"] .ring-primary\/40 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.40); }
html[data-custom-theme="true"] .ring-primary\/50 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.50); }
html[data-custom-theme="true"] .ring-primary\/60 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.60); }
}