CozyDo 论坛主题增强:多风格预设、自定义编辑、JSON 导入导出、右上角入口与可选悬浮按钮
// ==UserScript==
// @name CozyDo Theme Studio
// @namespace https://linux.do/
// @version 1.3.0
// @description CozyDo 论坛主题增强:多风格预设、自定义编辑、JSON 导入导出、右上角入口与可选悬浮按钮
// @author AIGCFREE
// @match https://linux.do/*
// @match https://*.linux.do/*
// @run-at document-start
// @grant none
// @license MIT
// ==/UserScript==
(() => {
"use strict";
const SCRIPT_VERSION = "1.3.0";
const STORAGE_KEY = "linuxdo_theme_config_v1";
const STYLE_UI_ID = "linuxdo-theme-ui";
const STYLE_VARS_ID = "linuxdo-theme-vars";
const STYLE_PATCH_ID = "linuxdo-theme-patches";
const PANEL_ID = "linuxdo-theme-panel";
const OVERLAY_ID = "linuxdo-theme-overlay";
const FLOATING_BTN_ID = "linuxdo-theme-floating-btn";
const HEADER_ENTRY_ID = "linuxdo-theme-header-entry";
const UI_DEBOUNCE_MS = 150;
const CUSTOM_THEME_ID = "custom-current";
const LIBRARY_SOURCE_CUSTOM = "custom";
const LIBRARY_SOURCE_IMPORTED = "imported";
const DISCOURSE_BIND_TIMEOUT_MS = 8000;
const DISCOURSE_BIND_INTERVAL_MS = 120;
const DISCOURSE_EVENT_PAGE_CHANGED = "page:changed";
const DISCOURSE_EVENT_INTERFACE_COLOR_CHANGED = "interface-color:changed";
const TOPIC_LINK_NEW_TAB_SELECTOR =
"a[data-topic-id],a.raw-topic-link,a.badge-posts,a.post-activity,.topic-post-badges a.badge-notification";
const TOPIC_LINK_NEW_TAB_CONTEXT_SELECTOR =
".topic-list,.latest-topic-list-item,.bookmark-list,.categories-topic-list,.latest-topic-list,.top-topic-list";
const TEST_MODE = globalThis.__LDT_TEST_MODE__ === true;
const MAX_IMPORT_TEXT_CHARS = 500_000;
const UNIQUE_THEME_ID_MAX_ATTEMPTS = 60;
const TOKEN_KEYS = [
"--primary",
"--primary-high",
"--primary-medium",
"--primary-low",
"--primary-low-mid",
"--primary-very-low",
"--secondary",
"--tertiary",
"--tertiary-low",
"--header_background",
"--header_primary",
"--background-color",
"--d-content-background",
"--d-unread-notification-background",
"--d-selected",
"--d-hover",
"--link-color",
"--link-color-hover",
"--content-border-color",
"--d-button-default-border",
"--d-border-radius",
"--d-border-radius-large",
"--shadow-card",
"--shadow-dropdown",
];
const TOKEN_ALIASES = {
"--tertiary-med-or-tertiary": "--tertiary",
};
const LINKED_BACKGROUND_TOKEN = "--d-content-background";
const EDITABLE_TOKEN_KEYS = TOKEN_KEYS.filter((key) => key !== LINKED_BACKGROUND_TOKEN);
const CUSTOM_IO_SCHEMA_VERSION = 2;
const CUSTOM_IO_KIND = "custom-page-config";
const COLLECTION_IO_SCHEMA_VERSION = 1;
const COLLECTION_IO_KIND = "theme-collection";
const TOKEN_META = {
"--primary": { label: "主体文字", type: "color" },
"--primary-high": { label: "次级文字(高)", type: "color" },
"--primary-medium": { label: "次级文字(中)", type: "color" },
"--primary-low": { label: "弱对比底色(Low)", type: "color" },
"--primary-low-mid": { label: "弱对比底色(Low Mid)", type: "color" },
"--primary-very-low": { label: "弱对比底色(Very Low)", type: "color" },
"--secondary": { label: "页面底色", type: "color" },
"--tertiary": { label: "品牌强调色", type: "color" },
"--tertiary-low": { label: "信息条背景", type: "color" },
"--header_background": { label: "顶部栏背景", type: "color" },
"--header_primary": { label: "顶部栏文字", type: "color" },
"--background-color": { label: "全局背景", type: "color" },
"--d-content-background": { label: "内容容器背景", type: "color" },
"--d-unread-notification-background": { label: "通知未读背景", type: "color" },
"--d-selected": { label: "选中态背景", type: "color" },
"--d-hover": { label: "悬停态背景", type: "color" },
"--link-color": { label: "链接颜色", type: "color" },
"--link-color-hover": { label: "链接悬停", type: "color" },
"--content-border-color": { label: "分隔线颜色", type: "color" },
"--d-button-default-border": { label: "默认按钮边框", type: "text" },
"--d-border-radius": { label: "基础圆角", type: "text" },
"--d-border-radius-large": { label: "大圆角", type: "text" },
"--shadow-card": { label: "卡片阴影", type: "text" },
"--shadow-dropdown": { label: "下拉阴影", type: "text" },
};
const PRESETS = {
"claude-light": {
id: "claude-light",
name: "Claude Light",
description: "温暖米色、柔和强调、适合长时间阅读。",
patchProfile: "claude",
patchDefaults: { headerGlass: true, topicCardElevation: false, radiusScale: 108, shadowIntensity: 95 },
tokens: {
"--primary": "#1f1f1a",
"--primary-high": "#3f3b33",
"--primary-medium": "#6f685d",
"--primary-low": "#ddcdb7",
"--primary-low-mid": "#b9a98f",
"--primary-very-low": "#f8f3eb",
"--secondary": "#f7f3ec",
"--tertiary": "#b97835",
"--tertiary-low": "#efe2d1",
"--header_background": "#f7f3ec",
"--header_primary": "#1f1f1a",
"--background-color": "#fbf8f2",
"--d-content-background": "#fbf8f2",
"--d-unread-notification-background": "#eee2d1",
"--d-selected": "#e9dcc8",
"--d-hover": "rgba(185, 120, 53, 0.14)",
"--link-color": "#8f5f2b",
"--link-color-hover": "#71481f",
"--content-border-color": "#ddcdb7",
"--d-button-default-border": "1px solid #d7c2a8",
"--d-border-radius": "11px",
"--d-border-radius-large": "18px",
"--shadow-card": "0 8px 26px rgba(45, 36, 24, 0.09)",
"--shadow-dropdown": "0 10px 28px rgba(45, 36, 24, 0.15)",
},
},
"claude-dark": {
id: "claude-dark",
name: "Claude Dark",
description: "深色暖灰、低刺激对比、偏 Claude 夜间质感。",
patchProfile: "claude",
patchDefaults: { headerGlass: true, topicCardElevation: false, radiusScale: 108, shadowIntensity: 115 },
tokens: {
"--primary": "#e9e3d9",
"--primary-high": "#c9c2b8",
"--primary-medium": "#a69e93",
"--primary-low": "#2d2720",
"--primary-low-mid": "#5f5548",
"--primary-very-low": "#1f1b16",
"--secondary": "#12110f",
"--tertiary": "#d99a4f",
"--tertiary-low": "#2d2418",
"--header_background": "#161411",
"--header_primary": "#ece4d8",
"--background-color": "#181613",
"--d-content-background": "#181613",
"--d-unread-notification-background": "#2d241a",
"--d-selected": "#2d241a",
"--d-hover": "rgba(217, 154, 79, 0.20)",
"--link-color": "#e0a35f",
"--link-color-hover": "#efb97f",
"--content-border-color": "#2d2720",
"--d-button-default-border": "1px solid #433826",
"--d-border-radius": "11px",
"--d-border-radius-large": "18px",
"--shadow-card": "0 10px 30px rgba(0, 0, 0, 0.45)",
"--shadow-dropdown": "0 12px 32px rgba(0, 0, 0, 0.62)",
},
},
"openai-light": {
id: "openai-light",
name: "OpenAI Light",
description: "清爽灰绿底色,OpenAI 风格低饱和与高可读性。",
patchProfile: "openai",
patchDefaults: { headerGlass: true, topicCardElevation: false, radiusScale: 100, shadowIntensity: 90 },
tokens: {
"--primary": "#0f1f17",
"--primary-high": "#284034",
"--primary-medium": "#5b7367",
"--primary-low": "#cde1d8",
"--primary-low-mid": "#92ada0",
"--primary-very-low": "#f1f7f4",
"--secondary": "#f5f8f6",
"--tertiary": "#10a37f",
"--tertiary-low": "#d8efe8",
"--header_background": "#f5f8f6",
"--header_primary": "#112018",
"--background-color": "#fbfdfc",
"--d-content-background": "#fbfdfc",
"--d-unread-notification-background": "#dcefe8",
"--d-selected": "#dcefe8",
"--d-hover": "rgba(16, 163, 127, 0.14)",
"--link-color": "#0b8a6a",
"--link-color-hover": "#076f54",
"--content-border-color": "#cde1d8",
"--d-button-default-border": "1px solid #b8d4c8",
"--d-border-radius": "10px",
"--d-border-radius-large": "16px",
"--shadow-card": "0 8px 24px rgba(16, 34, 27, 0.09)",
"--shadow-dropdown": "0 12px 30px rgba(16, 34, 27, 0.15)",
},
},
"openai-dark": {
id: "openai-dark",
name: "OpenAI Dark",
description: "深色灰绿背景,OpenAI 风格克制对比与绿色强调。",
patchProfile: "openai",
patchDefaults: { headerGlass: true, topicCardElevation: false, radiusScale: 100, shadowIntensity: 115 },
tokens: {
"--primary": "#e7f2ed",
"--primary-high": "#bed3c8",
"--primary-medium": "#8ba79a",
"--primary-low": "#1f3a30",
"--primary-low-mid": "#4f7063",
"--primary-very-low": "#12211c",
"--secondary": "#0d1512",
"--tertiary": "#10a37f",
"--tertiary-low": "#16352b",
"--header_background": "#101a16",
"--header_primary": "#e9f3ee",
"--background-color": "#0f1915",
"--d-content-background": "#0f1915",
"--d-unread-notification-background": "#173128",
"--d-selected": "#173128",
"--d-hover": "rgba(16, 163, 127, 0.22)",
"--link-color": "#35c59f",
"--link-color-hover": "#67d8b7",
"--content-border-color": "#1f3a30",
"--d-button-default-border": "1px solid #275144",
"--d-border-radius": "10px",
"--d-border-radius-large": "16px",
"--shadow-card": "0 10px 30px rgba(0, 0, 0, 0.45)",
"--shadow-dropdown": "0 12px 34px rgba(0, 0, 0, 0.60)",
},
},
"trae-light": {
id: "trae-light",
name: "Trae Light",
description: "参考 forum.trae.cn:深色顶栏 + 浅灰页面 + 明亮绿色强调。",
patchProfile: "trae",
patchDefaults: { headerGlass: false, topicCardElevation: false, radiusScale: 98, shadowIntensity: 92 },
tokens: {
"--primary": "#1a1b1d",
"--primary-high": "#5b5e65",
"--primary-medium": "#878b93",
"--primary-low": "#e7e8e9",
"--primary-low-mid": "#b7b9be",
"--primary-very-low": "#f8f8f9",
"--secondary": "#f3f4f5",
"--tertiary": "#0ab861",
"--tertiary-low": "#d2fce7",
"--header_background": "#1a1b1d",
"--header_primary": "#ffffff",
"--background-color": "#f8f8f9",
"--d-content-background": "#f8f8f9",
"--d-unread-notification-background": "#eafef4",
"--d-selected": "#dfe1e5",
"--d-hover": "#e6e8eb",
"--link-color": "#0ab861",
"--link-color-hover": "#078a49",
"--content-border-color": "#e7e8e9",
"--d-button-default-border": "1px solid #d2d5d9",
"--d-border-radius": "10px",
"--d-border-radius-large": "16px",
"--shadow-card": "0 4px 14px rgba(0, 0, 0, 0.15)",
"--shadow-dropdown": "0 2px 12px rgba(0, 0, 0, 0.10)",
},
},
"trae-dark": {
id: "trae-dark",
name: "Trae Dark",
description: "Trae 风格深色版:低亮度石墨背景 + 绿色交互强调。",
patchProfile: "trae",
patchDefaults: { headerGlass: false, topicCardElevation: false, radiusScale: 98, shadowIntensity: 112 },
tokens: {
"--primary": "#e8ebee",
"--primary-high": "#c5cbd2",
"--primary-medium": "#919aa5",
"--primary-low": "#2a323c",
"--primary-low-mid": "#5b6774",
"--primary-very-low": "#151a20",
"--secondary": "#111417",
"--tertiary": "#0ab861",
"--tertiary-low": "#173a2b",
"--header_background": "#161a1e",
"--header_primary": "#f7f9fb",
"--background-color": "#0e1114",
"--d-content-background": "#0e1114",
"--d-unread-notification-background": "#1a2e25",
"--d-selected": "#1e252d",
"--d-hover": "rgba(15, 220, 120, 0.16)",
"--link-color": "#2fdc8a",
"--link-color-hover": "#6bf0b0",
"--content-border-color": "#2a323c",
"--d-button-default-border": "1px solid #34404c",
"--d-border-radius": "10px",
"--d-border-radius-large": "16px",
"--shadow-card": "0 10px 30px rgba(0, 0, 0, 0.42)",
"--shadow-dropdown": "0 12px 32px rgba(0, 0, 0, 0.58)",
},
},
};
const DEFAULT_PATCHES = {
headerGlass: true,
topicCardElevation: false,
radiusScale: 100,
shadowIntensity: 100,
};
const DEFAULT_SETTINGS = {
enableFloatingButton: false,
openTopicInNewTab: false,
headerGlass: DEFAULT_PATCHES.headerGlass,
topicCardElevation: DEFAULT_PATCHES.topicCardElevation,
radiusScale: DEFAULT_PATCHES.radiusScale,
shadowIntensity: DEFAULT_PATCHES.shadowIntensity,
};
const SECTION_LABELS = {
"all-themes": "全部主题",
custom: "自定义",
"import-export": "导入导出",
settings: "设置",
};
const SOURCE_LABELS = {
[LIBRARY_SOURCE_CUSTOM]: "自定义",
[LIBRARY_SOURCE_IMPORTED]: "导入",
};
const DISCOURSE_THEME_ICON_VIEWBOX = "0 0 576 512";
const DISCOURSE_THEME_ICON_PATH = "M339.3 367.1c27.3-3.9 51.9-19.4 67.2-42.9L568.2 74.1c12.6-19.5 9.4-45.3-7.6-61.2S517.7-4.4 499.1 9.6L262.4 187.2c-24 18-38.2 46.1-38.4 76.1L339.3 367.1zm-19.6 25.4l-116-104.4C143.9 290.3 96 339.6 96 400c0 3.9 .2 7.8 .6 11.6C98.4 429.1 86.4 448 68.8 448L64 448c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0c61.9 0 112-50.1 112-112c0-2.5-.1-5-.2-7.5z";
const buildThemeIconSvg = (className = "d-icon") =>
`<svg${className ? ` class="${className}"` : ""} width="1em" height="1em" viewBox="${DISCOURSE_THEME_ICON_VIEWBOX}" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="${DISCOURSE_THEME_ICON_PATH}"/></svg>`;
const state = {
config: null,
uiStyle: null,
varsStyle: null,
patchStyle: null,
lastVarsCSS: "",
lastPatchCSS: "",
lastAppliedThemeSignature: "",
panel: null,
overlay: null,
floatingBtn: null,
panelStatusEl: null,
activeSection: "all-themes",
isPanelOpen: false,
updateTimer: null,
syncEntryTimer: null,
headerIconsObserver: null,
headerIconsObserverTarget: null,
discourseBindTimer: null,
discourseBindStartAt: 0,
discourseServicesBound: false,
discourseBindWarned: false,
appEvents: null,
interfaceColor: null,
onPageChangedHandler: null,
onInterfaceColorChangedHandler: null,
onTopicLinkClickCapture: null,
isTopicLinkNewTabBound: false,
isEscBound: false,
isBootstrapped: false,
lastExportText: "",
customThemeDraftName: "我的主题",
lastForcedForumMode: "",
};
const UI_CSS = `
#${FLOATING_BTN_ID}{position:fixed;right:20px;bottom:20px;z-index:10012;width:44px;height:44px;border-radius:999px;border:1px solid var(--content-border-color);background:var(--secondary);color:var(--primary);box-shadow:var(--shadow-dropdown);display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:18px;}
#${FLOATING_BTN_ID}:hover{background:var(--d-hover);}#${OVERLAY_ID}{position:fixed;inset:0;z-index:10010;background:rgba(0,0,0,.35);backdrop-filter:blur(1px);}#${PANEL_ID}{position:fixed;right:16px;top:72px;width:min(760px,calc(100vw - 32px));max-height:calc(100vh - 92px);overflow:auto;z-index:10011;background:var(--secondary);color:var(--primary);border:1px solid var(--content-border-color);border-radius:14px;box-shadow:var(--shadow-dropdown);}#${PANEL_ID}[hidden],#${OVERLAY_ID}[hidden]{display:none!important;}#${PANEL_ID} .ldt-header{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--content-border-color);background:var(--secondary);}#${PANEL_ID} .ldt-title-wrap h3{margin:0;font-size:16px;}#${PANEL_ID} .ldt-title-wrap p{margin:4px 0 0;color:var(--primary-medium);font-size:12px;}#${PANEL_ID} .ldt-close{border:1px solid var(--content-border-color);border-radius:8px;background:var(--secondary);color:var(--primary);cursor:pointer;width:30px;height:30px;}#${PANEL_ID} .ldt-tabs{display:flex;gap:8px;padding:12px 16px 0;flex-wrap:wrap;}#${PANEL_ID} .ldt-tab{border:1px solid var(--content-border-color);border-radius:10px;background:var(--secondary);color:var(--primary);padding:6px 10px;cursor:pointer;font-size:12px;}#${PANEL_ID} .ldt-tab.--active{background:var(--d-selected);border-color:var(--tertiary);}#${PANEL_ID} .ldt-sections{padding:12px 16px 16px;}#${PANEL_ID} .ldt-section[hidden]{display:none!important;}#${PANEL_ID} .ldt-preset-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;}#${PANEL_ID} .ldt-preset-card{border:1px solid var(--content-border-color);border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:8px;background:var(--d-content-background);}#${PANEL_ID} .ldt-preset-card.--active{border-color:var(--tertiary);box-shadow:0 0 0 1px var(--tertiary);}#${PANEL_ID} .ldt-preset-card h4{margin:0;font-size:14px;}#${PANEL_ID} .ldt-preset-card p{margin:0;color:var(--primary-medium);font-size:12px;line-height:1.35;}#${PANEL_ID} .ldt-badge{display:inline-flex;align-self:flex-start;font-size:11px;padding:2px 8px;border-radius:999px;background:var(--d-selected);color:var(--primary-high);}#${PANEL_ID} .ldt-btn{border:1px solid var(--content-border-color);border-radius:10px;background:var(--secondary);color:var(--primary);cursor:pointer;font-size:12px;padding:6px 10px;}#${PANEL_ID} .ldt-btn.--primary{border-color:var(--tertiary);background:var(--tertiary);color:var(--secondary);}#${PANEL_ID} .ldt-btn-row{display:flex;gap:8px;flex-wrap:wrap;}#${PANEL_ID} .ldt-field-grid{display:grid;gap:10px;}#${PANEL_ID} .ldt-group{border:1px solid var(--content-border-color);border-radius:12px;padding:10px;}#${PANEL_ID} .ldt-group h4{margin:0 0 8px;font-size:13px;}#${PANEL_ID} .ldt-group small{color:var(--primary-medium);}#${PANEL_ID} .ldt-token-row{display:grid;grid-template-columns:minmax(130px,1fr) minmax(90px,120px) minmax(170px,1.8fr) auto;gap:8px;align-items:center;margin-bottom:8px;}#${PANEL_ID} .ldt-token-row:last-child{margin-bottom:0;}#${PANEL_ID} .ldt-token-row label{margin:0;color:var(--primary-high);font-size:12px;}#${PANEL_ID} .ldt-token-row input[type="color"]{width:100%;height:32px;margin:0;padding:0;border-radius:8px;}#${PANEL_ID} .ldt-token-row input[type="text"],#${PANEL_ID} textarea,#${PANEL_ID} input[type="range"]{width:100%;margin:0;}#${PANEL_ID} .ldt-token-row input[type="text"]{height:32px;}#${PANEL_ID} .ldt-checkbox{display:flex;align-items:center;gap:8px;}#${PANEL_ID} .ldt-inline{display:flex;align-items:center;gap:8px;}#${PANEL_ID} .ldt-inline input[type="range"]{max-width:240px;}#${PANEL_ID} .ldt-value{min-width:48px;text-align:right;font-size:12px;color:var(--primary-medium);}#${PANEL_ID} textarea{min-height:180px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}#${PANEL_ID} .ldt-footer{position:sticky;bottom:0;border-top:1px solid var(--content-border-color);background:var(--secondary);padding:10px 16px;}#${PANEL_ID} .ldt-status{margin:0;font-size:12px;color:var(--primary-medium);min-height:1em;}#${PANEL_ID} .ldt-status.--error{color:var(--danger,#e5484d);}#${HEADER_ENTRY_ID} .linuxdo-theme-header-btn{border:0;background:transparent;}#${HEADER_ENTRY_ID} .linuxdo-theme-header-btn .d-icon{width:1em;height:1em;}@media(max-width:820px){#${PANEL_ID}{right:8px;left:8px;top:60px;width:auto;max-height:calc(100vh - 72px);}#${PANEL_ID} .ldt-token-row{grid-template-columns:1fr;}}
`;
function safeClone(value) {
if (typeof globalThis.structuredClone === "function") {
try {
return globalThis.structuredClone(value);
} catch {
// fallback below
}
}
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
const clone = (v) => safeClone(v);
const nowISO = () => new Date().toISOString();
const clamp = (n, min, max) => (Number.isNaN(n) ? min : Math.max(min, Math.min(max, n)));
const makePresetThemeRef = (presetId) => `preset:${presetId}`;
const makeLibraryThemeRef = (themeId) => `library:${themeId}`;
function safeJsonParse(text, fallback = null) {
if (typeof text !== "string") return fallback;
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
function safeStorageGetItem(key) {
try {
return localStorage.getItem(key);
} catch (error) {
console.warn("[linuxdo-theme] failed to read from localStorage", error);
return null;
}
}
function runSafely(label, fn) {
try {
return fn();
} catch (error) {
console.error(`[linuxdo-theme] ${label} failed`, error);
return undefined;
}
}
function clearStateTimer(timerKey) {
const timerId = state[timerKey];
if (!timerId) return;
clearTimeout(timerId);
state[timerKey] = null;
}
function createDefaultConfig() {
return {
schemaVersion: 1,
activePresetId: null,
activeThemeRef: null,
themeLibrary: [],
customTheme: { name: "我的主题", basePreset: null, tokens: {}, patches: clone(DEFAULT_PATCHES) },
settings: clone(DEFAULT_SETTINGS),
lastUpdatedAt: nowISO(),
};
}
function isFrontendRoute() {
return !/^\/admin(?:\/|$)/.test(location.pathname);
}
function getDiscourseServiceSchemeType() {
const service = state.interfaceColor;
if (!service) return null;
if (service.colorModeIsDark === true || service.darkModeForced === true) return "dark";
if (service.colorModeIsLight === true || service.lightModeForced === true) return "light";
const mode = typeof service.colorMode === "string" ? service.colorMode.trim().toLowerCase() : "";
return mode === "dark" || mode === "light" ? mode : null;
}
function getSchemeType() {
const serviceSchemeType = getDiscourseServiceSchemeType();
if (serviceSchemeType) return serviceSchemeType;
let type = "";
try {
type = getComputedStyle(document.documentElement).getPropertyValue("--scheme-type").trim().toLowerCase();
} catch {
type = "";
}
if (type === "dark" || type === "light") return type;
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function getPresetSchemeTypeByPresetId(presetId) {
if (typeof presetId !== "string") return null;
if (presetId.endsWith("-dark")) return "dark";
if (presetId.endsWith("-light")) return "light";
return null;
}
function getMatchedPresetIdByScheme(presetId, targetSchemeType) {
if (!isPresetId(presetId)) return null;
if (targetSchemeType !== "dark" && targetSchemeType !== "light") return null;
const suffixMatch = presetId.match(/-(dark|light)$/);
if (!suffixMatch) return null;
if (suffixMatch[1] === targetSchemeType) return presetId;
const baseId = presetId.slice(0, -suffixMatch[0].length);
const candidate = `${baseId}-${targetSchemeType}`;
return isPresetId(candidate) ? candidate : null;
}
function syncDiscourseColorMode(mode) {
if (mode !== "dark" && mode !== "light") return;
if (state.lastForcedForumMode === mode) return;
const service = state.interfaceColor;
if (!service) return;
try {
if (mode === "dark" && typeof service.forceDarkMode === "function") {
service.forceDarkMode({ flipStylesheets: true });
} else if (mode === "light" && typeof service.forceLightMode === "function") {
service.forceLightMode({ flipStylesheets: true });
} else {
return;
}
state.lastForcedForumMode = mode;
} catch (error) {
if (!state.discourseBindWarned) {
state.discourseBindWarned = true;
console.warn("[linuxdo-theme] Failed to sync interface color mode via service:interface-color", error);
}
}
}
const getDefaultPresetId = () => (getSchemeType() === "dark" ? "claude-dark" : "claude-light");
const isPresetId = (id) => Object.prototype.hasOwnProperty.call(PRESETS, id);
const isThemeId = (id) => typeof id === "string" && /^[a-z0-9][a-z0-9-]{1,63}$/.test(id);
const normalizeThemeName = (name, fallback = "我的主题") => {
if (typeof name !== "string") return fallback;
const trimmed = name.trim().slice(0, 80);
return trimmed || fallback;
};
function parseThemeRef(ref) {
if (typeof ref !== "string") return null;
if (ref.startsWith("preset:")) {
const presetId = ref.slice(7);
if (!isPresetId(presetId)) return null;
return { kind: "preset", id: presetId };
}
if (ref.startsWith("library:")) {
const themeId = ref.slice(8);
if (!isThemeId(themeId)) return null;
return { kind: "library", id: themeId };
}
return null;
}
function isSafeCssValue(value, maxLen = 180) {
return typeof value === "string" && value.length > 0 && value.length <= maxLen && !/[{};<>\n\r]/.test(value);
}
function isColorValue(value) {
if (!isSafeCssValue(value, 120)) return false;
const trimmedValue = value.trim();
return (
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(trimmedValue) ||
/^(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\(\s*[^)]+\)$/.test(trimmedValue) ||
/^var\(--[a-zA-Z0-9_-]+\)$/.test(trimmedValue) ||
/^light-dark\(\s*[^)]+\)$/.test(trimmedValue)
);
}
function normalizeTokenValue(token, rawValue) {
if (typeof rawValue !== "string") return null;
const value = rawValue.trim();
if (!value || !isSafeCssValue(value)) return null;
const meta = TOKEN_META[token];
if (!meta) return null;
if (meta.type === "color" && !isColorValue(value)) return null;
if (token === "--d-button-default-border" && !/^(none|[0-9.]+px\s+(solid|dashed|dotted)\s+.+)$/i.test(value)) return null;
if ((token === "--d-border-radius" || token === "--d-border-radius-large") && !/^(0|[0-9.]+px)$/.test(value)) return null;
if ((token === "--shadow-card" || token === "--shadow-dropdown") && value.length > 120) return null;
return value;
}
function normalizeTokens(rawTokens) {
const out = {};
if (!rawTokens || typeof rawTokens !== "object") return out;
TOKEN_KEYS.forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(rawTokens, key)) return;
const value = normalizeTokenValue(key, rawTokens[key]);
if (value !== null) out[key] = value;
});
Object.keys(TOKEN_ALIASES).forEach((aliasKey) => {
if (!Object.prototype.hasOwnProperty.call(rawTokens, aliasKey)) return;
const targetKey = TOKEN_ALIASES[aliasKey];
if (Object.prototype.hasOwnProperty.call(out, targetKey)) return;
const value = normalizeTokenValue(targetKey, rawTokens[aliasKey]);
if (value !== null) out[targetKey] = value;
});
return syncLinkedBackgroundToken(out);
}
function syncLinkedBackgroundToken(tokens) {
if (!tokens || typeof tokens !== "object") return tokens;
const backgroundValue = normalizeTokenValue("--background-color", tokens["--background-color"]);
if (backgroundValue !== null) tokens[LINKED_BACKGROUND_TOKEN] = backgroundValue;
else delete tokens[LINKED_BACKGROUND_TOKEN];
return tokens;
}
function normalizePatches(raw, fallback = DEFAULT_PATCHES) {
const base = {
headerGlass: !!fallback.headerGlass,
topicCardElevation: !!fallback.topicCardElevation,
radiusScale: clamp(parseInt(fallback.radiusScale, 10), 60, 160),
shadowIntensity: clamp(parseInt(fallback.shadowIntensity, 10), 0, 200),
};
if (!raw || typeof raw !== "object") return base;
if (typeof raw.headerGlass === "boolean") base.headerGlass = raw.headerGlass;
if (typeof raw.topicCardElevation === "boolean") base.topicCardElevation = raw.topicCardElevation;
if (raw.radiusScale !== undefined) base.radiusScale = clamp(parseInt(raw.radiusScale, 10), 60, 160);
if (raw.shadowIntensity !== undefined) base.shadowIntensity = clamp(parseInt(raw.shadowIntensity, 10), 0, 200);
return base;
}
function normalizeSettings(raw, fallback = DEFAULT_SETTINGS) {
const base = {
enableFloatingButton: !!fallback?.enableFloatingButton,
openTopicInNewTab: !!fallback?.openTopicInNewTab,
headerGlass: !!fallback?.headerGlass,
topicCardElevation: !!fallback?.topicCardElevation,
radiusScale: clamp(parseInt(fallback?.radiusScale, 10), 60, 160),
shadowIntensity: clamp(parseInt(fallback?.shadowIntensity, 10), 0, 200),
};
if (!raw || typeof raw !== "object") return base;
if (typeof raw.enableFloatingButton === "boolean") base.enableFloatingButton = raw.enableFloatingButton;
if (typeof raw.openTopicInNewTab === "boolean") base.openTopicInNewTab = raw.openTopicInNewTab;
if (typeof raw.headerGlass === "boolean") base.headerGlass = raw.headerGlass;
if (typeof raw.topicCardElevation === "boolean") base.topicCardElevation = raw.topicCardElevation;
if (raw.radiusScale !== undefined) base.radiusScale = clamp(parseInt(raw.radiusScale, 10), 60, 160);
if (raw.shadowIntensity !== undefined) base.shadowIntensity = clamp(parseInt(raw.shadowIntensity, 10), 0, 200);
return base;
}
function getTokenHardFallback(tokenKey) {
if (tokenKey === "--d-button-default-border") return "1px solid #999999";
if (tokenKey === "--d-border-radius") return "10px";
if (tokenKey === "--d-border-radius-large") return "16px";
if (tokenKey === "--shadow-card" || tokenKey === "--shadow-dropdown") return "none";
return "#000000";
}
function repairPresetRegistry() {
const presetIds = Object.keys(PRESETS);
if (!presetIds.length) return;
const fallbackPreset = PRESETS[presetIds[0]];
const fallbackTokens = normalizeTokens(fallbackPreset?.tokens);
presetIds.forEach((presetId) => {
const preset = PRESETS[presetId];
if (!preset || typeof preset !== "object") return;
preset.id = presetId;
if (typeof preset.name !== "string" || !preset.name.trim()) preset.name = presetId;
if (typeof preset.description !== "string") preset.description = "";
if (typeof preset.patchProfile !== "string" || !preset.patchProfile.trim()) preset.patchProfile = "custom";
const normalizedTokens = normalizeTokens({ ...fallbackTokens, ...(preset.tokens || {}) });
const repairedTokens = {};
TOKEN_KEYS.forEach((tokenKey) => {
repairedTokens[tokenKey] = normalizedTokens[tokenKey] || fallbackTokens[tokenKey] || getTokenHardFallback(tokenKey);
});
syncLinkedBackgroundToken(repairedTokens);
preset.tokens = repairedTokens;
preset.patchDefaults = normalizePatches(preset.patchDefaults, DEFAULT_PATCHES);
});
}
function getThemeEntryByIdFromConfig(config, themeId) {
if (!config || !Array.isArray(config.themeLibrary)) return null;
return config.themeLibrary.find((entry) => entry && entry.id === themeId) || null;
}
function upsertThemeEntryInConfig(config, entry) {
if (!config || !entry) return;
if (!Array.isArray(config.themeLibrary)) config.themeLibrary = [];
const index = config.themeLibrary.findIndex((item) => item && item.id === entry.id);
if (index >= 0) config.themeLibrary[index] = entry;
else config.themeLibrary.push(entry);
}
function normalizeThemeEntry(rawEntry, fallbackPresetId, fallbackSettings, fallbackId) {
if (!rawEntry || typeof rawEntry !== "object") return null;
const basePreset = isPresetId(rawEntry.basePreset) ? rawEntry.basePreset : fallbackPresetId;
if (!isPresetId(basePreset)) return null;
const preset = PRESETS[basePreset];
const source = rawEntry.source === LIBRARY_SOURCE_CUSTOM ? LIBRARY_SOURCE_CUSTOM : LIBRARY_SOURCE_IMPORTED;
const fallbackName = source === LIBRARY_SOURCE_CUSTOM ? "我的主题" : "导入主题";
const id = isThemeId(rawEntry.id) ? rawEntry.id : fallbackId;
if (!isThemeId(id)) return null;
return {
id,
name: normalizeThemeName(rawEntry.name, fallbackName),
source,
basePreset,
tokens: normalizeTokens(rawEntry.tokens),
patches: normalizePatches(rawEntry.patches, preset.patchDefaults || DEFAULT_PATCHES),
settings: normalizeSettings(rawEntry.settings, fallbackSettings),
createdAt: typeof rawEntry.createdAt === "string" ? rawEntry.createdAt : nowISO(),
updatedAt: typeof rawEntry.updatedAt === "string" ? rawEntry.updatedAt : nowISO(),
};
}
function syncCustomThemeToConfig(config) {
if (!config || typeof config !== "object") return null;
if (!Array.isArray(config.themeLibrary)) config.themeLibrary = [];
const fallbackPresetId = isPresetId(config.customTheme?.basePreset)
? config.customTheme.basePreset
: isPresetId(config.activePresetId)
? config.activePresetId
: getDefaultPresetId();
const preset = PRESETS[fallbackPresetId];
config.customTheme = {
name: "我的主题",
basePreset: fallbackPresetId,
tokens: normalizeTokens(config.customTheme?.tokens),
patches: normalizePatches(config.customTheme?.patches, preset.patchDefaults || DEFAULT_PATCHES),
};
const existing = getThemeEntryByIdFromConfig(config, CUSTOM_THEME_ID);
const customEntry = normalizeThemeEntry(
{
id: CUSTOM_THEME_ID,
source: LIBRARY_SOURCE_CUSTOM,
name: config.customTheme.name,
basePreset: config.customTheme.basePreset,
tokens: config.customTheme.tokens,
patches: config.customTheme.patches,
settings: config.settings,
createdAt: existing?.createdAt || nowISO(),
updatedAt: nowISO(),
},
fallbackPresetId,
config.settings,
CUSTOM_THEME_ID
);
upsertThemeEntryInConfig(config, customEntry);
return customEntry;
}
function resolveActiveThemeRef(config, rawRef, fallbackPresetId) {
const parsed = parseThemeRef(rawRef);
if (parsed?.kind === "preset") return makePresetThemeRef(parsed.id);
if (parsed?.kind === "library" && getThemeEntryByIdFromConfig(config, parsed.id)) return makeLibraryThemeRef(parsed.id);
return makePresetThemeRef(fallbackPresetId);
}
function normalizeConfig(rawConfig) {
const base = createDefaultConfig();
const raw = rawConfig && typeof rawConfig === "object" ? rawConfig : createDefaultConfig();
const fallbackPresetId = isPresetId(raw.activePresetId) ? raw.activePresetId : getDefaultPresetId();
base.activePresetId = fallbackPresetId;
base.settings = normalizeSettings(raw.settings, DEFAULT_SETTINGS);
if (raw.customTheme && typeof raw.customTheme === "object") {
base.customTheme.name = normalizeThemeName(raw.customTheme.name, "我的主题");
if (isPresetId(raw.customTheme.basePreset)) base.customTheme.basePreset = raw.customTheme.basePreset;
base.customTheme.tokens = normalizeTokens(raw.customTheme.tokens);
const customPreset = PRESETS[isPresetId(base.customTheme.basePreset) ? base.customTheme.basePreset : fallbackPresetId];
base.customTheme.patches = normalizePatches(raw.customTheme.patches, customPreset.patchDefaults || DEFAULT_PATCHES);
} else {
const preset = PRESETS[fallbackPresetId];
base.customTheme.patches = normalizePatches(null, preset.patchDefaults || DEFAULT_PATCHES);
}
if (Array.isArray(raw.themeLibrary)) {
const seen = new Set();
base.themeLibrary = raw.themeLibrary
.map((entry, index) =>
normalizeThemeEntry(entry, fallbackPresetId, base.settings, `imported-${String(index + 1).padStart(3, "0")}`)
)
.filter((entry) => {
if (!entry || seen.has(entry.id)) return false;
seen.add(entry.id);
return true;
});
}
syncCustomThemeToConfig(base);
base.activeThemeRef = resolveActiveThemeRef(base, raw.activeThemeRef, fallbackPresetId);
if (!raw.activeThemeRef && isPresetId(raw.activePresetId)) base.activeThemeRef = makePresetThemeRef(raw.activePresetId);
const activeRef = parseThemeRef(base.activeThemeRef);
if (activeRef?.kind === "preset") {
base.activePresetId = activeRef.id;
} else if (activeRef?.kind === "library") {
const activeEntry = getThemeEntryByIdFromConfig(base, activeRef.id);
if (activeEntry) base.activePresetId = activeEntry.basePreset;
}
base.lastUpdatedAt = typeof raw.lastUpdatedAt === "string" ? raw.lastUpdatedAt : nowISO();
return base;
}
function migrateConfig(raw) {
if (!raw || typeof raw !== "object") return null;
if (raw.schemaVersion === 1) return normalizeConfig(raw);
if (raw.version === 1 && raw.config) return normalizeConfig({ schemaVersion: 1, ...raw.config });
return null;
}
function loadConfig() {
const fallback = normalizeConfig(createDefaultConfig());
const raw = safeStorageGetItem(STORAGE_KEY);
if (!raw) return fallback;
const parsed = safeJsonParse(raw, null);
if (!parsed) return fallback;
return migrateConfig(parsed) || fallback;
}
function saveConfig() {
if (!state.config || typeof state.config !== "object") {
setStatus("配置状态异常,已跳过写入。", true);
return;
}
try {
state.config.lastUpdatedAt = nowISO();
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.config));
} catch (error) {
console.error("[linuxdo-theme] failed to persist config", error);
setStatus("配置写入失败,请检查浏览器存储配额。", true);
}
}
function getThemeEntryById(themeId) {
return getThemeEntryByIdFromConfig(state.config, themeId);
}
function upsertThemeEntry(entry) {
upsertThemeEntryInConfig(state.config, entry);
}
function syncCustomThemeToLibrary() {
const entry = syncCustomThemeToConfig(state.config);
if (!entry) return;
entry.updatedAt = nowISO();
upsertThemeEntry(entry);
}
function syncSettingsToActiveTheme() {
state.config.settings = normalizeSettings(state.config.settings, DEFAULT_SETTINGS);
}
function resolveActiveThemeSelection(config = state.config) {
const fallbackPresetId = isPresetId(config?.activePresetId) ? config.activePresetId : getDefaultPresetId();
const parsed = parseThemeRef(config?.activeThemeRef);
if (parsed?.kind === "library") {
const entry = getThemeEntryByIdFromConfig(config, parsed.id);
if (entry) return { kind: "library", themeRef: makeLibraryThemeRef(entry.id), presetId: entry.basePreset, entry };
}
if (parsed?.kind === "preset") return { kind: "preset", themeRef: makePresetThemeRef(parsed.id), presetId: parsed.id, preset: PRESETS[parsed.id] };
return { kind: "preset", themeRef: makePresetThemeRef(fallbackPresetId), presetId: fallbackPresetId, preset: PRESETS[fallbackPresetId] };
}
const getActivePresetId = () => resolveActiveThemeSelection().presetId;
function getEffectiveTheme() {
const selection = resolveActiveThemeSelection();
const presetId = selection.presetId;
const preset = PRESETS[presetId];
const tokens = syncLinkedBackgroundToken(
selection.kind === "library" ? { ...preset.tokens, ...selection.entry.tokens } : { ...preset.tokens }
);
const settings = normalizeSettings(state.config.settings, DEFAULT_SETTINGS);
const patches = normalizePatches(settings, DEFAULT_PATCHES);
return {
presetId,
preset,
tokens,
patches,
settings,
patchProfile: preset.patchProfile,
source: selection.kind,
themeRef: selection.themeRef,
themeId: selection.kind === "library" ? selection.entry.id : null,
};
}
function getEditableCustomTheme() {
const presetId = getCustomThemePresetId();
const preset = PRESETS[presetId];
return {
presetId,
preset,
tokens: syncLinkedBackgroundToken({ ...preset.tokens, ...state.config.customTheme.tokens }),
patches: normalizePatches(state.config.customTheme.patches, preset.patchDefaults || DEFAULT_PATCHES),
};
}
function ensureStyles() {
if (!state.uiStyle) {
state.uiStyle = document.getElementById(STYLE_UI_ID) || document.createElement("style");
state.uiStyle.id = STYLE_UI_ID;
if (!state.uiStyle.parentNode) (document.head || document.documentElement).appendChild(state.uiStyle);
if (state.uiStyle.textContent !== UI_CSS) state.uiStyle.textContent = UI_CSS;
}
if (!state.varsStyle) {
state.varsStyle = document.getElementById(STYLE_VARS_ID) || document.createElement("style");
state.varsStyle.id = STYLE_VARS_ID;
if (!state.varsStyle.parentNode) (document.head || document.documentElement).appendChild(state.varsStyle);
}
if (!state.patchStyle) {
state.patchStyle = document.getElementById(STYLE_PATCH_ID) || document.createElement("style");
state.patchStyle.id = STYLE_PATCH_ID;
if (!state.patchStyle.parentNode) (document.head || document.documentElement).appendChild(state.patchStyle);
}
}
function colorToRgbTuple(value) {
if (typeof value !== "string") return null;
const input = value.trim();
if (!input) return null;
if (/^#[0-9a-fA-F]{3}$/.test(input)) {
const h = input.slice(1);
const r = parseInt(`${h[0]}${h[0]}`, 16);
const g = parseInt(`${h[1]}${h[1]}`, 16);
const b = parseInt(`${h[2]}${h[2]}`, 16);
return `${r}, ${g}, ${b}`;
}
if (/^#[0-9a-fA-F]{6}$/.test(input) || /^#[0-9a-fA-F]{8}$/.test(input)) {
const hex = input.slice(1, 7);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `${r}, ${g}, ${b}`;
}
const match = input.match(/^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})/i);
if (!match) return null;
const r = clamp(parseInt(match[1], 10), 0, 255);
const g = clamp(parseInt(match[2], 10), 0, 255);
const b = clamp(parseInt(match[3], 10), 0, 255);
return `${r}, ${g}, ${b}`;
}
function buildVarsCSS(theme) {
const resolvedTokens = syncLinkedBackgroundToken({ ...theme.tokens });
const lines = TOKEN_KEYS.map((k) => ` ${k}: ${resolvedTokens[k]} !important;`);
lines.push(" --tertiary-med-or-tertiary: var(--tertiary) !important;");
const dLinkColor = resolvedTokens["--link-color"] || resolvedTokens["--tertiary"] || "var(--tertiary)";
lines.push(` --d-link-color: ${dLinkColor} !important;`);
[
["--primary", "--primary-rgb"],
["--primary-low", "--primary-low-rgb"],
["--primary-very-low", "--primary-very-low-rgb"],
["--secondary", "--secondary-rgb"],
["--header_background", "--header_background-rgb"],
["--tertiary", "--tertiary-rgb"],
].forEach(([source, target]) => {
const rgb = colorToRgbTuple(resolvedTokens[source]);
if (rgb) lines.push(` ${target}: ${rgb} !important;`);
});
const sidebarBackground = resolvedTokens["--background-color"] || "var(--secondary)";
lines.push(` --linuxdo-theme-radius-scale: ${(theme.patches.radiusScale / 100).toFixed(2)} !important;`);
lines.push(` --linuxdo-theme-shadow-scale: ${(theme.patches.shadowIntensity / 100).toFixed(2)} !important;`);
lines.push(` --d-sidebar-background: ${sidebarBackground} !important;`);
lines.push(` --d-sidebar-footer-fade: ${sidebarBackground} !important;`);
return `html[data-linuxdo-theme-active="1"] {\n${lines.join("\n")}\n}\n`;
}
function buildProfilePatchCSS(profile, shadowScale) {
if (profile === "claude") return `html[data-linuxdo-theme-active="1"][data-linuxdo-theme-profile="claude"] body:not(.admin-interface){background-image:radial-gradient(circle at 80% -10%,rgba(255,190,120,.08),transparent 40%);}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-profile="claude"] .btn-primary{letter-spacing:.01em;}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-profile="claude"] .topic-list .topic-list-item{border-color:color-mix(in srgb,var(--content-border-color) 92%,var(--tertiary));}`;
if (profile === "trae") {
const glow = (0.18 * shadowScale).toFixed(3);
return `html[data-linuxdo-theme-active="1"][data-linuxdo-theme-profile="trae"] body:not(.admin-interface){background-image:radial-gradient(circle at 20% -10%,rgba(46,201,211,.08),transparent 45%);}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-profile="trae"] .btn-primary{box-shadow:0 0 0 1px rgba(46,201,211,${glow}) inset;}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-profile="trae"] .topic-list .topic-list-item{border-color:color-mix(in srgb,var(--content-border-color) 78%,var(--tertiary));}`;
}
if (profile === "linear") return `html[data-linuxdo-theme-active="1"][data-linuxdo-theme-profile="linear"] .d-header{border-bottom:1px solid var(--content-border-color);}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-profile="linear"] .topic-list .topic-list-item{border-radius:calc(var(--linuxdo-theme-radius) * .88);}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-profile="linear"] .btn-primary{font-weight:500;}`;
return "";
}
function buildPatchCSS(theme) {
// v1.2.5 visual consistency patches + dark-mode readability fixes.
const shadowScale = theme.patches.shadowIntensity / 100;
const subtleAmbientAlpha = (0.032 * Math.max(0.7, shadowScale)).toFixed(3);
const subtleLiftAlpha = (0.055 * Math.max(0.7, shadowScale)).toFixed(3);
const subtleHoverAmbientAlpha = (0.048 * Math.max(0.7, shadowScale)).toFixed(3);
const subtleHoverLiftAlpha = (0.082 * Math.max(0.7, shadowScale)).toFixed(3);
const subtleSelectedAmbientAlpha = (0.053 * Math.max(0.7, shadowScale)).toFixed(3);
const subtleSelectedLiftAlpha = (0.086 * Math.max(0.7, shadowScale)).toFixed(3);
const elevatedAmbientAlpha = (0.052 * Math.max(0.7, shadowScale)).toFixed(3);
const elevatedLiftAlpha = (0.084 * Math.max(0.7, shadowScale)).toFixed(3);
const elevatedHoverAmbientAlpha = (0.074 * Math.max(0.7, shadowScale)).toFixed(3);
const elevatedHoverLiftAlpha = (0.114 * Math.max(0.7, shadowScale)).toFixed(3);
const elevatedSelectedAmbientAlpha = (0.068 * Math.max(0.7, shadowScale)).toFixed(3);
const elevatedSelectedLiftAlpha = (0.106 * Math.max(0.7, shadowScale)).toFixed(3);
const dropdownAlpha = (0.28 * shadowScale).toFixed(3);
const glassColor = "color-mix(in srgb, var(--header_background) 86%, transparent)";
const topicSurfaceBase = "color-mix(in srgb,var(--d-content-background) 97%,var(--tertiary) 3%)";
const topicSurfaceHover = "color-mix(in srgb,var(--d-content-background) 93%,var(--tertiary) 7%)";
const topicEdgeBase = "color-mix(in srgb,var(--content-border-color) 84%,transparent)";
const topicEdgeHover = "color-mix(in srgb,var(--content-border-color) 62%,var(--tertiary) 38%)";
const headerGlassCss = theme.patches.headerGlass
? `html[data-linuxdo-theme-active="1"] .d-header{backdrop-filter:saturate(1.25) blur(10px);background:${glassColor}!important;}`
: `html[data-linuxdo-theme-active="1"] .d-header{backdrop-filter:none;background:var(--header_background)!important;}`;
const topicElevationCss = theme.patches.topicCardElevation
? `html[data-linuxdo-theme-active="1"] .topic-list .topic-list-item{background:var(--d-content-background);background-image:linear-gradient(180deg,${topicSurfaceBase},var(--d-content-background));border-color:${topicEdgeBase};box-shadow:0 4px 10px -6px rgba(0,0,0,${elevatedAmbientAlpha}),0 12px 26px -18px rgba(0,0,0,${elevatedLiftAlpha}),var(--shadow-card);transition:box-shadow .24s cubic-bezier(.22,.61,.36,1),border-color .2s ease,background-color .2s ease,background-image .2s ease;}html[data-linuxdo-theme-active="1"] .topic-list .topic-list-item:hover{background-image:linear-gradient(180deg,${topicSurfaceHover},var(--d-content-background));border-color:${topicEdgeHover};box-shadow:0 8px 16px -8px rgba(0,0,0,${elevatedHoverAmbientAlpha}),0 18px 34px -22px rgba(0,0,0,${elevatedHoverLiftAlpha}),var(--shadow-card);}html[data-linuxdo-theme-active="1"] .topic-list .topic-list-item.selected{border-color:${topicEdgeBase};box-shadow:0 7px 16px -8px rgba(0,0,0,${elevatedSelectedAmbientAlpha}),0 17px 32px -22px rgba(0,0,0,${elevatedSelectedLiftAlpha}),var(--shadow-card);}html[data-linuxdo-theme-active="1"] .topic-list .topic-list-item:focus-within{border-color:${topicEdgeBase};box-shadow:0 10px 22px -14px rgba(0,0,0,${elevatedHoverLiftAlpha}),var(--shadow-card);}`
: `html[data-linuxdo-theme-active="1"] .topic-list .topic-list-item{background:var(--d-content-background);background-image:linear-gradient(180deg,${topicSurfaceBase},var(--d-content-background));border-color:${topicEdgeBase};box-shadow:0 2px 6px -3px rgba(0,0,0,${subtleAmbientAlpha}),0 10px 22px -18px rgba(0,0,0,${subtleLiftAlpha});transition:box-shadow .24s cubic-bezier(.22,.61,.36,1),border-color .2s ease,background-color .2s ease,background-image .2s ease;}html[data-linuxdo-theme-active="1"] .topic-list .topic-list-item:hover{background-image:linear-gradient(180deg,${topicSurfaceHover},var(--d-content-background));border-color:${topicEdgeHover};box-shadow:0 4px 10px -5px rgba(0,0,0,${subtleHoverAmbientAlpha}),0 14px 28px -18px rgba(0,0,0,${subtleHoverLiftAlpha});}html[data-linuxdo-theme-active="1"] .topic-list .topic-list-item.selected{border-color:${topicEdgeBase};box-shadow:0 5px 11px -6px rgba(0,0,0,${subtleSelectedAmbientAlpha}),0 14px 26px -18px rgba(0,0,0,${subtleSelectedLiftAlpha});}html[data-linuxdo-theme-active="1"] .topic-list .topic-list-item:focus-within{border-color:${topicEdgeBase};box-shadow:0 8px 18px -12px rgba(0,0,0,${subtleHoverLiftAlpha});}`;
const darkSummaryCss = `html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .topic-map__stats .number,html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .topic-map .view-explainer,html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .topic-map .topic-map__stat-label{color:var(--primary-high)!important;}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .topic-map__views-content canvas{filter:contrast(1.12) saturate(1.08) brightness(1.04);}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .top-categories-section table{max-width:100%;}`;
const darkReadableTextCss = `html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked blockquote,html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked aside.quote,html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked aside,html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .topic-post .post-notice{color:var(--primary)!important;}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked aside.quote .title,html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .topic-post .post-notice .post-notice-label{color:var(--primary-high)!important;}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .topic-post .post-notice a,html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked blockquote a,html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked aside a{color:var(--link-color)!important;}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked details > summary,html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked .discourse-details > summary{color:var(--primary-high)!important;}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked details[open],html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked details[open] *{color:var(--primary)!important;}html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked .spoiler:not(.spoiler-blurred),html[data-linuxdo-theme-active="1"][data-linuxdo-theme-scheme="dark"] .cooked .spoiler:not(.spoiler-blurred) *{color:var(--primary)!important;}`;
return `html[data-linuxdo-theme-active="1"]{--linuxdo-theme-radius:calc(var(--d-border-radius) * var(--linuxdo-theme-radius-scale));--linuxdo-theme-radius-large:calc(var(--d-border-radius-large) * var(--linuxdo-theme-radius-scale));}
html[data-linuxdo-theme-active="1"] body:not(.admin-interface),html[data-linuxdo-theme-active="1"] #main-outlet-wrapper{background-color:var(--background-color)!important;}
html[data-linuxdo-theme-active="1"] #main-outlet{border-radius:var(--linuxdo-theme-radius-large)!important;background:var(--d-content-background)!important;}
html[data-linuxdo-theme-active="1"] .topic-list .topic-list-item,html[data-linuxdo-theme-active="1"] .topic-list-header,html[data-linuxdo-theme-active="1"] .btn,html[data-linuxdo-theme-active="1"] .select-kit .select-kit-header,html[data-linuxdo-theme-active="1"] .d-menu{border-radius:var(--linuxdo-theme-radius)!important;}
html[data-linuxdo-theme-active="1"] .d-menu,html[data-linuxdo-theme-active="1"] .dropdown-menu{box-shadow:0 12px 30px rgba(0,0,0,${dropdownAlpha})!important;}
html[data-linuxdo-theme-active="1"] .sidebar-wrapper,html[data-linuxdo-theme-active="1"] .sidebar-hamburger-dropdown,html[data-linuxdo-theme-active="1"] .sidebar-footer-wrapper{background-color:var(--d-sidebar-background)!important;}
html[data-linuxdo-theme-active="1"] .sidebar-footer-wrapper .sidebar-footer-container{background-color:var(--d-sidebar-background)!important;}
html[data-linuxdo-theme-active="1"] .sidebar-footer-wrapper .sidebar-footer-container::before,html[data-linuxdo-theme-active="1"] .menu-panel .sidebar-footer-wrapper .sidebar-footer-container::before{background:linear-gradient(to bottom,transparent,var(--d-sidebar-footer-fade))!important;}
html[data-linuxdo-theme-active="1"] .sidebar__panel-switch-button{background:var(--d-content-background)!important;border:1px solid var(--content-border-color)!important;}
html[data-linuxdo-theme-active="1"] .sidebar__panel-switch-button:hover{background:var(--d-hover)!important;border-color:color-mix(in srgb,var(--content-border-color) 68%,var(--tertiary) 32%)!important;}
html[data-linuxdo-theme-active="1"] #d-splash{--dot-color:var(--tertiary)!important;}
html[data-linuxdo-theme-active="1"] #d-splash .dots{background-color:var(--dot-color)!important;}
${headerGlassCss}
${topicElevationCss}
${darkSummaryCss}
${darkReadableTextCss}
${buildProfilePatchCSS(theme.patchProfile, shadowScale)}`;
}
function buildThemeApplySignature(theme, schemeType) {
const tokenSignature = TOKEN_KEYS.map((token) => theme.tokens[token] || "").join("\u001f");
return [
theme.themeRef || "",
theme.presetId || "",
theme.patchProfile || "",
schemeType || "",
theme.patches.headerGlass ? "1" : "0",
theme.patches.topicCardElevation ? "1" : "0",
String(theme.patches.radiusScale),
String(theme.patches.shadowIntensity),
tokenSignature,
].join("\u001e");
}
function applyTheme() {
ensureStyles();
if (!isFrontendRoute()) {
deactivateTheme();
return;
}
runSafely("apply theme", () => {
const theme = getEffectiveTheme();
const expectedSchemeType = getPresetSchemeTypeByPresetId(theme.presetId);
if (expectedSchemeType) syncDiscourseColorMode(expectedSchemeType);
const resolvedSchemeType = expectedSchemeType || getSchemeType();
document.documentElement.setAttribute("data-linuxdo-theme-active", "1");
document.documentElement.setAttribute("data-linuxdo-theme-preset", theme.presetId);
document.documentElement.setAttribute("data-linuxdo-theme-profile", theme.patchProfile);
document.documentElement.setAttribute("data-linuxdo-theme-scheme", resolvedSchemeType);
const nextSignature = buildThemeApplySignature(theme, resolvedSchemeType);
const styleIsInSync =
state.varsStyle?.textContent === state.lastVarsCSS && state.patchStyle?.textContent === state.lastPatchCSS;
if (state.lastAppliedThemeSignature === nextSignature && styleIsInSync) return;
const nextVarsCSS = buildVarsCSS(theme);
const nextPatchCSS = buildPatchCSS(theme);
if (state.lastVarsCSS !== nextVarsCSS) {
state.varsStyle.textContent = nextVarsCSS;
state.lastVarsCSS = nextVarsCSS;
}
if (state.lastPatchCSS !== nextPatchCSS) {
state.patchStyle.textContent = nextPatchCSS;
state.lastPatchCSS = nextPatchCSS;
}
state.lastAppliedThemeSignature = nextSignature;
});
}
function deactivateTheme() {
document.documentElement.removeAttribute("data-linuxdo-theme-active");
document.documentElement.removeAttribute("data-linuxdo-theme-preset");
document.documentElement.removeAttribute("data-linuxdo-theme-profile");
document.documentElement.removeAttribute("data-linuxdo-theme-scheme");
if (state.varsStyle && state.lastVarsCSS) state.varsStyle.textContent = "";
if (state.patchStyle && state.lastPatchCSS) state.patchStyle.textContent = "";
state.lastVarsCSS = "";
state.lastPatchCSS = "";
state.lastAppliedThemeSignature = "";
}
function debounceApply() {
clearStateTimer("updateTimer");
state.updateTimer = setTimeout(() => {
state.updateTimer = null;
runSafely("debounced apply", () => {
saveConfig();
applyTheme();
refreshDynamicCustomInputs();
refreshDynamicSettingsInputs();
});
}, UI_DEBOUNCE_MS);
}
function setStatus(message, isError = false) {
if (!state.panelStatusEl) return;
state.panelStatusEl.textContent = message;
state.panelStatusEl.classList.toggle("--error", isError);
}
function removePanelDom() {
clearStateTimer("updateTimer");
clearStateTimer("syncEntryTimer");
if (state.panel) {
state.panel.remove();
state.panel = null;
state.panelStatusEl = null;
}
if (state.overlay) {
state.overlay.remove();
state.overlay = null;
}
state.isPanelOpen = false;
}
function closePanel() {
if (!state.panel || !state.overlay || !state.isPanelOpen) return;
state.panel.hidden = true;
state.overlay.hidden = true;
state.panel.setAttribute("aria-hidden", "true");
state.isPanelOpen = false;
}
function openPanel() {
if (!isFrontendRoute()) return;
ensurePanel();
renderPanel();
state.overlay.hidden = false;
state.panel.hidden = false;
state.panel.setAttribute("aria-hidden", "false");
state.isPanelOpen = true;
}
const togglePanel = () => (state.isPanelOpen ? closePanel() : openPanel());
function toColorInputValue(value) {
if (typeof value !== "string") return null;
const hex = value.trim();
if (/^#[0-9a-fA-F]{6}$/.test(hex)) return hex.toLowerCase();
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
const h = hex.replace("#", "").toLowerCase();
return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`;
}
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return `#${hex.slice(1, 7).toLowerCase()}`;
const m = hex.match(/^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})/);
if (!m) return null;
const r = clamp(parseInt(m[1], 10), 0, 255);
const g = clamp(parseInt(m[2], 10), 0, 255);
const b = clamp(parseInt(m[3], 10), 0, 255);
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
}
const getCustomThemePresetId = () => (isPresetId(state.config.customTheme.basePreset) ? state.config.customTheme.basePreset : getActivePresetId());
const getBaseTokenValue = (token) => PRESETS[getCustomThemePresetId()].tokens[token];
function syncCustomBaseWithActivePresetIfNeeded() {
const selection = resolveActiveThemeSelection();
if (selection.kind === "preset") state.config.customTheme.basePreset = selection.presetId;
}
function activateCustomTheme() {
state.config.activeThemeRef = makeLibraryThemeRef(CUSTOM_THEME_ID);
state.config.activePresetId = getCustomThemePresetId();
}
function applyLibraryTheme(themeId, options = {}) {
const { syncCustomTheme = true } = options;
const entry = getThemeEntryById(themeId);
if (!entry) return false;
state.config.activeThemeRef = makeLibraryThemeRef(entry.id);
state.config.activePresetId = entry.basePreset;
if (syncCustomTheme) {
state.config.customTheme.name = entry.name;
state.config.customTheme.basePreset = entry.basePreset;
state.config.customTheme.tokens = clone(entry.tokens);
state.config.customTheme.patches = clone(entry.patches);
state.customThemeDraftName = entry.name;
syncCustomThemeToLibrary();
}
return true;
}
function setTokenOverride(token, value) {
if (token === LINKED_BACKGROUND_TOKEN) {
setStatus("“内容容器背景”已与“全局背景”联动,请编辑“全局背景”。");
return;
}
syncCustomBaseWithActivePresetIfNeeded();
const normalized = normalizeTokenValue(token, value);
if (normalized === null) {
setStatus(`字段 ${token} 值无效,已忽略。`, true);
return;
}
const base = getBaseTokenValue(token);
if (normalized === base) delete state.config.customTheme.tokens[token];
else state.config.customTheme.tokens[token] = normalized;
state.config.customTheme.basePreset = getCustomThemePresetId();
activateCustomTheme();
syncCustomThemeToLibrary();
setStatus(`已更新 ${token}。`);
debounceApply();
}
function resetTokenOverride(token) {
if (token === LINKED_BACKGROUND_TOKEN) {
setStatus("“内容容器背景”已与“全局背景”联动,无需单独恢复。");
return;
}
syncCustomBaseWithActivePresetIfNeeded();
delete state.config.customTheme.tokens[token];
state.config.customTheme.basePreset = getCustomThemePresetId();
activateCustomTheme();
syncCustomThemeToLibrary();
saveConfig();
applyTheme();
refreshDynamicCustomInputs();
setStatus(`已恢复 ${token} 为预设值。`);
}
function resetCustomToPreset() {
const preset = PRESETS[getActivePresetId()];
state.config.customTheme.basePreset = preset.id;
state.config.customTheme.tokens = {};
state.config.customTheme.patches = normalizePatches({}, preset.patchDefaults || DEFAULT_PATCHES);
activateCustomTheme();
state.customThemeDraftName = "我的主题";
syncCustomThemeToLibrary();
saveConfig();
applyTheme();
renderCustomSection();
setStatus("已恢复为预设默认参数。");
}
function saveCustomThemeSnapshot(rawName) {
syncCustomBaseWithActivePresetIfNeeded();
const basePresetId = getCustomThemePresetId();
const preset = PRESETS[basePresetId];
const snapshotId = buildUniqueThemeId("custom");
const requestedName = normalizeThemeName(rawName, "我的主题");
const existing = new Set(
(state.config.themeLibrary || [])
.filter((item) => item && item.id !== CUSTOM_THEME_ID)
.map((item) => item.name)
.filter(Boolean)
);
let finalName = requestedName;
if (existing.has(finalName)) {
for (let i = 2; i < 999; i += 1) {
const suffix = ` (${i})`;
const sliced = requestedName.slice(0, Math.max(1, 80 - suffix.length));
const candidate = `${sliced}${suffix}`;
if (!existing.has(candidate)) {
finalName = candidate;
break;
}
}
}
const snapshot = normalizeThemeEntry(
{
id: snapshotId,
source: LIBRARY_SOURCE_CUSTOM,
name: finalName,
basePreset: basePresetId,
tokens: normalizeTokens(state.config.customTheme.tokens),
patches: normalizePatches(state.config.customTheme.patches, preset.patchDefaults || DEFAULT_PATCHES),
settings: normalizeSettings(state.config.settings),
createdAt: nowISO(),
updatedAt: nowISO(),
},
basePresetId,
state.config.settings,
snapshotId
);
if (!snapshot) return setStatus("保存失败:主题数据无效。", true);
upsertThemeEntry(snapshot);
state.customThemeDraftName = finalName;
saveConfig();
renderPanel();
switchSection("custom", false);
syncEntryPoints();
setStatus(`已保存到全部主题:${snapshot.name}`);
}
function setPreset(presetId) {
if (!isPresetId(presetId)) return;
const preset = PRESETS[presetId];
state.config.activeThemeRef = makePresetThemeRef(presetId);
state.config.activePresetId = presetId;
saveConfig();
applyTheme();
renderPanel();
syncEntryPoints();
setStatus(`已应用预设:${preset.name}。`);
}
function setThemeByRef(themeRef) {
const parsed = parseThemeRef(themeRef);
if (!parsed) return;
if (parsed.kind === "preset") {
setPreset(parsed.id);
return;
}
if (!applyLibraryTheme(parsed.id, { syncCustomTheme: true })) return;
saveConfig();
applyTheme();
renderPanel();
syncEntryPoints();
const applied = getThemeEntryById(parsed.id);
if (applied) setStatus(`已应用主题:${applied.name}。`);
}
function editThemeByRef(themeRef) {
const parsed = parseThemeRef(themeRef);
if (!parsed) return setStatus("编辑失败:主题标识无效。", true);
if (parsed.kind === "preset") {
const preset = PRESETS[parsed.id];
state.config.customTheme.basePreset = preset.id;
state.config.customTheme.tokens = {};
state.config.customTheme.patches = normalizePatches({}, preset.patchDefaults || DEFAULT_PATCHES);
state.customThemeDraftName = `${preset.name} 自定义`;
activateCustomTheme();
syncCustomThemeToLibrary();
saveConfig();
applyTheme();
renderPanel();
switchSection("custom", false);
syncEntryPoints();
return setStatus(`已进入编辑:${preset.name}。`);
}
const entry = getThemeEntryById(parsed.id);
if (!entry) return setStatus("编辑失败:主题不存在。", true);
state.config.customTheme.basePreset = entry.basePreset;
state.config.customTheme.tokens = clone(entry.tokens);
state.config.customTheme.patches = clone(entry.patches);
state.customThemeDraftName = entry.name;
activateCustomTheme();
syncCustomThemeToLibrary();
saveConfig();
applyTheme();
renderPanel();
switchSection("custom", false);
syncEntryPoints();
setStatus(`已进入编辑:${entry.name}。`);
}
function buildCustomPageModelFromThemeRef(themeRef) {
const parsed = parseThemeRef(themeRef);
if (!parsed) return null;
if (parsed.kind === "library") {
const entry = getThemeEntryById(parsed.id);
if (!entry) return null;
const preset = PRESETS[entry.basePreset];
return {
name: normalizeThemeName(entry.name, "导出主题"),
basePreset: entry.basePreset,
tokens: normalizeTokens({ ...preset.tokens, ...entry.tokens }),
};
}
const preset = PRESETS[parsed.id];
return {
name: preset.name,
basePreset: preset.id,
tokens: normalizeTokens(preset.tokens),
};
}
function buildCustomPagePayloadFromModel(model) {
if (!model || !isPresetId(model.basePreset)) return null;
const preset = PRESETS[model.basePreset];
const resolvedTokens = normalizeTokens({ ...preset.tokens, ...(model.tokens || {}) });
return {
schemaVersion: CUSTOM_IO_SCHEMA_VERSION,
kind: CUSTOM_IO_KIND,
custom: {
name: normalizeThemeName(model.name, "我的主题"),
basePreset: model.basePreset,
tokens: resolvedTokens,
},
compatibility: {
exporter: `cozydo-theme.user.js@${SCRIPT_VERSION}`,
exportedAt: nowISO(),
tokenEncoding: "resolved-full",
tokenKeys: TOKEN_KEYS,
tokenAliases: TOKEN_ALIASES,
},
};
}
function buildCurrentThemePayload() {
const selection = resolveActiveThemeSelection();
const model = buildCustomPageModelFromThemeRef(selection.themeRef);
if (!model) return null;
return buildCustomPagePayloadFromModel(model);
}
function buildCurrentThemePayloadText() {
const payload = buildCurrentThemePayload();
if (!payload) return "";
return JSON.stringify(payload, null, 2);
}
function getAllThemeRefsForCollection() {
const refs = Object.keys(PRESETS).map((presetId) => makePresetThemeRef(presetId));
if (getThemeEntryById(CUSTOM_THEME_ID)) refs.push(makeLibraryThemeRef(CUSTOM_THEME_ID));
const savedCustom = (state.config.themeLibrary || [])
.filter((entry) => entry?.id !== CUSTOM_THEME_ID && entry?.source === LIBRARY_SOURCE_CUSTOM)
.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
savedCustom.forEach((entry) => refs.push(makeLibraryThemeRef(entry.id)));
const imported = (state.config.themeLibrary || [])
.filter((entry) => entry?.id !== CUSTOM_THEME_ID && entry?.source === LIBRARY_SOURCE_IMPORTED)
.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
imported.forEach((entry) => refs.push(makeLibraryThemeRef(entry.id)));
return refs;
}
function buildThemeCollectionPayload() {
const activeRef = resolveActiveThemeSelection().themeRef;
const themes = [];
getAllThemeRefsForCollection().forEach((themeRef) => {
const parsed = parseThemeRef(themeRef);
const model = buildCustomPageModelFromThemeRef(themeRef);
if (!parsed || !model) return;
if (parsed.kind === "preset") {
themes.push({
kind: "preset",
source: "preset",
workspace: false,
selected: themeRef === activeRef,
name: model.name,
basePreset: model.basePreset,
tokens: model.tokens,
});
return;
}
const entry = getThemeEntryById(parsed.id);
if (!entry) return;
themes.push({
kind: "library",
source: entry.source === LIBRARY_SOURCE_CUSTOM ? LIBRARY_SOURCE_CUSTOM : LIBRARY_SOURCE_IMPORTED,
workspace: parsed.id === CUSTOM_THEME_ID,
selected: themeRef === activeRef,
name: model.name,
basePreset: model.basePreset,
tokens: model.tokens,
});
});
if (!themes.length) return null;
if (!themes.some((item) => item.selected)) themes[0].selected = true;
return {
schemaVersion: COLLECTION_IO_SCHEMA_VERSION,
kind: COLLECTION_IO_KIND,
collection: {
themes,
},
compatibility: {
exporter: `cozydo-theme.user.js@${SCRIPT_VERSION}`,
exportedAt: nowISO(),
tokenEncoding: "resolved-full",
tokenKeys: TOKEN_KEYS,
tokenAliases: TOKEN_ALIASES,
},
};
}
function copyTextToClipboard(text, successMessage, failureMessage = "复制失败,请手动复制。") {
if (!text) return setStatus("没有可复制内容。", true);
if (!navigator.clipboard?.writeText) {
return setStatus("当前环境不支持剪贴板 API,请到“导入导出”页手动复制。", true);
}
navigator.clipboard
.writeText(text)
.then(() => setStatus(successMessage))
.catch(() => setStatus(failureMessage, true));
}
function writePayloadToImportExportArea(payload, successMessage, switchToImportExport = true) {
const text = JSON.stringify(payload, null, 2);
state.lastExportText = text;
const textarea = getPanelTextarea();
if (textarea) textarea.value = text;
if (switchToImportExport) switchSection("import-export", false);
setStatus(successMessage);
}
function exportThemeByRef(themeRef) {
const targetRef = parseThemeRef(themeRef) ? themeRef : resolveActiveThemeSelection().themeRef;
const model = buildCustomPageModelFromThemeRef(targetRef);
if (!model) return setStatus("导出失败:主题不存在。", true);
const payload = buildCustomPagePayloadFromModel(model);
if (!payload) return setStatus("导出失败:主题数据无效。", true);
const text = JSON.stringify(payload, null, 2);
state.lastExportText = text;
const textarea = getPanelTextarea();
if (textarea) textarea.value = text;
copyTextToClipboard(text, `已复制当前主题 JSON:${model.name}`);
}
function exportCurrentThemeConfig() {
const payload = buildCurrentThemePayload();
if (!payload) return setStatus("导出失败:当前主题不可用。", true);
writePayloadToImportExportArea(payload, "已生成当前主题 JSON。");
}
function exportThemeCollection() {
const payload = buildThemeCollectionPayload();
if (!payload) return setStatus("导出失败:无可导出的主题。", true);
writePayloadToImportExportArea(payload, "已生成全部主题 JSON 合集。");
}
function deleteThemeByRef(themeRef) {
const parsed = parseThemeRef(themeRef);
if (!parsed || parsed.kind !== "library") return setStatus("仅支持删除已保存主题。", true);
if (parsed.id === CUSTOM_THEME_ID) return setStatus("“我的主题”工作区不可删除。", true);
const entry = getThemeEntryById(parsed.id);
if (!entry) return setStatus("删除失败:主题不存在。", true);
if (!confirmWithFallback(`确认删除主题“${entry.name}”?此操作不可撤销。`)) return;
state.config.themeLibrary = (state.config.themeLibrary || []).filter((item) => item && item.id !== parsed.id);
if (state.config.activeThemeRef === makeLibraryThemeRef(parsed.id)) {
const fallbackPresetId = isPresetId(entry.basePreset) ? entry.basePreset : getDefaultPresetId();
state.config.activeThemeRef = makePresetThemeRef(fallbackPresetId);
state.config.activePresetId = fallbackPresetId;
}
syncCustomThemeToLibrary();
saveConfig();
applyTheme();
renderPanel();
syncEntryPoints();
setStatus(`已删除主题:${entry.name}`);
}
function ensureNoUnknownTokenKey(tokens) {
const keys = Object.keys(tokens || {});
const allowed = new Set([...TOKEN_KEYS, ...Object.keys(TOKEN_ALIASES)]);
const unknown = keys.filter((k) => !allowed.has(k));
if (unknown.length) throw new Error(`存在不允许的 token: ${unknown.join(", ")}`);
}
function alertImportConflict(message) {
try {
window.alert(message);
} catch {
// ignore alert failures in restricted contexts
}
throw new Error(message);
}
function confirmWithFallback(message, fallback = false) {
try {
if (typeof window.confirm === "function") return !!window.confirm(message);
} catch (error) {
console.warn("[linuxdo-theme] confirm dialog unavailable", error);
}
return fallback;
}
function ensureImportThemeNameAvailable(rawName, existingNamePool) {
const name = normalizeThemeName(rawName, "导入主题");
const lowered = name.toLowerCase();
const presetNames = new Set(Object.values(PRESETS).map((preset) => String(preset.name || "").toLowerCase()));
if (presetNames.has(lowered)) {
alertImportConflict(`导入失败:主题名“${name}”与内置预设同名,预设主题不可替换。`);
}
const existingNames =
existingNamePool instanceof Set
? existingNamePool
: new Set((state.config.themeLibrary || []).map((entry) => String(entry?.name || "").toLowerCase()).filter(Boolean));
if (existingNames.has(lowered)) {
alertImportConflict(`导入失败:已存在同名主题“${name}”,不允许同名导入。`);
}
if (existingNamePool instanceof Set) existingNamePool.add(lowered);
return name;
}
function getRandomIdSuffix() {
const cryptoApi = globalThis.crypto;
if (cryptoApi && typeof cryptoApi.getRandomValues === "function") {
const bytes = new Uint8Array(5);
cryptoApi.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(36).padStart(2, "0")).join("").slice(0, 8);
}
return Math.random().toString(36).slice(2, 10);
}
function buildUniqueThemeId(prefix = "imported") {
const normalizedPrefix = typeof prefix === "string" && prefix ? prefix : "imported";
let candidate = "";
let attempts = 0;
do {
candidate = `${normalizedPrefix}-${Date.now().toString(36)}-${getRandomIdSuffix()}`;
attempts += 1;
} while (getThemeEntryById(candidate) && attempts < UNIQUE_THEME_ID_MAX_ATTEMPTS);
if (!getThemeEntryById(candidate)) return candidate;
// Deterministic fallback to guarantee uniqueness under extreme collision cases.
const seed = `${normalizedPrefix}-${Date.now().toString(36)}-${getRandomIdSuffix()}`;
let suffix = 0;
do {
candidate = `${seed}-${suffix.toString(36)}`;
suffix += 1;
} while (getThemeEntryById(candidate));
return candidate;
}
function parseAndResolveTokenMap(rawTokens, basePresetId, fieldName = "tokens") {
if (!rawTokens || typeof rawTokens !== "object") throw new Error(`${fieldName} 必须为对象。`);
ensureNoUnknownTokenKey(rawTokens);
const normalizedTokens = normalizeTokens(rawTokens);
const uniqueCanonicalTokenCount = new Set(
Object.keys(rawTokens).map((key) => TOKEN_ALIASES[key] || key)
).size;
if (Object.keys(normalizedTokens).length !== uniqueCanonicalTokenCount) {
throw new Error(`${fieldName} 中存在非法值,请检查颜色格式或字符长度。`);
}
return normalizeTokens({ ...PRESETS[basePresetId].tokens, ...normalizedTokens });
}
function parseCustomPagePayload(payload) {
if (payload?.kind !== CUSTOM_IO_KIND) return null;
if (payload?.schemaVersion !== CUSTOM_IO_SCHEMA_VERSION) {
throw new Error(`导入失败:${CUSTOM_IO_KIND} 仅支持 schemaVersion = ${CUSTOM_IO_SCHEMA_VERSION}。`);
}
const custom = payload.custom;
if (!custom || typeof custom !== "object") throw new Error("导入失败:custom 字段缺失或格式错误。");
if (!isPresetId(custom.basePreset)) throw new Error("导入失败:custom.basePreset 不在内置预设列表中。");
return {
name: normalizeThemeName(custom.name, "导入配置"),
basePreset: custom.basePreset,
tokens: parseAndResolveTokenMap(custom.tokens, custom.basePreset, "custom.tokens"),
};
}
function parseLegacyThemePackPayload(payload) {
if (payload?.schemaVersion !== 1) return null;
const kind = payload.kind || (payload.config ? "full-config" : "theme-pack");
if (kind !== "theme-pack") return null;
if (!isPresetId(payload.basePreset)) throw new Error("basePreset 不在内置预设列表中。");
if (typeof payload.name !== "string" || !payload.name.trim() || payload.name.trim().length > 80) {
throw new Error("name 不能为空,且长度不能超过 80。");
}
return {
name: payload.name.trim().slice(0, 80),
basePreset: payload.basePreset,
tokens: parseAndResolveTokenMap(payload.tokens, payload.basePreset, "tokens"),
};
}
function parseLegacyFullConfigPayload(payload) {
if (payload?.schemaVersion !== 1) return null;
const kind = payload.kind || (payload.config ? "full-config" : "theme-pack");
if (kind !== "full-config") return null;
if (!payload.config || typeof payload.config !== "object") throw new Error("full-config 缺少 config。");
const importedConfig = normalizeConfig(payload.config);
const basePreset = isPresetId(importedConfig.customTheme?.basePreset)
? importedConfig.customTheme.basePreset
: isPresetId(importedConfig.activePresetId)
? importedConfig.activePresetId
: getDefaultPresetId();
return {
name: normalizeThemeName(importedConfig.customTheme?.name, "导入配置"),
basePreset,
tokens: parseAndResolveTokenMap(importedConfig.customTheme?.tokens || {}, basePreset, "config.customTheme.tokens"),
};
}
function parseThemeCollectionPayload(payload) {
if (payload?.kind !== COLLECTION_IO_KIND) return null;
if (payload?.schemaVersion !== COLLECTION_IO_SCHEMA_VERSION) {
throw new Error(`导入失败:${COLLECTION_IO_KIND} 仅支持 schemaVersion = ${COLLECTION_IO_SCHEMA_VERSION}。`);
}
const rawThemes = payload.collection?.themes;
if (!Array.isArray(rawThemes) || rawThemes.length === 0) throw new Error("导入失败:theme-collection 缺少 themes 列表。");
const themes = rawThemes.map((raw, index) => {
if (!raw || typeof raw !== "object") throw new Error(`导入失败:collection.themes[${index}] 必须为对象。`);
const kind = raw.kind === "preset" || raw.kind === "library" ? raw.kind : null;
if (!kind) throw new Error(`导入失败:collection.themes[${index}].kind 仅支持 preset / library。`);
if (!isPresetId(raw.basePreset)) throw new Error(`导入失败:collection.themes[${index}].basePreset 不在内置预设列表中。`);
const source =
kind === "preset"
? "preset"
: raw.source === LIBRARY_SOURCE_CUSTOM
? LIBRARY_SOURCE_CUSTOM
: raw.source === LIBRARY_SOURCE_IMPORTED
? LIBRARY_SOURCE_IMPORTED
: null;
if (!source) throw new Error(`导入失败:collection.themes[${index}].source 非法。`);
const fallbackName = kind === "preset" ? PRESETS[raw.basePreset].name : "导入主题";
return {
kind,
source,
workspace: kind === "library" ? !!raw.workspace : false,
selected: !!raw.selected,
name: normalizeThemeName(raw.name, fallbackName),
basePreset: raw.basePreset,
tokens: parseAndResolveTokenMap(raw.tokens, raw.basePreset, `collection.themes[${index}].tokens`),
};
});
if (!themes.some((item) => item.selected)) themes[0].selected = true;
return { themes };
}
function parseImportPayload(payload) {
const collectionPayload = parseThemeCollectionPayload(payload);
if (collectionPayload) return { type: "collection", collection: collectionPayload, sourceKind: COLLECTION_IO_KIND };
const customPayload = parseCustomPagePayload(payload);
if (customPayload) return { type: "single", model: customPayload, sourceKind: CUSTOM_IO_KIND };
const legacyThemePack = parseLegacyThemePackPayload(payload);
if (legacyThemePack) return { type: "single", model: legacyThemePack, sourceKind: "theme-pack" };
const legacyFullConfig = parseLegacyFullConfigPayload(payload);
if (legacyFullConfig) return { type: "single", model: legacyFullConfig, sourceKind: "full-config" };
if (typeof payload?.schemaVersion === "number") {
throw new Error(`导入失败:不支持的 schemaVersion = ${payload.schemaVersion}。`);
}
throw new Error("导入失败:无法识别 JSON 格式。");
}
function applyImportedCustomPageConfig(model, sourceKind) {
const importName = ensureImportThemeNameAvailable(model.name);
const entryId = buildUniqueThemeId("imported");
const importedEntry = normalizeThemeEntry(
{
id: entryId,
source: LIBRARY_SOURCE_IMPORTED,
name: importName,
basePreset: model.basePreset,
tokens: model.tokens,
settings: state.config.settings,
createdAt: nowISO(),
updatedAt: nowISO(),
},
model.basePreset,
state.config.settings,
entryId
);
if (!importedEntry) throw new Error("导入失败:主题数据无效。");
upsertThemeEntry(importedEntry);
applyLibraryTheme(importedEntry.id, { syncCustomTheme: true });
saveConfig();
applyTheme();
renderPanel();
syncEntryPoints();
if (sourceKind === CUSTOM_IO_KIND) return `导入成功,已添加到“全部主题”:${importedEntry.name}`;
return `已兼容导入 ${sourceKind},并添加到“全部主题”:${importedEntry.name}`;
}
function applyImportedThemeCollection(collection, sourceKind) {
const themes = Array.isArray(collection?.themes) ? collection.themes : [];
if (!themes.length) throw new Error("导入失败:主题合集为空。");
const selectedTheme = themes.find((item) => item.selected) || themes[0];
const libraryThemes = themes.filter((item) => item.kind === "library");
const workspaceTheme =
libraryThemes.find((item) => item.workspace) ||
(selectedTheme.kind === "library" ? selectedTheme : libraryThemes[0]) ||
selectedTheme;
const indexToEntryId = new Map();
const importedEntries = [];
const existingNames = new Set(
(state.config.themeLibrary || [])
.filter((entry) => entry && entry.id !== CUSTOM_THEME_ID)
.map((entry) => String(entry.name || "").toLowerCase())
.filter(Boolean)
);
themes.forEach((theme, index) => {
if (theme.kind !== "library") return;
if (theme === workspaceTheme) return;
const importName = ensureImportThemeNameAvailable(theme.name, existingNames);
const prefix = theme.source === LIBRARY_SOURCE_CUSTOM ? "custom" : "imported";
const entryId = buildUniqueThemeId(prefix);
const entry = normalizeThemeEntry(
{
id: entryId,
source: theme.source,
name: importName,
basePreset: theme.basePreset,
tokens: theme.tokens,
settings: state.config.settings,
createdAt: nowISO(),
updatedAt: nowISO(),
},
theme.basePreset,
state.config.settings,
entryId
);
if (!entry) return;
importedEntries.push(entry);
indexToEntryId.set(index, entry.id);
});
state.config.customTheme.name = "我的主题";
state.config.customTheme.basePreset = workspaceTheme.basePreset;
state.config.customTheme.tokens = clone(workspaceTheme.tokens);
state.config.customTheme.patches = normalizePatches(state.config.customTheme.patches, DEFAULT_PATCHES);
state.customThemeDraftName = normalizeThemeName(workspaceTheme.name, "我的主题");
const preservedEntries = (state.config.themeLibrary || []).filter((entry) => entry && entry.id !== CUSTOM_THEME_ID);
state.config.themeLibrary = [...preservedEntries, ...importedEntries];
syncCustomThemeToLibrary();
if (selectedTheme.kind === "preset") {
state.config.activeThemeRef = makePresetThemeRef(selectedTheme.basePreset);
state.config.activePresetId = selectedTheme.basePreset;
} else if (selectedTheme === workspaceTheme) {
activateCustomTheme();
} else {
const selectedIndex = themes.indexOf(selectedTheme);
const mappedId = indexToEntryId.get(selectedIndex);
if (mappedId && getThemeEntryById(mappedId)) {
state.config.activeThemeRef = makeLibraryThemeRef(mappedId);
state.config.activePresetId = selectedTheme.basePreset;
} else {
activateCustomTheme();
}
}
saveConfig();
applyTheme();
renderPanel();
syncEntryPoints();
if (sourceKind === COLLECTION_IO_KIND) return `全部主题合集导入成功,新增 ${importedEntries.length} 个主题。`;
return `已导入主题合集:${sourceKind}`;
}
function parseJsonPayloadFromText(text) {
const trimmedInput = typeof text === "string" ? text.trim() : "";
if (!trimmedInput) throw new Error("JSON 解析失败。");
if (trimmedInput.length > MAX_IMPORT_TEXT_CHARS) {
throw new Error(`JSON 过大(>${MAX_IMPORT_TEXT_CHARS} 字符),请精简后重试。`);
}
const candidates = [trimmedInput];
const fencedMatch = trimmedInput.match(/```(?:json|jsonc)?\s*([\s\S]*?)\s*```/i);
if (fencedMatch && fencedMatch[1]) candidates.push(fencedMatch[1].trim());
const firstBrace = trimmedInput.indexOf("{");
const lastBrace = trimmedInput.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) candidates.push(trimmedInput.slice(firstBrace, lastBrace + 1).trim());
const tried = new Set();
for (const candidate of candidates) {
if (!candidate || tried.has(candidate)) continue;
tried.add(candidate);
try {
return JSON.parse(candidate);
} catch {
// try next candidate
}
}
throw new Error("JSON 解析失败,请粘贴合法 JSON(支持包含 ```json``` 代码块或前后说明文字)。");
}
function importFromJsonText(text) {
const payload = parseJsonPayloadFromText(text);
const parsed = parseImportPayload(payload);
if (parsed.type === "collection") return applyImportedThemeCollection(parsed.collection, parsed.sourceKind);
return applyImportedCustomPageConfig(parsed.model, parsed.sourceKind);
}
const getPanelTextarea = () => state.panel?.querySelector('[data-role="import-export-text"]');
function copyTextareaContent() {
const textarea = getPanelTextarea();
if (!textarea) return;
const content = textarea.value.trim();
copyTextToClipboard(content, "已复制到剪贴板。");
}
function buildAllThemesViewModel() {
const activeRef = resolveActiveThemeSelection().themeRef;
const cards = Object.values(PRESETS).map((preset) => ({
themeRef: makePresetThemeRef(preset.id),
name: preset.name,
description: preset.description,
badge: `预设 · ${preset.patchProfile}`,
canEdit: true,
canDelete: false,
canExport: true,
}));
const customEntry = getThemeEntryById(CUSTOM_THEME_ID);
if (customEntry) {
cards.push({
themeRef: makeLibraryThemeRef(customEntry.id),
name: customEntry.name,
description: `基于 ${PRESETS[customEntry.basePreset]?.name || customEntry.basePreset},自动同步你的自定义编辑。`,
badge: `${SOURCE_LABELS[LIBRARY_SOURCE_CUSTOM]} · ${PRESETS[customEntry.basePreset]?.patchProfile || "custom"}`,
canEdit: true,
canDelete: false,
canExport: true,
});
}
const savedCustom = (state.config.themeLibrary || [])
.filter((entry) => entry?.id !== CUSTOM_THEME_ID && entry?.source === LIBRARY_SOURCE_CUSTOM)
.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
savedCustom.forEach((entry) => {
cards.push({
themeRef: makeLibraryThemeRef(entry.id),
name: entry.name,
description: `自定义保存主题,基于 ${PRESETS[entry.basePreset]?.name || entry.basePreset}。`,
badge: `${SOURCE_LABELS[LIBRARY_SOURCE_CUSTOM]} · ${PRESETS[entry.basePreset]?.patchProfile || "custom"}`,
canEdit: true,
canDelete: true,
canExport: true,
});
});
const imported = (state.config.themeLibrary || [])
.filter((entry) => entry?.id !== CUSTOM_THEME_ID && entry?.source === LIBRARY_SOURCE_IMPORTED)
.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
imported.forEach((entry) => {
cards.push({
themeRef: makeLibraryThemeRef(entry.id),
name: entry.name,
description: `导入主题,基于 ${PRESETS[entry.basePreset]?.name || entry.basePreset}。`,
badge: `${SOURCE_LABELS[LIBRARY_SOURCE_IMPORTED]} · ${PRESETS[entry.basePreset]?.patchProfile || "custom"}`,
canEdit: true,
canDelete: true,
canExport: true,
});
});
return { activeRef, cards };
}
function renderAllThemesSection() {
const section = state.panel?.querySelector('[data-panel-section="all-themes"]');
if (!section) return;
const viewModel = buildAllThemesViewModel();
section.innerHTML = `<div class="ldt-preset-grid">${viewModel.cards
.map((card) => {
const active = card.themeRef === viewModel.activeRef;
const actions = [
`<button class="ldt-btn ${active ? "--primary" : ""}" data-action="apply-theme-ref" data-theme-ref="${card.themeRef}">${active ? "当前使用中" : "应用主题"}</button>`,
];
if (card.canEdit) actions.push(`<button class="ldt-btn" data-action="edit-theme-ref" data-theme-ref="${card.themeRef}">编辑</button>`);
if (card.canExport) actions.push(`<button class="ldt-btn" data-action="export-theme-ref" data-theme-ref="${card.themeRef}">导出</button>`);
if (card.canDelete) actions.push(`<button class="ldt-btn" data-action="delete-theme-ref" data-theme-ref="${card.themeRef}">删除</button>`);
return `<article class="ldt-preset-card ${active ? "--active" : ""}"><h4>${escapeHtml(card.name)}</h4><p>${escapeHtml(card.description)}</p><span class="ldt-badge">${escapeHtml(card.badge)}</span><div class="ldt-btn-row">${actions.join("")}</div></article>`;
})
.join("")}</div>`;
}
function renderCustomSection() {
const section = state.panel?.querySelector('[data-panel-section="custom"]');
if (!section) return;
const theme = getEditableCustomTheme();
const saveName = (state.customThemeDraftName || "").trim() || "我的主题";
const rows = EDITABLE_TOKEN_KEYS.map((token) => {
const meta = TOKEN_META[token];
const value = theme.tokens[token];
const picker = toColorInputValue(value);
if (meta.type === "color") {
return `<div class="ldt-token-row" data-token="${token}"><label>${meta.label}<br><small>${token}</small></label><input type="color" data-action="token-color" data-token="${token}" value="${picker || "#000000"}" ${picker ? "" : "disabled"}><input type="text" data-action="token-text" data-token="${token}" value="${escapeHtml(value)}"><button class="ldt-btn" data-action="reset-token" data-token="${token}">恢复</button></div>`;
}
return `<div class="ldt-token-row" data-token="${token}"><label>${meta.label}<br><small>${token}</small></label><div></div><input type="text" data-action="token-text" data-token="${token}" value="${escapeHtml(value)}"><button class="ldt-btn" data-action="reset-token" data-token="${token}">恢复</button></div>`;
}).join("");
section.innerHTML = `<div class="ldt-field-grid"><div class="ldt-group"><h4>保存到全部主题</h4><small>输入名称后保存当前自定义快照,便于在“全部主题”中切换和导出。</small><div class="ldt-btn-row"><input type="text" data-role="custom-save-name" maxlength="80" value="${escapeHtml(saveName)}" placeholder="输入主题名称" style="min-width:220px;flex:1 1 220px;height:32px;"><button class="ldt-btn --primary" data-action="save-custom-theme">保存</button></div></div><div class="ldt-group"><h4>Token 编辑(颜色可双通道:色板 + 文本)</h4><small>“内容容器背景”已与“全局背景”联动,不再单独配置。</small>${rows}<div class="ldt-btn-row"><button class="ldt-btn" data-action="restore-custom">恢复为当前预设</button></div></div></div>`;
}
function refreshDynamicCustomInputs() {
if (!state.panel) return;
const section = state.panel.querySelector('[data-panel-section="custom"]');
if (!section || section.hidden) return;
const theme = getEditableCustomTheme();
EDITABLE_TOKEN_KEYS.forEach((token) => {
const row = section.querySelector(`.ldt-token-row[data-token="${token}"]`);
if (!row) return;
const value = theme.tokens[token];
const textInput = row.querySelector('[data-action="token-text"]');
if (textInput && document.activeElement !== textInput) textInput.value = value;
const colorInput = row.querySelector('[data-action="token-color"]');
if (colorInput) {
const color = toColorInputValue(value);
if (color) {
colorInput.disabled = false;
if (document.activeElement !== colorInput) colorInput.value = color;
} else {
colorInput.disabled = true;
}
}
});
const saveNameInput = section.querySelector('[data-role="custom-save-name"]');
if (saveNameInput instanceof HTMLInputElement && document.activeElement !== saveNameInput) {
saveNameInput.value = (state.customThemeDraftName || "").trim() || "我的主题";
}
}
function refreshDynamicSettingsInputs() {
if (!state.panel) return;
const section = state.panel.querySelector('[data-panel-section="settings"]');
if (!section || section.hidden) return;
const settings = normalizeSettings(state.config.settings, DEFAULT_SETTINGS);
const radiusInput = section.querySelector('[data-action="patch-radius-scale"]');
const shadowInput = section.querySelector('[data-action="patch-shadow-intensity"]');
const radiusValue = section.querySelector('[data-role="radius-scale-value"]');
const shadowValue = section.querySelector('[data-role="shadow-intensity-value"]');
if (radiusInput instanceof HTMLInputElement && document.activeElement !== radiusInput) radiusInput.value = `${settings.radiusScale}`;
if (shadowInput instanceof HTMLInputElement && document.activeElement !== shadowInput) shadowInput.value = `${settings.shadowIntensity}`;
if (radiusValue) radiusValue.textContent = `${settings.radiusScale}%`;
if (shadowValue) shadowValue.textContent = `${settings.shadowIntensity}%`;
}
function renderImportExportSection() {
const section = state.panel?.querySelector('[data-panel-section="import-export"]');
if (!section) return;
const defaultText = buildCurrentThemePayloadText();
if (defaultText) state.lastExportText = defaultText;
section.innerHTML = `<div class="ldt-field-grid"><div class="ldt-group"><h4>主题 JSON</h4><small>默认展示“全部主题”当前选中主题的 JSON。支持导入单主题配置(custom-page-config)、旧版 theme-pack / full-config,以及“导出全部”生成的 theme-collection 合集。可直接粘贴带 JSON 代码块或前后说明文字的内容。结构参数为全局设置,不包含在导入导出 JSON 中。</small><textarea data-role="import-export-text" placeholder="粘贴 JSON 到这里...">${escapeHtml(state.lastExportText)}</textarea><div class="ldt-btn-row"><button class="ldt-btn --primary" data-action="export-current-theme">导出当前主题</button><button class="ldt-btn" data-action="export-theme-collection">导出全部</button><button class="ldt-btn" data-action="import-json">导入 JSON</button><button class="ldt-btn" data-action="copy-json">复制内容</button></div></div></div>`;
}
function renderSettingsSection() {
const section = state.panel?.querySelector('[data-panel-section="settings"]');
if (!section) return;
const settings = normalizeSettings(state.config.settings, DEFAULT_SETTINGS);
section.innerHTML = `<div class="ldt-field-grid"><div class="ldt-group"><h4>通用设置</h4><label class="ldt-checkbox"><input type="checkbox" data-action="toggle-floating" ${settings.enableFloatingButton ? "checked" : ""}><span>启用悬浮按钮(右下角)</span></label><label class="ldt-checkbox"><input type="checkbox" data-action="toggle-open-topic-new-tab" ${settings.openTopicInNewTab ? "checked" : ""}><span>文章链接默认新标签页打开</span></label></div><div class="ldt-group"><h4>结构参数(全局)</h4><small>作用于所有主题,不随主题导入导出。</small><div class="ldt-field-grid"><label class="ldt-checkbox"><input type="checkbox" data-action="patch-header-glass" ${settings.headerGlass ? "checked" : ""}><span>Header 毛玻璃</span></label><label class="ldt-checkbox"><input type="checkbox" data-action="patch-topic-elevation" ${settings.topicCardElevation ? "checked" : ""}><span>Topic 卡片立体感</span></label><label class="ldt-inline"><span>圆角缩放</span><input type="range" min="60" max="160" step="1" value="${settings.radiusScale}" data-action="patch-radius-scale"><span class="ldt-value" data-role="radius-scale-value">${settings.radiusScale}%</span></label><label class="ldt-inline"><span>阴影强度</span><input type="range" min="0" max="200" step="1" value="${settings.shadowIntensity}" data-action="patch-shadow-intensity"><span class="ldt-value" data-role="shadow-intensity-value">${settings.shadowIntensity}%</span></label></div></div><div class="ldt-group"><h4>重置</h4><div class="ldt-btn-row"><button class="ldt-btn" data-action="reset-all">重置全部配置</button></div></div><div class="ldt-group"><h4>信息</h4><small>版本:${SCRIPT_VERSION}</small></div></div>`;
}
function renderPanel() {
if (!state.panel) return;
renderAllThemesSection();
renderCustomSection();
renderImportExportSection();
renderSettingsSection();
switchSection(state.activeSection, false);
}
function switchSection(sectionName, setMsg = true) {
if (!state.panel) return;
state.activeSection = sectionName;
state.panel.querySelectorAll(".ldt-tab").forEach((tab) => tab.classList.toggle("--active", tab.getAttribute("data-section") === sectionName));
state.panel.querySelectorAll(".ldt-section").forEach((sec) => {
sec.hidden = sec.getAttribute("data-panel-section") !== sectionName;
});
if (setMsg) setStatus(`已切换到 ${SECTION_LABELS[sectionName] || sectionName}。`);
}
function onPanelClick(event) {
const target = event.target.closest("[data-action]");
if (!target) return;
const action = target.getAttribute("data-action");
if (action === "close-panel") return closePanel();
if (action === "switch-section") return switchSection(target.getAttribute("data-section"));
if (action === "apply-theme-ref") return setThemeByRef(target.getAttribute("data-theme-ref"));
if (action === "edit-theme-ref") return editThemeByRef(target.getAttribute("data-theme-ref"));
if (action === "export-theme-ref") return exportThemeByRef(target.getAttribute("data-theme-ref"));
if (action === "delete-theme-ref") return deleteThemeByRef(target.getAttribute("data-theme-ref"));
if (action === "reset-token") return resetTokenOverride(target.getAttribute("data-token"));
if (action === "save-custom-theme") {
const nameInput = state.panel?.querySelector('[data-role="custom-save-name"]');
const rawName = nameInput instanceof HTMLInputElement ? nameInput.value : "";
return saveCustomThemeSnapshot(rawName);
}
if (action === "restore-custom") return resetCustomToPreset();
if (action === "export-current-theme") return exportCurrentThemeConfig();
if (action === "export-theme-collection") return exportThemeCollection();
if (action === "copy-json") return copyTextareaContent();
if (action === "import-json") {
const textarea = getPanelTextarea();
if (!textarea) return;
const input = textarea.value.trim();
if (!input) return setStatus("请先粘贴 JSON。", true);
try {
setStatus(importFromJsonText(input));
} catch (error) {
setStatus(error.message || "导入失败。", true);
}
return;
}
if (action === "reset-all") {
if (!confirmWithFallback("确认重置全部主题配置?此操作不可撤销。")) return;
state.config = normalizeConfig(createDefaultConfig());
state.customThemeDraftName = "我的主题";
saveConfig();
applyTheme();
renderPanel();
syncEntryPoints();
setStatus("全部配置已重置。");
}
}
function onPanelInput(event) {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target instanceof HTMLInputElement && target.getAttribute("data-role") === "custom-save-name") {
state.customThemeDraftName = target.value.slice(0, 80);
return;
}
const action = target.getAttribute("data-action");
if (!action) return;
if (action === "token-color") {
const token = target.getAttribute("data-token");
if (!token || !(target instanceof HTMLInputElement)) return;
const row = target.closest(`.ldt-token-row[data-token="${token}"]`);
const textInput = row?.querySelector('[data-action="token-text"]');
if (textInput instanceof HTMLInputElement) textInput.value = target.value;
return setTokenOverride(token, target.value);
}
if (action === "token-text") {
if (!(target instanceof HTMLInputElement)) return;
const token = target.getAttribute("data-token");
if (!token) return;
return setTokenOverride(token, target.value);
}
if (action === "patch-radius-scale") {
if (!(target instanceof HTMLInputElement)) return;
state.config.settings.radiusScale = clamp(parseInt(target.value, 10), 60, 160);
const radiusValueEl = state.panel?.querySelector('[data-role="radius-scale-value"]');
if (radiusValueEl) radiusValueEl.textContent = `${state.config.settings.radiusScale}%`;
return debounceApply();
}
if (action === "patch-shadow-intensity") {
if (!(target instanceof HTMLInputElement)) return;
state.config.settings.shadowIntensity = clamp(parseInt(target.value, 10), 0, 200);
const shadowValueEl = state.panel?.querySelector('[data-role="shadow-intensity-value"]');
if (shadowValueEl) shadowValueEl.textContent = `${state.config.settings.shadowIntensity}%`;
debounceApply();
}
}
function onPanelChange(event) {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const action = target.getAttribute("data-action");
if (!action) return;
if (action === "patch-header-glass" && target instanceof HTMLInputElement) {
state.config.settings.headerGlass = target.checked;
saveConfig();
applyTheme();
return setStatus("全局 Header 毛玻璃设置已更新。");
}
if (action === "patch-topic-elevation" && target instanceof HTMLInputElement) {
state.config.settings.topicCardElevation = target.checked;
saveConfig();
applyTheme();
return setStatus("全局 Topic 卡片立体感设置已更新。");
}
if (action === "toggle-floating" && target instanceof HTMLInputElement) {
state.config.settings.enableFloatingButton = target.checked;
syncSettingsToActiveTheme();
saveConfig();
syncEntryPoints();
setStatus("悬浮按钮设置已更新。");
return;
}
if (action === "toggle-open-topic-new-tab" && target instanceof HTMLInputElement) {
state.config.settings.openTopicInNewTab = target.checked;
syncSettingsToActiveTheme();
saveConfig();
syncTopicLinkNewTabBinding();
setStatus("文章新标签页打开设置已更新。");
}
}
function ensurePanel() {
if (state.panel && state.overlay) return;
if (!document.body) return;
state.overlay = document.getElementById(OVERLAY_ID);
if (!state.overlay) {
state.overlay = document.createElement("div");
state.overlay.id = OVERLAY_ID;
state.overlay.hidden = true;
state.overlay.addEventListener("click", () => runSafely("overlay click", closePanel));
document.body.appendChild(state.overlay);
}
state.panel = document.getElementById(PANEL_ID);
if (!state.panel) {
state.panel = document.createElement("aside");
state.panel.id = PANEL_ID;
state.panel.hidden = true;
state.panel.setAttribute("aria-hidden", "true");
state.panel.innerHTML = `<div class="ldt-header"><div class="ldt-title-wrap"><h3>CozyDo Theme Studio</h3><p>全部主题 + 自定义 + JSON 分享</p></div><button class="ldt-close" data-action="close-panel" aria-label="关闭">×</button></div><div class="ldt-tabs"><button class="ldt-tab --active" data-action="switch-section" data-section="all-themes">全部主题</button><button class="ldt-tab" data-action="switch-section" data-section="custom">自定义</button><button class="ldt-tab" data-action="switch-section" data-section="import-export">导入导出</button><button class="ldt-tab" data-action="switch-section" data-section="settings">设置</button></div><div class="ldt-sections"><section class="ldt-section" data-panel-section="all-themes"></section><section class="ldt-section" data-panel-section="custom" hidden></section><section class="ldt-section" data-panel-section="import-export" hidden></section><section class="ldt-section" data-panel-section="settings" hidden></section></div><div class="ldt-footer"><p class="ldt-status" data-role="status"></p></div>`;
state.panel.addEventListener("click", (event) => runSafely("panel click", () => onPanelClick(event)));
state.panel.addEventListener("input", (event) => runSafely("panel input", () => onPanelInput(event)));
state.panel.addEventListener("change", (event) => runSafely("panel change", () => onPanelChange(event)));
document.body.appendChild(state.panel);
}
state.panelStatusEl = state.panel.querySelector('[data-role="status"]');
}
function ensureFloatingButton(show) {
if (!document.body) return;
if (!show || !isFrontendRoute()) {
if (state.floatingBtn) {
state.floatingBtn.remove();
state.floatingBtn = null;
}
return;
}
if (!state.floatingBtn) {
state.floatingBtn = document.createElement("button");
state.floatingBtn.id = FLOATING_BTN_ID;
state.floatingBtn.type = "button";
state.floatingBtn.title = "主题设置";
state.floatingBtn.setAttribute("aria-label", "主题设置");
state.floatingBtn.innerHTML = buildThemeIconSvg("d-icon");
state.floatingBtn.addEventListener("click", () => runSafely("floating button click", togglePanel));
document.body.appendChild(state.floatingBtn);
}
}
function removeHeaderEntry() {
const entry = document.getElementById(HEADER_ENTRY_ID);
if (entry) entry.remove();
}
function ensureHeaderEntry() {
if (!isFrontendRoute()) {
removeHeaderEntry();
return false;
}
const icons = document.querySelector(".d-header-icons");
if (!icons) {
removeHeaderEntry();
return false;
}
let entry = document.getElementById(HEADER_ENTRY_ID);
if (!entry) {
entry = document.createElement("li");
entry.id = HEADER_ENTRY_ID;
entry.className = "header-dropdown-toggle";
entry.innerHTML = `<button class="icon linuxdo-theme-header-btn" type="button" title="主题设置" aria-label="主题设置">${buildThemeIconSvg("d-icon")}</button>`;
entry.querySelector("button")?.addEventListener("click", () => runSafely("header button click", togglePanel));
icons.prepend(entry);
}
const btn = entry.querySelector("button");
if (btn) btn.className = "icon linuxdo-theme-header-btn";
const icon = entry.querySelector("svg");
if (icon) icon.classList.add("d-icon");
return true;
}
function resolveTopicLinkFromClickEvent(event) {
if (!event || event.defaultPrevented) return null;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return null;
if (typeof event.button === "number" && event.button !== 0) return null;
const target = event.target;
if (!target || typeof target.closest !== "function") return null;
const link = target.closest("a");
if (!link || typeof link.matches !== "function") return null;
if (!link.matches(TOPIC_LINK_NEW_TAB_SELECTOR)) return null;
if (!link.closest(TOPIC_LINK_NEW_TAB_CONTEXT_SELECTOR)) return null;
if (typeof link.target === "string" && link.target && link.target.toLowerCase() !== "_self") return null;
const rawHref = typeof link.getAttribute === "function" ? link.getAttribute("href") : "";
if (!rawHref || rawHref.startsWith("#") || /^javascript:/i.test(rawHref) || /^mailto:/i.test(rawHref)) return null;
let parsedUrl = null;
try {
parsedUrl = new URL(rawHref, location.href);
} catch {
return null;
}
if (!parsedUrl || parsedUrl.origin !== location.origin) return null;
if (!/\/t\/(?:[^/]+\/)?\d+/.test(parsedUrl.pathname)) return null;
return { link, href: parsedUrl.href };
}
function onTopicLinkClickCapture(event) {
if (!state.config?.settings?.openTopicInNewTab || !isFrontendRoute()) return;
const resolved = resolveTopicLinkFromClickEvent(event);
if (!resolved) return;
event.preventDefault();
if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
else if (typeof event.stopPropagation === "function") event.stopPropagation();
if (typeof window.open === "function") window.open(resolved.href, "_blank", "noopener");
}
function syncTopicLinkNewTabBinding() {
const shouldBind = !!state.config?.settings?.openTopicInNewTab && isFrontendRoute();
if (!shouldBind) {
if (!state.isTopicLinkNewTabBound || !state.onTopicLinkClickCapture) return;
try {
document.removeEventListener("click", state.onTopicLinkClickCapture, true);
} catch {
// ignore remove listener failures
}
state.isTopicLinkNewTabBound = false;
return;
}
if (state.isTopicLinkNewTabBound) return;
if (!state.onTopicLinkClickCapture) {
state.onTopicLinkClickCapture = (event) => runSafely("topic link new-tab click", () => onTopicLinkClickCapture(event));
}
document.addEventListener("click", state.onTopicLinkClickCapture, true);
state.isTopicLinkNewTabBound = true;
}
function syncEntryPoints() {
if (!document.body) return;
if (!isFrontendRoute()) {
disconnectHeaderIconsObserver();
removeHeaderEntry();
ensureFloatingButton(false);
closePanel();
removePanelDom();
syncTopicLinkNewTabBinding();
return;
}
const hasHeader = ensureHeaderEntry();
ensureFloatingButton(state.config.settings.enableFloatingButton || !hasHeader);
observeHeaderIconsContainer();
syncTopicLinkNewTabBinding();
}
function scheduleSyncEntryPoints() {
clearStateTimer("syncEntryTimer");
state.syncEntryTimer = setTimeout(() => {
state.syncEntryTimer = null;
runSafely("sync entry points timer", syncEntryPoints);
}, 80);
}
function disconnectHeaderIconsObserver() {
if (state.headerIconsObserver) {
state.headerIconsObserver.disconnect();
state.headerIconsObserver = null;
}
state.headerIconsObserverTarget = null;
}
function observeHeaderIconsContainer() {
if (!isFrontendRoute()) {
disconnectHeaderIconsObserver();
return;
}
const icons = document.querySelector(".d-header-icons");
if (!icons) {
disconnectHeaderIconsObserver();
return;
}
if (state.headerIconsObserverTarget === icons && state.headerIconsObserver) return;
disconnectHeaderIconsObserver();
state.headerIconsObserver = new MutationObserver(scheduleSyncEntryPoints);
state.headerIconsObserver.observe(icons, { childList: true });
state.headerIconsObserverTarget = icons;
}
function getDiscourseLookup() {
if (!window.Discourse || typeof window.Discourse.lookup !== "function") return null;
return window.Discourse.lookup.bind(window.Discourse);
}
function handleDiscoursePageChanged(payload) {
const replacedOnlyQueryParams = !!(
payload &&
typeof payload === "object" &&
payload.replacedOnlyQueryParams === true
);
closePanel();
if (isFrontendRoute()) {
if (replacedOnlyQueryParams) {
observeHeaderIconsContainer();
if (!document.getElementById(HEADER_ENTRY_ID)) scheduleSyncEntryPoints();
return;
}
applyTheme();
syncEntryPoints();
observeHeaderIconsContainer();
} else {
deactivateTheme();
syncEntryPoints();
disconnectHeaderIconsObserver();
}
}
function handleDiscourseInterfaceColorChanged(mode) {
const normalizedMode = mode === "dark" || mode === "light" ? mode : null;
const service = state.interfaceColor;
const resolvedMode =
service?.colorModeIsAuto === true ? getSchemeType() : normalizedMode || getSchemeType();
if (resolvedMode !== "dark" && resolvedMode !== "light") return;
state.lastForcedForumMode = resolvedMode;
const selection = resolveActiveThemeSelection();
if (selection.kind !== "preset") return;
const matchedPresetId = getMatchedPresetIdByScheme(selection.presetId, resolvedMode);
if (!matchedPresetId || matchedPresetId === selection.presetId) return;
state.config.activeThemeRef = makePresetThemeRef(matchedPresetId);
state.config.activePresetId = matchedPresetId;
saveConfig();
applyTheme();
if (state.panel) renderPanel();
syncEntryPoints();
if (state.isPanelOpen) {
setStatus(`已跟随论坛色彩模式切换:${PRESETS[matchedPresetId]?.name || matchedPresetId}。`);
}
}
function bindDiscourseServices() {
if (state.discourseServicesBound || state.discourseBindTimer) return;
const stopTimer = () => {
clearStateTimer("discourseBindTimer");
};
const bindAttempt = () => {
const lookup = getDiscourseLookup();
if (lookup) {
let appEvents = null;
let interfaceColor = null;
try {
appEvents = lookup("service:app-events");
interfaceColor = lookup("service:interface-color");
} catch {
appEvents = null;
interfaceColor = null;
}
if (appEvents) {
stopTimer();
if (state.appEvents && state.onPageChangedHandler) {
state.appEvents.off(DISCOURSE_EVENT_PAGE_CHANGED, state.onPageChangedHandler);
}
if (state.appEvents && state.onInterfaceColorChangedHandler) {
state.appEvents.off(DISCOURSE_EVENT_INTERFACE_COLOR_CHANGED, state.onInterfaceColorChangedHandler);
}
state.appEvents = appEvents;
state.interfaceColor = interfaceColor || null;
state.onPageChangedHandler = (payload) => handleDiscoursePageChanged(payload);
state.onInterfaceColorChangedHandler = (mode) => {
const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : "";
handleDiscourseInterfaceColorChanged(normalizedMode);
};
state.appEvents.on(DISCOURSE_EVENT_PAGE_CHANGED, state.onPageChangedHandler);
state.appEvents.on(DISCOURSE_EVENT_INTERFACE_COLOR_CHANGED, state.onInterfaceColorChangedHandler);
state.discourseServicesBound = true;
handleDiscoursePageChanged();
return;
}
}
if (!state.discourseBindStartAt) state.discourseBindStartAt = Date.now();
if (Date.now() - state.discourseBindStartAt >= DISCOURSE_BIND_TIMEOUT_MS) {
stopTimer();
if (!state.discourseBindWarned) {
state.discourseBindWarned = true;
console.warn("[linuxdo-theme] Discourse services not available within timeout; applying current page only.");
}
return;
}
state.discourseBindTimer = setTimeout(() => {
state.discourseBindTimer = null;
bindAttempt();
}, DISCOURSE_BIND_INTERVAL_MS);
};
runSafely("bind discourse services", bindAttempt);
}
function escapeHtml(value) {
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
}
function initDefaultsIfMissing() {
if (!isPresetId(state.config.activePresetId)) state.config.activePresetId = getDefaultPresetId();
if (!Array.isArray(state.config.themeLibrary)) state.config.themeLibrary = [];
state.config.settings = normalizeSettings(state.config.settings, DEFAULT_SETTINGS);
if (typeof state.config.customTheme?.name !== "string" || !state.config.customTheme.name.trim()) state.config.customTheme.name = "我的主题";
if (!isPresetId(state.config.customTheme.basePreset)) state.config.customTheme.basePreset = state.config.activePresetId;
if (!state.config.customTheme.patches || typeof state.config.customTheme.patches !== "object") state.config.customTheme.patches = clone(DEFAULT_PATCHES);
syncCustomThemeToLibrary();
state.config.activeThemeRef = resolveActiveThemeRef(state.config, state.config.activeThemeRef, state.config.activePresetId);
const active = resolveActiveThemeSelection();
if (active.kind === "library") {
state.config.activePresetId = active.entry.basePreset;
} else {
state.config.activePresetId = active.presetId;
}
}
function bindEscToClose() {
if (state.isEscBound) return;
document.addEventListener("keydown", (event) => {
runSafely("keydown handler", () => {
if (event.key === "Escape") closePanel();
});
});
state.isEscBound = true;
}
function bootstrap() {
if (state.isBootstrapped) return;
state.isBootstrapped = true;
repairPresetRegistry();
state.config = loadConfig();
initDefaultsIfMissing();
ensureStyles();
applyTheme();
const ready = () => {
runSafely("sync entry points", syncEntryPoints);
bindDiscourseServices();
bindEscToClose();
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", ready, { once: true });
else ready();
}
function exposeTestAPI() {
globalThis.__LDT_TEST_API__ = {
state,
PRESETS,
normalizeTokenValue,
normalizeTokens,
buildVarsCSS,
colorToRgbTuple,
syncDiscourseColorMode,
bindDiscourseServices,
bootstrap,
};
}
if (TEST_MODE) exposeTestAPI();
else bootstrap();
})();