From e717dc2ac58fced69b612646ec774af7e56ec9d2 Mon Sep 17 00:00:00 2001 From: weiweiplus0527 Date: Tue, 11 Aug 2026 11:13:48 +0800 Subject: [PATCH] feat(skin-studio): desktop theme editor plugin Full-featured Hermes Desktop theme editor (deep Chinese localization of Theme Forge, MIT): wallpapers/video backdrops, extended palette, bold & typography, auto-color from images, global FX layer (Matrix digit wall / hanzi rain / CRT scanlines) decoupled from color themes, one-click apply via the native skins path (plugin.js + dashboard/plugin_api.py backend). --- README.md | 3 + skin-studio/LICENSE | 22 + skin-studio/README.md | 115 + skin-studio/dashboard/manifest.json | 4 + skin-studio/dashboard/plugin_api.py | 123 + skin-studio/plugin.js | 3492 +++++++++++++++++++++++++++ 6 files changed, 3759 insertions(+) create mode 100644 skin-studio/LICENSE create mode 100644 skin-studio/README.md create mode 100644 skin-studio/dashboard/manifest.json create mode 100644 skin-studio/dashboard/plugin_api.py create mode 100644 skin-studio/plugin.js diff --git a/README.md b/README.md index 2d22f05..6a6fb7c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ These are **not bundled with `hermes-agent`**. The core repo ships only the plug | [`plugin-llm-async-example`](./plugin-llm-async-example) | `ctx.llm.acomplete()` + `asyncio.gather()` | Async LLM lane — concurrent forward + sentiment + back-translation pass for `/translate` | | [`example-dashboard`](./example-dashboard) | `dashboard/manifest.json` | Bare-minimum dashboard plugin — a tab, a slot injection, a backend route | | [`strike-freedom-cockpit`](./strike-freedom-cockpit) | dashboard theme + slot plugin | Complete custom-skin reskin — palette, layout variant, asset slots, sidebar HUD | +| [`skin-studio`](./skin-studio) | desktop theme editor + dashboard backend | Full theme editor — wallpapers/video backdrops, extended palette, bold/typography, global FX layer (digit wall / hanzi rain / CRT scanlines), one-click apply via the native skins path | ## Installing an example as a user plugin @@ -25,6 +26,8 @@ cp -r hermes-example-plugins/plugin-llm-example ~/.hermes/plugins/ cp -r hermes-example-plugins/plugin-llm-async-example ~/.hermes/plugins/ cp -r hermes-example-plugins/example-dashboard ~/.hermes/plugins/ cp -r hermes-example-plugins/strike-freedom-cockpit ~/.hermes/plugins/ +cp -r hermes-example-plugins/skin-studio ~/.hermes/plugins/ +cp -r hermes-example-plugins/skin-studio ~/.hermes/desktop-plugins/ # enable any with a slash command surface hermes plugins enable plugin-llm-example diff --git a/skin-studio/LICENSE b/skin-studio/LICENSE new file mode 100644 index 0000000..660ad5c --- /dev/null +++ b/skin-studio/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Theme Forge contributors +Copyright (c) 2026 Skin Studio contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skin-studio/README.md b/skin-studio/README.md new file mode 100644 index 0000000..9844769 --- /dev/null +++ b/skin-studio/README.md @@ -0,0 +1,115 @@ +# Skin Studio (皮肤工坊) — Dress up your Hermes Desktop + +**A Hermes Desktop theme editor plugin: wallpaper / animated video backdrops, extended palette, text bold levels, chat font size, markdown accent colors, and animated rain backdrops (Matrix-style digit wall & traditional-Chinese-character rain).** + +- ✅ No build step, no app-code changes — one ESM file + an optional Python backend +- ✅ **One-click apply** via Hermes' native skin path (`/skin`-equivalent hot path, repaints every surface in ~1s) +- ✅ **Live editing** — changes repaint the active theme immediately +- ✅ **15 preset themes**: 6 Chinese-style + 4 anime-style + 5 classics +- ✅ **Auto-color from image** (median-cut extraction + WCAG contrast guarantee) + 10 palette templates +- ✅ **Video media library**: large videos stored in IndexedDB, survive restarts, click-to-apply +- ✅ **Global FX layer**: rain effects decoupled from themes — pair the digit wall or hanzi rain with *any* color theme + +> This plugin is a deep localization of the open-source [Theme Forge](https://github.com/NousResearch/hermes-example-plugins/pull/8) (MIT, by criptogus): fully Chinese UI, Chinese-style & anime-style themes, hanzi-rain FX, auto-color, video library, and the global FX layer. + +## Included themes + +### Chinese style + +| Theme | Look | FX | +|---|---|---| +| **水墨 Ink** | Rice-paper white + ink black + cinnabar seal red | — | +| **青花瓷 Blue-and-white** | Glaze white + cobalt blue, oriental elegance | — | +| **故宫 Forbidden City** | Palace-wall red + glazed gold | — | +| **竹林 Bamboo** | Bamboo green + ink green, airy | — | +| **敦煌 Dunhuang** | Sand gold + mural ochre, Silk Road colors | — | +| **汉字雨 Hanzi Rain** | Ink-black paper + seal red | 🌧️ **Traditional-hanzi rain** (parchment-white drops + cinnabar accents) | + +### Anime style + +| Theme | Look | FX | +|---|---|---| +| **EVA 初号机 Unit-01** | Eva purple + phosphor green | — | +| **高达 Gundam** | RX-78 white/blue/red | — | +| **初音未来 Hatsune Miku** | Miku teal | — | +| **鬼灭之刃 Demon Slayer** | Tanjiro black-green check + nichirin red | — | + +### Classics (inherited from Theme Forge) + +| Theme | Look | FX | +|---|---|---| +| **赛博 Cyber** | Pure black + phosphor green + purple + gold | — | +| **玻璃 Glass** | Cool translucent neutrals + cyan | — | +| **纸感 Paper** | Warm paper light + terracotta | — | +| **数字墙 Digit Wall** | Pure black + phosphor green | 🌧️ **Matrix-style digit wall** (full-screen 0–9 grid, in-place refresh flow — no falling, no stacking, dim green so it never steals focus from text) | +| **黑客 Hacker** | Terminal green + purple + gold | 📺 CRT scanlines | +| **自定义 Custom** | Your editable theme (persists) | Everything adjustable | + +## What it does that Hermes can't natively + +| Feature | Why it doesn't exist natively | +|---|---| +| **Wallpaper / animated video backdrop** + overlay/blur | The `DesktopTheme` model only supports solid colors | +| **Extended palette** — `--ui-red/green/blue/purple/yellow/cyan/orange/warm` | Those colors are fixed in the app's `styles.css` | +| **Text bold levels** (3 levels) | No per-theme typography weight control | +| **Conversation font size** (12–48px for FX, 11–18px text) | Token exists but no UI | +| **Markdown accent colors** (headings, links, chat code) | Not configurable | +| **Digit wall / hanzi rain / CRT scanlines** | Impossible in the theme model | +| **One-click theme apply** | The desktop doesn't expose `setTheme` to plugins | + +## Global FX layer (new in Skin Studio) + +Rain effects are a **separate layer from color themes** — pick any theme and overlay any FX: + +``` +[跟随主题 Follow theme] [无 None] [数字墙 Digit wall] [汉字雨 Hanzi rain] +``` + +- `Follow theme` keeps each theme's built-in FX (classic behavior) +- `Digit wall` / `Hanzi rain` apply globally — switch color themes freely, the FX persists +- Font size & speed sliders tune the rain globally +- CRT scanlines get their own three-state switch (follow / on / off) and are **mutually exclusive with the digit wall** (a full-screen digit grid + horizontal lines reads as noise) +- FX config lives in its own storage key, themes stay clean + +## Installation + +### 1. Desktop plugin (required) + +```bash +mkdir -p ~/.hermes/desktop-plugins +cp -R skin-studio ~/.hermes/desktop-plugins/ +``` + +The desktop app hot-reloads the folder (or ⌘K → **Reload desktop plugins**). + +### 2. Python backend (for one-click "Apply") + +```bash +mkdir -p ~/.hermes/plugins/skin-studio +cp -R skin-studio/dashboard ~/.hermes/plugins/skin-studio/ +``` + +Then make sure `skin-studio` is in `plugins.enabled` in `~/.hermes/config.yaml` (as a **YAML list**, not a stringified JSON string): + +```yaml +plugins: + enabled: + - skin-studio +``` + +The backend writes `$HERMES_HOME/skins/.yaml` and sets `display.skin`, so the gateway's skin watcher broadcasts `skin.changed` and every surface repaints live — no restart needed. Restart the app after installing the backend (routes mount at startup only). + +Without the backend, themes still work: pick them via ⌘K → **Themes**. + +## Usage + +1. Open **皮肤工坊** from the sidebar (or ⌘K → "皮肤工坊") +2. **颜色** tab: hand-tune any color module (all Chinese labels), or click a palette template / auto-color from an image +3. **背景** tab: wallpaper URL / local image / local video (IndexedDB library keeps the last 5), overlay & blur +4. **文字** tab: bold levels, font size, markdown accents +5. **背景特效(全局层)**: pick the global FX layer & scanlines +6. Click **应用** to activate as the live Hermes skin (global — CLI/TUI repaint too) + +## License + +MIT — see [LICENSE](./LICENSE). Forked from [Theme Forge](https://github.com/NousResearch/hermes-example-plugins/pull/8) (MIT). Film/anime presets are palette homages with no official affiliation. diff --git a/skin-studio/dashboard/manifest.json b/skin-studio/dashboard/manifest.json new file mode 100644 index 0000000..00ad5a7 --- /dev/null +++ b/skin-studio/dashboard/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "skin-studio", + "api": "plugin_api.py" +} diff --git a/skin-studio/dashboard/plugin_api.py b/skin-studio/dashboard/plugin_api.py new file mode 100644 index 0000000..f2b9176 --- /dev/null +++ b/skin-studio/dashboard/plugin_api.py @@ -0,0 +1,123 @@ +"""皮肤工坊(Skin Studio)backend — 把皮肤工坊主题应用为实时 Hermes 皮肤。 + +Rides the canonical skin path: writes ``$HERMES_HOME/skins/.yaml`` and +sets ``display.skin`` so the gateway's skin watcher broadcasts ``skin.changed`` +— the same live path the ``/skin`` command uses. Every Hermes surface (CLI, +TUI, desktop) repaints within ~1s, no restart needed. + +Mounted at ``POST /api/plugins/skin-studio/activate`` by the desktop SDK's +plugin backend (``ctx.rest``). Only accepts a whitelisted subset of the +canonical skin tokens, validated as hex colors — never free-form YAML. + +复刻自 Theme Forge(MIT),品牌与文档改为皮肤工坊。 +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +import yaml +from fastapi import APIRouter + +from hermes_constants import get_hermes_home + +router = APIRouter() + +_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") +_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$") + +# Whitelisted canonical skin color tokens (see apps/shared/src/skin.ts). +_ALLOWED_TOKENS = { + "background", + "ui_text", + "ui_accent", + "ui_primary", + "ui_border", + "ui_error", + "banner_dim", + "banner_title", + "banner_text", + "status_bar_bg", + "completion_menu_bg", + "ui_ok", + "ui_warn", +} + + +def _skins_dir() -> Path: + return get_hermes_home() / "skins" + + +@router.post("/activate") +async def activate(body: dict): + name = str((body or {}).get("name") or "").strip() + if not _NAME_RE.match(name): + return {"ok": False, "error": "invalid skin name"} + + raw_colors = (body or {}).get("colors") or {} + if not isinstance(raw_colors, dict): + return {"ok": False, "error": "colors must be an object"} + + colors = { + key: value + for key, value in raw_colors.items() + if key in _ALLOWED_TOKENS and _HEX_RE.match(str(value)) + } + if not colors: + return {"ok": False, "error": "no valid colors provided"} + + label = str((body or {}).get("label") or name) + description = str((body or {}).get("description") or f"Forge theme {name}") + + data = { + "name": name, + "description": description, + "colors": colors, + } + + path = _skins_dir() / f"{name}.yaml" + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8") + except OSError as exc: # pragma: no cover - filesystem edge + return {"ok": False, "error": f"write failed: {exc}"} + + try: + from hermes_cli.config import config_command + + config_command( + argparse.Namespace(config_command="set", key="display.skin", value=name, force=True) + ) + except Exception as exc: # pragma: no cover - surface backend failure to the UI + return {"ok": False, "error": f"config set failed: {exc}"} + + return {"ok": True, "name": name, "label": label, "skin_path": str(path)} + + +@router.post("/reassert") +async def reassert(body: dict): + """Re-assert the active forge skin after an app/backend restart. + + The desktop applies a skin only when the gateway broadcasts ``skin.changed`` + (it fires when ``display.skin`` or the skin YAML mtime changes). On boot the + desktop does NOT re-apply the configured skin, so after an update the theme + falls back to the app's local storage. Re-writing the same value bumps the + config mtime, which makes the gateway watcher broadcast again — every + surface re-applies the theme with no manual step. + """ + name = str((body or {}).get("name") or "").strip() + if not _NAME_RE.match(name): + return {"ok": False, "error": "invalid skin name"} + if not (_skins_dir() / f"{name}.yaml").exists(): + return {"ok": False, "error": f"skin {name} not found — apply it from the editor first"} + try: + from hermes_cli.config import config_command + + config_command( + argparse.Namespace(config_command="set", key="display.skin", value=name, force=True) + ) + except Exception as exc: # pragma: no cover - surface backend failure to the UI + return {"ok": False, "error": f"config set failed: {exc}"} + return {"ok": True, "name": name, "reasserted": True} diff --git a/skin-studio/plugin.js b/skin-studio/plugin.js new file mode 100644 index 0000000..58fe979 --- /dev/null +++ b/skin-studio/plugin.js @@ -0,0 +1,3492 @@ +/** + * skin-studio — 皮肤工坊 for Hermes Desktop. + * + * 复刻自 Theme Forge(https://github.com/NousResearch/hermes-example-plugins/pull/8, + * MIT 协议),深度改造: + * - 全中文界面(原版为葡萄牙语) + * - 新增中国风主题:水墨 / 青花瓷 / 故宫 / 竹林 / 敦煌 / 汉字雨 + * - 新增动漫风主题:EVA 初号机 / 高达 / 初音未来 / 鬼灭之刃 + * - 汉字雨特效:中文汉字从墨夜坠落,偶见朱砂印章红(中国版数字墙) + * + * 技术机制沿用原版:在原生 DesktopTheme 模型之外扩展—— + * - 扩展调色板(应用固定的 --ui-* 色标:红橙黄绿青蓝紫暖、diff 增删) + * - 背景图片(URL 或 data URI)+ 遮罩 + 模糊,画在玻璃外壳后面 + * - 文字加粗等级(提升 font-weight 一至两级) + * + * 扩展字段随主题对象传递(应用忽略未知键),由本插件的 CSS 注入引擎读回。 + * + * Plain ESM, loaded uncompiled — UI is jsx() calls, not JSX syntax. + * Only these imports resolve: @hermes/plugin-sdk, react, react/jsx-runtime. + */ + +import { + Badge, + Button, + cn, + host, + Input, + ScrollArea, + Tabs, + TabsList, + TabsTrigger, + Tip, + PALETTE_AREA, + THEMES_AREA, + ROUTES_AREA, + SIDEBAR_NAV_AREA, + icons +} from '@hermes/plugin-sdk' +import { jsx, jsxs } from 'react/jsx-runtime' +import { useEffect, useRef, useState } from 'react' + +// ───────────────────────────────────────────────────────────────────────────── +// Presets — the forge ships three full themes + one editable custom theme. +// The custom theme starts as a clone of ss-cyber (the user's cyberpunk +// palette: #000 / #A8FF00 / #B026FF / #FFD700). +// ───────────────────────────────────────────────────────────────────────────── + +const EMOJI = '"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", emoji' +const SANS = + '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, system-ui, sans-serif, ' + EMOJI +const MONO = '"JetBrains Mono", "Cascadia Code", "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace, ' + EMOJI + +const FONT_URL = + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700;800&display=swap' + +const SYSTEM_SANS = '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif, ' + EMOJI +const SYSTEM_MONO = '"SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace, ' + EMOJI + +// Font presets for the Text tab — each sets typography.fontSans/fontMono/fontUrl +// on the custom theme; applyTheme() re-applies them (and injects fontUrl) on +// every registry-bump repaint. +const FONT_PRESETS = { + system: { label: '系统', fontSans: SYSTEM_SANS, fontMono: SYSTEM_MONO, fontUrl: null }, + inter: { label: 'Inter', fontSans: SANS, fontMono: MONO, fontUrl: FONT_URL }, + geist: { + label: 'Geist', + fontSans: '"Geist", "SF Pro Text", -apple-system, BlinkMacSystemFont, system-ui, sans-serif, ' + EMOJI, + fontMono: '"Geist Mono", "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace, ' + EMOJI, + fontUrl: 'https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500;700&display=swap' + }, + plex: { + label: 'Plex', + fontSans: '"IBM Plex Sans", "SF Pro Text", -apple-system, BlinkMacSystemFont, system-ui, sans-serif, ' + EMOJI, + fontMono: '"IBM Plex Mono", "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace, ' + EMOJI, + fontUrl: + 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;700&display=swap' + }, + grotesk: { + label: 'Grotesk', + fontSans: '"Space Grotesk", "SF Pro Text", -apple-system, BlinkMacSystemFont, system-ui, sans-serif, ' + EMOJI, + fontMono: MONO, + fontUrl: + 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap' + } +} + +// Defaults for the Text-tab extras (merged into every theme's .forge). +const TEXT_DEFAULTS = { + fontFamily: 'inter', + fontSize: 13, // px base for the conversation text (app default: 13px) + linkColor: null, + headingColor: null, + codeColor: null, + backgroundVideo: null, // animated backdrop (URL/data URI/blob) — wins over backgroundImage + // Sidebar per-element colors (null = theme default). Scoped token overrides + // via the app's stable data-slot hooks — hover/active keep text-foreground. + sidebarNavColor: null, // New Session · Capabilities · Messaging · Artifacts + sidebarSectionColor: null, // group labels: Pinned, Recents, … + sidebarItemColor: null // session + project row labels +} + +const forgeCyber = { + name: 'ss-cyber', + label: '赛博', + description: '纯黑底、荧光绿字、紫色镀铬、金色光环——完整赛博朋克配色', + colors: { + background: '#000000', + foreground: '#A8FF00', + card: '#060608', + cardForeground: '#A8FF00', + muted: '#0E0E12', + mutedForeground: '#6D9900', + popover: '#0A0A0D', + popoverForeground: '#A8FF00', + primary: '#A8FF00', + primaryForeground: '#000000', + secondary: '#14141A', + secondaryForeground: '#8FCC00', + accent: '#101018', + accentForeground: '#BFFF00', + border: '#2A103A', + input: '#2A103A', + ring: '#FFD700', + midground: '#A8FF00', + destructive: '#FF2E88', + destructiveForeground: '#000000', + sidebarBackground: '#000000', + sidebarBorder: '#1A0828', + userBubble: '#08080E', + userBubbleBorder: '#2A103A' + }, + darkColors: { + background: '#000000', + foreground: '#A8FF00', + card: '#060608', + cardForeground: '#A8FF00', + muted: '#0E0E12', + mutedForeground: '#6D9900', + popover: '#0A0A0D', + popoverForeground: '#A8FF00', + primary: '#A8FF00', + primaryForeground: '#000000', + secondary: '#14141A', + secondaryForeground: '#8FCC00', + accent: '#101018', + accentForeground: '#BFFF00', + border: '#2A103A', + input: '#2A103A', + ring: '#FFD700', + midground: '#A8FF00', + destructive: '#FF2E88', + destructiveForeground: '#000000', + sidebarBackground: '#000000', + sidebarBorder: '#1A0828', + userBubble: '#08080E', + userBubbleBorder: '#2A103A' + }, + typography: { fontSans: SANS, fontMono: MONO, fontUrl: FONT_URL }, + forge: { + backgroundImage: null, + imageFit: 'cover', + overlayOpacity: 0.15, + blur: 0, + boldLevel: 1, + extraColors: { + uiRed: '#FF2E88', + uiOrange: '#FF8A3D', + uiYellow: '#FFD700', + uiGreen: '#A8FF00', + uiCyan: '#00E5FF', + uiBlue: '#4D7CFF', + uiPurple: '#B026FF', + uiWarm: '#FFD700' + } + } +} + +const forgeGlass = { + name: 'ss-glass', + label: '玻璃', + description: '冷调半透明中性色,青色点缀——玻璃质感', + colors: { + background: '#0B0F14', + foreground: '#DCE6F0', + card: '#10161D', + cardForeground: '#DCE6F0', + muted: '#141C25', + mutedForeground: '#7E8EA1', + popover: '#121A22', + popoverForeground: '#DCE6F0', + primary: '#DCE6F0', + primaryForeground: '#0B0F14', + secondary: '#1A2430', + secondaryForeground: '#B6C4D4', + accent: '#16202B', + accentForeground: '#C8D6E6', + border: '#1E2A38', + input: '#1E2A38', + ring: '#00C8E8', + midground: '#00C8E8', + destructive: '#D64545', + destructiveForeground: '#FFF5F5', + sidebarBackground: '#090D11', + sidebarBorder: '#141C24', + userBubble: '#12202B', + userBubbleBorder: '#1E3A48' + }, + darkColors: { + background: '#0B0F14', + foreground: '#DCE6F0', + card: '#10161D', + cardForeground: '#DCE6F0', + muted: '#141C25', + mutedForeground: '#7E8EA1', + popover: '#121A22', + popoverForeground: '#DCE6F0', + primary: '#DCE6F0', + primaryForeground: '#0B0F14', + secondary: '#1A2430', + secondaryForeground: '#B6C4D4', + accent: '#16202B', + accentForeground: '#C8D6E6', + border: '#1E2A38', + input: '#1E2A38', + ring: '#00C8E8', + midground: '#00C8E8', + destructive: '#D64545', + destructiveForeground: '#FFF5F5', + sidebarBackground: '#090D11', + sidebarBorder: '#141C24', + userBubble: '#12202B', + userBubbleBorder: '#1E3A48' + }, + typography: { fontSans: SANS, fontMono: MONO, fontUrl: FONT_URL }, + forge: { + backgroundImage: null, + imageFit: 'cover', + overlayOpacity: 0.6, + blur: 0, + boldLevel: 0, + extraColors: { + uiRed: '#E5484D', + uiOrange: '#E58B3D', + uiYellow: '#D9B43C', + uiGreen: '#30A46C', + uiCyan: '#12A594', + uiBlue: '#0091FF', + uiPurple: '#8E4EC6', + uiWarm: '#C99B7A' + } + } +} + +const forgePaper = { + name: 'ss-paper', + label: '纸感', + description: '暖调纸感浅色,墨色文字与陶土色点缀', + colors: { + background: '#FAF6F0', + foreground: '#23201C', + card: '#FFFFFF', + cardForeground: '#23201C', + muted: '#F1EAE0', + mutedForeground: '#7A7166', + popover: '#FFFDF9', + popoverForeground: '#23201C', + primary: '#B4552D', + primaryForeground: '#FFFFFF', + secondary: '#F4EDE3', + secondaryForeground: '#4A4238', + accent: '#EFE6D8', + accentForeground: '#3A332B', + border: '#E4DACA', + input: '#E4DACA', + ring: '#B4552D', + midground: '#B4552D', + destructive: '#C0392B', + destructiveForeground: '#FFF5F2', + sidebarBackground: '#F3ECE1', + sidebarBorder: '#E2D6C4', + userBubble: '#F4E9DB', + userBubbleBorder: '#E2D2BC' + }, + darkColors: { + background: '#141210', + foreground: '#E8E0D4', + card: '#1B1815', + cardForeground: '#E8E0D4', + muted: '#241F1B', + mutedForeground: '#9C9184', + popover: '#1F1B17', + popoverForeground: '#E8E0D4', + primary: '#D9804F', + primaryForeground: '#1B1109', + secondary: '#2A241E', + secondaryForeground: '#C8BCAC', + accent: '#262019', + accentForeground: '#DCCFC0', + border: '#332B23', + input: '#332B23', + ring: '#D9804F', + midground: '#D9804F', + destructive: '#D64545', + destructiveForeground: '#FFF5F5', + sidebarBackground: '#100E0C', + sidebarBorder: '#241F1A', + userBubble: '#241C15', + userBubbleBorder: '#3A2E22' + }, + typography: { fontSans: SANS, fontMono: MONO, fontUrl: FONT_URL }, + forge: { + backgroundImage: null, + imageFit: 'cover', + overlayOpacity: 0.25, + blur: 0, + boldLevel: 0, + extraColors: { + uiRed: '#C0392B', + uiOrange: '#D4760A', + uiYellow: '#B58A1E', + uiGreen: '#3E7C4F', + uiCyan: '#3A7E8C', + uiBlue: '#3A6EA5', + uiPurple: '#7D5BA6', + uiWarm: '#B4552D' + } + } +} + +/** Matrix — pure black, phosphor green, animated digital rain background. */ +const forgeMatrix = { + name: 'ss-matrix', + label: '数字墙', + description: '纯黑底磷光绿,黑客帝国式纯数字墙动画背景', + colors: { + background: '#000000', + foreground: '#00FF41', + card: '#00140A', + cardForeground: '#00FF41', + muted: '#002A12', + mutedForeground: '#2E8B57', + popover: '#001A0A', + popoverForeground: '#00FF41', + primary: '#00FF41', + primaryForeground: '#000000', + secondary: '#002A12', + secondaryForeground: '#66FF99', + accent: '#001A0A', + accentForeground: '#7CFFAB', + border: '#0A3D1A', + input: '#0A3D1A', + ring: '#00FF41', + midground: '#00FF41', + destructive: '#FF2E88', + destructiveForeground: '#000000', + sidebarBackground: '#000000', + sidebarBorder: '#062B12', + userBubble: '#00140A', + userBubbleBorder: '#0A3D1A' + }, + darkColors: { + background: '#000000', + foreground: '#00FF41', + card: '#00140A', + cardForeground: '#00FF41', + muted: '#002A12', + mutedForeground: '#2E8B57', + popover: '#001A0A', + popoverForeground: '#00FF41', + primary: '#00FF41', + primaryForeground: '#000000', + secondary: '#002A12', + secondaryForeground: '#66FF99', + accent: '#001A0A', + accentForeground: '#7CFFAB', + border: '#0A3D1A', + input: '#0A3D1A', + ring: '#00FF41', + midground: '#00FF41', + destructive: '#FF2E88', + destructiveForeground: '#000000', + sidebarBackground: '#000000', + sidebarBorder: '#062B12', + userBubble: '#00140A', + userBubbleBorder: '#0A3D1A' + }, + typography: { fontSans: SANS, fontMono: MONO, fontUrl: FONT_URL }, + forge: { + backgroundImage: null, + imageFit: 'cover', + overlayOpacity: 0.3, + blur: 0, + boldLevel: 0, + matrixRain: true, + extraColors: { + uiRed: '#FF2E88', + uiOrange: '#FF8A3D', + uiYellow: '#D4FF00', + uiGreen: '#00FF41', + uiCyan: '#00E5FF', + uiBlue: '#00A8FF', + uiPurple: '#B026FF', + uiWarm: '#00FF41' + } + } +} + +/** Hacker — lime terminal on black with purple chrome and gold highlights. */ +const forgeHacker = { + name: 'ss-hacker', + label: '黑客', + description: '荧光绿终端、紫色镀铬、金色光环——黑客风格', + colors: { + background: '#050505', + foreground: '#A8FF00', + card: '#0C0C0C', + cardForeground: '#A8FF00', + muted: '#121212', + mutedForeground: '#6D9900', + popover: '#0F0F0F', + popoverForeground: '#A8FF00', + primary: '#A8FF00', + primaryForeground: '#050505', + secondary: '#161616', + secondaryForeground: '#8FCC00', + accent: '#111111', + accentForeground: '#C6FF2E', + border: '#2A103A', + input: '#2A103A', + ring: '#FFD700', + midground: '#A8FF00', + destructive: '#FF2E88', + destructiveForeground: '#050505', + sidebarBackground: '#050505', + sidebarBorder: '#1A0828', + userBubble: '#0A0A0A', + userBubbleBorder: '#2A103A' + }, + darkColors: { + background: '#050505', + foreground: '#A8FF00', + card: '#0C0C0C', + cardForeground: '#A8FF00', + muted: '#121212', + mutedForeground: '#6D9900', + popover: '#0F0F0F', + popoverForeground: '#A8FF00', + primary: '#A8FF00', + primaryForeground: '#050505', + secondary: '#161616', + secondaryForeground: '#8FCC00', + accent: '#111111', + accentForeground: '#C6FF2E', + border: '#2A103A', + input: '#2A103A', + ring: '#FFD700', + midground: '#A8FF00', + destructive: '#FF2E88', + destructiveForeground: '#050505', + sidebarBackground: '#050505', + sidebarBorder: '#1A0828', + userBubble: '#0A0A0A', + userBubbleBorder: '#2A103A' + }, + typography: { fontSans: SANS, fontMono: MONO, fontUrl: FONT_URL }, + forge: { + backgroundImage: null, + imageFit: 'cover', + overlayOpacity: 0.4, + blur: 0, + boldLevel: 1, + scanlines: true, + extraColors: { + uiRed: '#FF2E88', + uiOrange: '#FF8A3D', + uiYellow: '#FFD700', + uiGreen: '#A8FF00', + uiCyan: '#00E5FF', + uiBlue: '#4D7CFF', + uiPurple: '#B026FF', + uiWarm: '#FFD700' + } + } +} + + +// ───────────────────────────────────────────────────────────────────────────── +// Small color helpers (same math family as the app's themes/color.ts). +// ───────────────────────────────────────────────────────────────────────────── + +function hexToRgb(hex) { + const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim()) + if (!m) return null + const n = parseInt(m[1], 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} + +function readableOn(hex) { + const rgb = hexToRgb(hex) + if (!rgb) return '#FFFFFF' + const [r, g, b] = rgb.map(v => v / 255) + const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b + return lum > 0.5 ? '#000000' : '#FFFFFF' +} + +function mix(hexA, hexB, t) { + const a = hexToRgb(hexA) + const b = hexToRgb(hexB) + if (!a || !b) return hexA + const c = a.map((v, i) => Math.round(v + (b[i] - v) * t)) + return '#' + c.map(v => v.toString(16).padStart(2, '0')).join('') +} + +// ───────────────────────────────────────────────────────────────────────────── +// 自动配色引擎 — 从图片提取主色板(中位切分),或从配色模板生成整套主题。 +// ───────────────────────────────────────────────────────────────────────────── + +function rgbToHex(r, g, b) { + const c = [r, g, b].map(v => Math.max(0, Math.min(255, Math.round(v)))) + return '#' + c.map(v => v.toString(16).padStart(2, '0')).join('') +} + +function colorLum(hex) { + const rgb = hexToRgb(hex) + if (!rgb) return 0 + const [r, g, b] = rgb.map(v => v / 255) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +function colorSat(hex) { + const rgb = hexToRgb(hex) + if (!rgb) return 0 + const [r, g, b] = rgb.map(v => v / 255) + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + if (max === min) return 0 + const d = max - min + return l > 0.5 ? d / (2 - max - min) : d / (max + min) +} + +function hexToHsl(hex) { + const rgb = hexToRgb(hex) + if (!rgb) return { h: 0, s: 0, l: 0 } + const [r, g, b] = rgb.map(v => v / 255) + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + if (max === min) return { h: 0, s: 0, l } + const d = max - min + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + let h = 0 + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6 + else if (max === g) h = ((b - r) / d + 2) / 6 + else h = ((r - g) / d + 4) / 6 + return { h: h * 360, s, l } +} + +function hslToHex(h, s, l) { + const hue = ((h % 360) + 360) % 360 / 360 + let r, g, b + if (s === 0) { + r = g = b = l + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + return p + } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + r = hue2rgb(p, q, hue + 1 / 3) + g = hue2rgb(p, q, hue) + b = hue2rgb(p, q, hue - 1 / 3) + } + return rgbToHex(r * 255, g * 255, b * 255) +} + +// 色相旋转(保持饱和度/亮度)——用于从强调色生成和谐扩展色板。 +function hueShift(hex, deg) { + const { h, s, l } = hexToHsl(hex) + return hslToHex(h + deg, s, l) +} + +// 中位切分(median cut):递归按 RGB 范围最大的通道对半分,取桶内平均色。 +function medianCut(pixels, depth) { + if (depth === 0 || pixels.length < 8) { + const n = pixels.length + const sum = pixels.reduce((a, p) => [a[0] + p[0], a[1] + p[1], a[2] + p[2]], [0, 0, 0]) + return [[Math.round(sum[0] / n), Math.round(sum[1] / n), Math.round(sum[2] / n)]] + } + let chan = 0 + let maxRange = -1 + for (let c = 0; c < 3; c++) { + let lo = 255, hi = 0 + for (const p of pixels) { + if (p[c] < lo) lo = p[c] + if (p[c] > hi) hi = p[c] + } + const range = hi - lo + if (range > maxRange) { + maxRange = range + chan = c + } + } + if (maxRange < 24) { + const n = pixels.length + const sum = pixels.reduce((a, p) => [a[0] + p[0], a[1] + p[1], a[2] + p[2]], [0, 0, 0]) + return [[Math.round(sum[0] / n), Math.round(sum[1] / n), Math.round(sum[2] / n)]] + } + const sorted = [...pixels].sort((a, b) => a[chan] - b[chan]) + const mid = Math.floor(sorted.length / 2) + return [...medianCut(sorted.slice(0, mid), depth - 1), ...medianCut(sorted.slice(mid), depth - 1)] +} + +// 从图片(URL / data URI)提取主色板(最多 8 色)。 +function extractPaletteFromImage(src) { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => { + try { + const size = 48 + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d') + ctx.drawImage(img, 0, 0, size, size) + const { data } = ctx.getImageData(0, 0, size, size) + const pixels = [] + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] < 128) continue + pixels.push([data[i], data[i + 1], data[i + 2]]) + } + if (pixels.length < 32) { + reject(new Error('图片像素不足')) + return + } + const sample = + pixels.length > 5000 ? pixels.filter(() => Math.random() < 5000 / pixels.length) : pixels + const sw = medianCut(sample, 3) + resolve(sw.map(([r, g, b]) => rgbToHex(r, g, b))) + } catch (e) { + reject(e) + } + } + img.onerror = () => reject(new Error('图片加载失败(跨域图片可能无法取色,请用本地图片)')) + img.src = src + }) +} + +// WCAG 对比度(用于可读性保证)。 +function contrastRatio(hexA, hexB) { + const la = colorLum(hexA) + const lb = colorLum(hexB) + const hi = Math.max(la, lb) + const lo = Math.min(la, lb) + return (hi + 0.05) / (lo + 0.05) +} + +// 从主色板生成整套主题配色(背景取最暗、文字取最亮、强调取最鲜艳)。 +function themeFromPalette(hexes) { + const cs = (hexes || []).filter(h => hexToRgb(h)) + if (cs.length < 2) return null + const byLum = [...cs].sort((a, b) => colorLum(a) - colorLum(b)) + const bySat = [...cs].sort((a, b) => colorSat(b) - colorSat(a)) + let bg = byLum[0] + let fg = byLum[byLum.length - 1] + let accent = bySat[0] + // 可读性保护:太亮的背景压暗、太暗的文字提亮、灰扑扑的强调色提饱和。 + if (colorLum(bg) > 0.3) bg = mix(bg, '#000000', 0.5) + if (colorLum(fg) < 0.35) fg = mix(fg, '#FFFFFF', 0.75) + if (colorSat(accent) < 0.35) { + // 强调色饱和度不足(灰图/白图):以文字色相为基础生成鲜艳强调色, + // 完全无色相时默认用暖橙(避免白色/灰色 accent 毫无存在感)。 + const { h } = hexToHsl(fg) + accent = hslToHex(h > 0 ? h : 25, 0.65, 0.55) + } + if (colorLum(accent) < 0.35) { + // HSL 提亮而非混白——保持色相与饱和度(避免暗红变粉)。 + const { h, s, l } = hexToHsl(accent) + accent = hslToHex(h, s, Math.min(l + 0.18, 0.72)) + } + // 对比度迭代保证:正文与背景 WCAG 对比度 >= 4.5(看不清的核心修复)。 + let guard = 0 + while (contrastRatio(fg, bg) < 4.5 && guard++ < 10) { + if (colorLum(fg) < 0.55) fg = mix(fg, '#FFFFFF', 0.3) + else bg = mix(bg, '#000000', 0.25) + } + // mutedForeground(次要文字)对比度保证 >= 3,并降低饱和度保持中性灰调 + //(避免 fg 是荧光色时次要文字也跟着变色,影响协调)。 + let mutedFg = mix(fg, bg, 0.42) + { + const { h, s, l } = hexToHsl(mutedFg) + mutedFg = hslToHex(h, s * 0.3, l) + } + guard = 0 + while (contrastRatio(mutedFg, bg) < 3 && guard++ < 8) { + mutedFg = mix(mutedFg, fg, 0.3) + } + const border = mix(bg, accent, 0.28) + const destructive = mix('#C0392B', accent, 0.25) + const colors = { + background: bg, + foreground: fg, + card: mix(bg, fg, 0.05), + cardForeground: fg, + muted: mix(bg, fg, 0.08), + mutedForeground: mutedFg, + popover: mix(bg, fg, 0.04), + popoverForeground: fg, + primary: accent, + primaryForeground: readableOn(accent), + secondary: mix(bg, accent, 0.16), + secondaryForeground: fg, + accent: mix(bg, accent, 0.12), + accentForeground: fg, + border, + input: border, + ring: accent, + midground: accent, + destructive, + destructiveForeground: readableOn(destructive), + sidebarBackground: bg, + sidebarBorder: mix(bg, fg, 0.12), + userBubble: mix(bg, accent, 0.1), + userBubbleBorder: border + } + // 扩展色板(语法高亮/状态色):使用标准色相轮的绝对色相——语义永远正确 + //(uiRed 是红色、uiGreen 是绿色),统一饱和/亮度保证在暗背景上清晰、 + // 整体协调(修「代码黑字看不清」+ 配色协调性)。 + const extraColors = { + uiRed: hslToHex(0, 0.65, 0.6), + uiOrange: hslToHex(30, 0.65, 0.6), + uiYellow: hslToHex(55, 0.65, 0.6), + uiGreen: hslToHex(140, 0.65, 0.6), + uiCyan: hslToHex(185, 0.65, 0.6), + uiBlue: hslToHex(220, 0.65, 0.6), + uiPurple: hslToHex(275, 0.65, 0.6), + uiWarm: hslToHex(25, 0.65, 0.6) + } + // Markdown 强调色自动生成:代码文字比正文略亮(保证代码块可读), + // 链接用强调色,标题用带强调色调的亮色——开箱即用,无需手动调。 + const forge = { + extraColors, + codeColor: mix(fg, '#FFFFFF', 0.2), + linkColor: accent, + headingColor: mix(fg, accent, 0.25) + } + return { colors, darkColors: colors, forge } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Film-inspired presets — generated from a 6-color palette so every theme has +// the full DesktopTheme shape the app expects (dark + light derived). +// ───────────────────────────────────────────────────────────────────────────── + +function filmTheme({ name, label, description, bg, fg, accent, border, destructive, mutedFg, extraColors, boldLevel = 1, overlayOpacity = 0.3, scanlines, matrixRain, hanziRain }) { + const dark = { + background: bg, + foreground: fg, + card: mix(bg, fg, 0.05), + cardForeground: fg, + muted: mix(bg, fg, 0.08), + mutedForeground: mutedFg || mix(fg, bg, 0.35), + popover: mix(bg, fg, 0.04), + popoverForeground: fg, + primary: accent, + primaryForeground: readableOn(accent), + secondary: mix(bg, accent, 0.16), + secondaryForeground: fg, + accent: mix(bg, accent, 0.12), + accentForeground: fg, + border, + input: border, + ring: accent, + midground: accent, + destructive, + destructiveForeground: readableOn(destructive), + sidebarBackground: bg, + sidebarBorder: mix(bg, fg, 0.12), + userBubble: mix(bg, accent, 0.1), + userBubbleBorder: border + } + const forge = { + backgroundImage: null, + imageFit: 'cover', + overlayOpacity, + blur: 0, + boldLevel, + extraColors: extraColors || {} + } + if (scanlines) forge.scanlines = true + if (matrixRain) forge.matrixRain = true + if (hanziRain) forge.hanziRain = true + return { + name, + label, + description, + colors: dark, + darkColors: dark, + typography: { fontSans: SANS, fontMono: MONO, fontUrl: FONT_URL }, + forge + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 中国风主题 — 水墨、青花瓷、故宫、竹林、敦煌、汉字雨 +// ───────────────────────────────────────────────────────────────────────────── + +/** 水墨 — 宣纸白 + 墨色 + 朱砂印章红,文人画意境。 */ +const ssInk = filmTheme({ + name: 'ss-ink', + label: '水墨', + description: '宣纸白与墨色,朱砂印章点睛——文人画意境', + bg: '#0D0C0A', + fg: '#E8E2D5', + accent: '#B0342A', + border: '#2A251C', + destructive: '#8A2A22', + mutedFg: '#8F8778', + boldLevel: 1, + overlayOpacity: 0.25, + extraColors: { uiRed: '#B0342A', uiWarm: '#C9A86A', uiGreen: '#4A6B4A', uiBlue: '#3A5A7A', uiYellow: '#B08D2E', uiCyan: '#4A6A6A', uiPurple: '#6E5A8E', uiOrange: '#A86A3A' } +}) + +/** 青花瓷 — 釉白 + 钴蓝,克制典雅的东方美学。 */ +const ssQinghua = filmTheme({ + name: 'ss-qinghua', + label: '青花瓷', + description: '釉白与钴蓝——青花瓷的东方典雅', + bg: '#0A0F16', + fg: '#DCE6EE', + accent: '#33779E', + border: '#1E3A52', + destructive: '#8A3A2E', + mutedFg: '#7E8E9E', + boldLevel: 1, + overlayOpacity: 0.25, + extraColors: { uiBlue: '#33779E', uiCyan: '#2E8CA8', uiGreen: '#2E7A6A', uiRed: '#B0342A', uiYellow: '#B08D2E', uiOrange: '#C77B2F', uiPurple: '#6A5A8E', uiWarm: '#8E9AA8' } +}) + +/** 故宫 — 宫墙红 + 琉璃金,皇家气派。 */ +const ssGugong = filmTheme({ + name: 'ss-gugong', + label: '故宫', + description: '宫墙红与琉璃金——紫禁城的皇家气派', + bg: '#140B0A', + fg: '#F0E4D0', + accent: '#B03A2E', + border: '#4A2A1E', + destructive: '#7A1E14', + mutedFg: '#A8977E', + boldLevel: 1, + overlayOpacity: 0.25, + extraColors: { uiRed: '#B03A2E', uiYellow: '#D4A017', uiOrange: '#C77B2F', uiWarm: '#D4A017', uiGreen: '#4A6B4A', uiCyan: '#4A6A8A', uiBlue: '#3A5A7A', uiPurple: '#7A4A6A' } +}) + +/** 竹林 — 竹青 + 墨绿,清雅空灵。 */ +const ssBamboo = filmTheme({ + name: 'ss-bamboo', + label: '竹林', + description: '竹青与墨绿——七贤竹林之清雅', + bg: '#0A0F0C', + fg: '#DCE8DA', + accent: '#4A8C5C', + border: '#1E3A28', + destructive: '#8A3A2E', + mutedFg: '#7E9A86', + boldLevel: 1, + overlayOpacity: 0.25, + extraColors: { uiGreen: '#4A8C5C', uiCyan: '#3E7A6A', uiBlue: '#3A5A7A', uiRed: '#B0413E', uiYellow: '#B08D2E', uiOrange: '#A86A3A', uiPurple: '#6E5A8E', uiWarm: '#8A9A6A' } +}) + +/** 敦煌 — 沙金 + 壁画赭石,丝路色彩。 */ +const ssDunhuang = filmTheme({ + name: 'ss-dunhuang', + label: '敦煌', + description: '沙金与壁画赭石——丝路石窟的千年色彩', + bg: '#140E08', + fg: '#E8DCC0', + accent: '#C77B2F', + border: '#4A3520', + destructive: '#8A2E2E', + mutedFg: '#A89778', + boldLevel: 1, + overlayOpacity: 0.25, + extraColors: { uiOrange: '#C77B2F', uiYellow: '#D9A441', uiWarm: '#D9A441', uiRed: '#B0413E', uiGreen: '#7A8B3E', uiBlue: '#5A6E8C', uiCyan: '#4A7A7A', uiPurple: '#8E6A4E' } +}) + +/** 汉字雨 — 墨色宣纸 + 印章红,汉字从天而降(中国版 Matrix)。 */ +const ssHanziRain = filmTheme({ + name: 'ss-hanzi-rain', + label: '汉字雨', + description: '墨黑宣纸上汉字飘落,偶见朱砂印章红——中国版数字墙', + bg: '#0B0A08', + fg: '#F5F1E8', + accent: '#C0392B', + border: '#2A251C', + destructive: '#8A2A22', + mutedFg: '#9A927E', + boldLevel: 1, + overlayOpacity: 0.15, + matrixRain: true, + hanziRain: true, + // 汉字雨专属参数:繁体大字、无媒体时慢速飘落(预设值,可在自定义主题中调节)。 + rainFontSize: 26, + rainSpeed: 0.3, + extraColors: { uiRed: '#C0392B', uiWarm: '#B08D2E', uiGreen: '#4A6B4A', uiBlue: '#3A5A7A', uiYellow: '#B08D2E', uiCyan: '#4A6A6A', uiPurple: '#6E5A8E', uiOrange: '#A86A3A' } +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 动漫风主题 — EVA、高达、初音未来、鬼灭之刃(调色板灵感致敬,无官方关联) +// ───────────────────────────────────────────────────────────────────────────── + +/** EVA 初号机 — 深紫 + 荧光绿,新世纪福音战士。 */ +const ssEva = filmTheme({ + name: 'ss-eva', + label: 'EVA 初号机', + description: '初号机紫与荧光绿——新世纪福音战士配色', + bg: '#0A0A14', + fg: '#E8E8F0', + accent: '#00E56A', + border: '#2E2450', + destructive: '#E53935', + mutedFg: '#8A8A9E', + boldLevel: 2, + overlayOpacity: 0.3, + extraColors: { uiPurple: '#7B2FBE', uiGreen: '#00E56A', uiCyan: '#00E5FF', uiRed: '#E53935', uiBlue: '#4D7CFF', uiYellow: '#FFD700', uiOrange: '#FF8A3D', uiWarm: '#00E56A' } +}) + +/** 高达 — RX-78 白蓝红,机动战士高达。 */ +const ssGundam = filmTheme({ + name: 'ss-gundam', + label: '高达', + description: 'RX-78 白蓝红三色——机动战士高达', + bg: '#0E1116', + fg: '#E8ECF0', + accent: '#1E6BB8', + border: '#2A3542', + destructive: '#D64545', + mutedFg: '#8A94A0', + boldLevel: 1, + overlayOpacity: 0.3, + extraColors: { uiBlue: '#1E6BB8', uiRed: '#D64545', uiYellow: '#E8C93D', uiGreen: '#30A46C', uiCyan: '#4DD0E1', uiOrange: '#FF8A3D', uiPurple: '#8E4EC6', uiWarm: '#E8C93D' } +}) + +/** 初音未来 — 青绿双马尾,VOCALOID 代表色。 */ +const ssMiku = filmTheme({ + name: 'ss-miku', + label: '初音未来', + description: '初音青绿——VOCALOID 的代表色', + bg: '#0A0E12', + fg: '#E8F0EE', + accent: '#39C5BB', + border: '#1E3A3A', + destructive: '#E53935', + mutedFg: '#7E9A96', + boldLevel: 1, + overlayOpacity: 0.25, + extraColors: { uiCyan: '#39C5BB', uiGreen: '#2ECC71', uiBlue: '#4D7CFF', uiRed: '#E53935', uiYellow: '#E8C93D', uiOrange: '#FF8A3D', uiPurple: '#8E4EC6', uiWarm: '#39C5BB' } +}) + +/** 鬼灭之刃 — 炭治郎黑绿格纹 + 日轮刀红。 */ +const ssKimetsu = filmTheme({ + name: 'ss-kimetsu', + label: '鬼灭之刃', + description: '炭治郎黑绿格纹与日轮刀红——鬼灭之刃', + bg: '#0B0D0A', + fg: '#E8ECE0', + accent: '#C0392B', + border: '#26301E', + destructive: '#8A1E1E', + mutedFg: '#8A9684', + boldLevel: 1, + overlayOpacity: 0.3, + extraColors: { uiGreen: '#2E8B57', uiRed: '#C0392B', uiWarm: '#8A7A5A', uiYellow: '#B08D2E', uiBlue: '#3A5A7A', uiCyan: '#4A7A6A', uiOrange: '#A86A3A', uiPurple: '#6E5A8E' } +}) + +// ───────────────────────────────────────────────────────────────────────────── +// The forge state: the editable custom theme + its extras. Persisted as JSON +// in ctx.storage (namespace hermes.plugin.skin-studio.*). +// ───────────────────────────────────────────────────────────────────────────── + +const STORAGE_KEY = 'ss-custom-v1' +// The wallpaper data URI lives in its OWN storage key so a large image can +// never block the theme save (blur/overlay/colors are small and always +// persist). Before, the whole theme — image included — went into one JSON: +// a big data URI blew the localStorage quota and the ENTIRE save failed +// silently, so slider tweaks rolled back on every reload. +const STORAGE_IMG_KEY = 'ss-custom-img-v1' +// 本地视频改存 IndexedDB(容量可达 GB 级,不再受 localStorage 3.5MB 限制)。 +// 主题 JSON 里只存 videoId 标记,启动时从 IDB 读回 blob → objectURL。 +const STORAGE_VIDEO_ID_KEY = 'ss-video-id' +// 视频媒体库:IndexedDB 存视频本体,localStorage 只存元数据列表 +//([{id, name, size, date}]),最多保留 VIDEO_LIB_MAX 个,超出自动清理最旧。 +const STORAGE_VIDEO_LIB_KEY = 'ss-video-lib' +const VIDEO_LIB_MAX = 5 +const IDB_NAME = 'skin-studio-media' +const IDB_STORE = 'videos' +// 全局特效配置(独立于主题的「特效层」):mode = auto(跟随主题) / +// none(关闭) / matrix(数字墙) / hanzi(汉字雨);rainFontSize / rainSpeed +// 为 null 时跟随主题默认。可与任意配色主题搭配使用。 +const STORAGE_FX_KEY = 'ss-fx-v1' + +function readVideoLib() { + try { + const raw = storage?.get(STORAGE_VIDEO_LIB_KEY, null) + const arr = raw ? JSON.parse(raw) : [] + return Array.isArray(arr) ? arr : [] + } catch { + return [] + } +} + +function writeVideoLib(lib) { + storage?.set(STORAGE_VIDEO_LIB_KEY, JSON.stringify(lib.slice(0, VIDEO_LIB_MAX))) +} + +// ── 全局特效层(独立于主题,可与任意配色搭配)── +function loadFxGlobal() { + try { + const raw = storage?.get(STORAGE_FX_KEY, null) + const o = raw ? JSON.parse(raw) : {} + return { + mode: ['auto', 'none', 'matrix', 'hanzi'].includes(o.mode) ? o.mode : 'auto', + rainFontSize: Number(o.rainFontSize) || null, + rainSpeed: Number(o.rainSpeed) || null, + scanlines: ['on', 'off'].includes(o.scanlines) ? o.scanlines : 'auto' + } + } catch { + return { mode: 'auto', rainFontSize: null, rainSpeed: null, scanlines: 'auto' } + } +} + +function saveFxGlobal(fx) { + storage?.set(STORAGE_FX_KEY, JSON.stringify(fx)) +} + +function idbOpen() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(IDB_NAME, 1) + req.onupgradeneeded = () => { + if (!req.result.objectStoreNames.contains(IDB_STORE)) { + req.result.createObjectStore(IDB_STORE) + } + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +async function idbPut(key, blob) { + const db = await idbOpen() + return new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE, 'readwrite') + tx.objectStore(IDB_STORE).put(blob, key) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} + +async function idbGet(key) { + const db = await idbOpen() + return new Promise((resolve, reject) => { + const req = db.transaction(IDB_STORE).objectStore(IDB_STORE).get(key) + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +async function idbDel(key) { + const db = await idbOpen() + return new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE, 'readwrite') + tx.objectStore(IDB_STORE).delete(key) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} + +// 请求持久化存储(IndexedDB 默认为「尽力而为」模式,配额小且可能被系统 +// 清理;persist() 后数据稳定保留,配额也大幅放宽)。 +async function ensurePersistentStorage() { + try { + if (navigator.storage && navigator.storage.persist) { + await navigator.storage.persist() + } + } catch { + // 非致命:尽力而为模式下仍可写入,只是配额较小。 + } +} + +// 查询当前存储配额与使用量(供诊断显示)。 +async function storageEstimate() { + try { + if (!navigator.storage || !navigator.storage.estimate) return null + const e = await navigator.storage.estimate() + return { usage: e.usage, quota: e.quota } + } catch { + return null + } +} + +const PRESETS = [forgeCyber, forgeGlass, forgePaper, forgeMatrix, forgeHacker, ssInk, ssQinghua, ssGugong, ssBamboo, ssDunhuang, ssHanziRain, ssEva, ssGundam, ssMiku, ssKimetsu] +const CUSTOM_NAME = 'ss-custom' + +let storage = null // injected from register(ctx) +let themeMap = new Map() // name -> theme object (with .forge extras) +let customTheme = null // live-editable custom theme +let customDisposer = null // registry disposer for the custom theme contribution + +function defaultCustom() { + const base = JSON.parse(JSON.stringify(forgeCyber)) + return { + ...base, + name: CUSTOM_NAME, + label: '皮肤工坊 · 自定义', + description: '可编辑主题——在皮肤工坊面板中调整', + forge: { ...TEXT_DEFAULTS, ...base.forge } + } +} + +function loadCustom() { + try { + const raw = storage?.get(STORAGE_KEY, null) + if (!raw) return defaultCustom() + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && parsed.colors && parsed.forge) { + const base = defaultCustom() + const forge = { ...TEXT_DEFAULTS, ...base.forge, ...(parsed.forge || {}) } + // blob: URLs die with the session that minted them. 有 localVideoId 标记的 + // 说明视频存在 IndexedDB,restoreLocalVideo() 会异步读回(这里先清空); + // 没有标记的旧 blob 是死引用,直接清。 + if (typeof forge.backgroundVideo === 'string' && forge.backgroundVideo.startsWith('blob:')) { + if (!forge.localVideoId) forge.backgroundVideo = null + } + // Wallpaper data URI from its dedicated key (large images live there so + // they can't block the theme save). Older saves carry the data URI + // inline in forge.backgroundImage — migrate it to the dedicated key. + const imgKey = storage?.get(STORAGE_IMG_KEY, null) + const inline = typeof forge.backgroundImage === 'string' && forge.backgroundImage.startsWith('data:') + if (typeof imgKey === 'string' && imgKey.startsWith('data:')) { + forge.backgroundImage = imgKey + } else if (inline) { + // keep as-is; next saveCustom() migrates it out of the theme JSON + } + return { + ...base, + ...parsed, + forge + } + } + return defaultCustom() + } catch { + return defaultCustom() + } +} + +let saveWarned = false + +// 安全通知:模块级函数里 host 是 SDK 导入(组件/函数里若有局部变量 +// 也叫 host 会遮蔽它,导致 host.notify 不是函数——统一走这里)。 +function notifyUser(msg) { + try { + host.notify(msg) + } catch { + // 通知失败不影响功能 + } +} + +// 启动时从 IndexedDB 恢复本地视频(大视频持久化的关键一步)。 +// 完成后通过全局事件通知面板组件同步 state(否则面板的防抖保存会用 +// 无视频的旧 state 覆盖掉刚恢复的视频)。 +async function restoreLocalVideo() { + try { + const vid = storage?.get(STORAGE_VIDEO_ID_KEY, null) + if (!vid || !customTheme) return + const blob = await idbGet(vid) + if (blob) { + const url = URL.createObjectURL(blob) + customTheme.forge.backgroundVideo = url + customTheme.forge.localVideoId = vid + publishCustom() + const entry = readVideoLib().find(e => e.id === vid) + document.dispatchEvent( + new CustomEvent('skin-studio:video-restored', { + detail: { url, id: vid, name: entry ? entry.name : '视频', size: entry ? entry.size : blob.size } + }) + ) + } else { + // IDB 记录丢失(数据库被清)——清理标记,避免死引用。 + storage?.remove(STORAGE_VIDEO_ID_KEY) + if (customTheme.forge.localVideoId) { + customTheme.forge.localVideoId = null + customTheme.forge.backgroundVideo = null + publishCustom() + } + } + } catch { + // 静默失败:主题本身仍可用,只是视频回到未设置状态。 + } +} + +function saveCustom() { + // Split the payload: the theme (colors, blur, overlay, text) saves ALWAYS — + // a big wallpaper data URI must never be able to roll back slider tweaks. + // The image goes to its own key; if IT overflows, only the image is lost. + // blob: 视频 URL 不写进 localStorage(会话级引用,重启失效)—— + // 视频本体存在 IndexedDB,restoreLocalVideo() 负责恢复。 + const forge = customTheme.forge || {} + const inlineImg = forge.backgroundImage + const isData = typeof inlineImg === 'string' && inlineImg.startsWith('data:') + const isBlobVideo = + typeof forge.backgroundVideo === 'string' && forge.backgroundVideo.startsWith('blob:') + const themeForSave = isData || isBlobVideo + ? { + ...customTheme, + forge: { + ...forge, + backgroundImage: isData ? null : forge.backgroundImage, + backgroundVideo: isBlobVideo ? null : forge.backgroundVideo + } + } + : customTheme + try { + storage?.set(STORAGE_KEY, JSON.stringify(themeForSave)) + saveWarned = false + } catch { + if (!saveWarned && typeof host !== 'undefined') { + saveWarned = true + host.notify({ + kind: 'error', + message: '主题保存失败(存储空间已满)。' // pragma: no cover + }) + } + } + try { + if (isData) storage?.set(STORAGE_IMG_KEY, inlineImg) + else if (!forge.backgroundImage) storage?.remove(STORAGE_IMG_KEY) + } catch { + // Only the wallpaper is lost on reload — theme tweaks are already safe. + if (!saveWarned && typeof host !== 'undefined') { + saveWarned = true + host.notify({ + kind: 'error', + message: '背景图片超出存储容量——请用更小的文件或粘贴 URL。' + }) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// CSS injection engine. +// +// The app's applyTheme() paints --theme-* / --dt-* seeds + data-hermes-theme +// on . We watch those attributes (same technique as the app's own +// onThemeRepaint) and, when the active theme is one of ours, mount a