diff --git a/AGENTS.md b/AGENTS.md
index d7c4d77..1db56db 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -64,7 +64,7 @@ src/
│ ├── utils.ts # Binary utilities
│ ├── sftp.ts # SFTP v3 client implementation
│ └── sftp-types.ts # SFTP protocol constants and types
-├── theme-schema.ts # Theme V2 shared validation, allowlists, enums, and size limits
+├── theme-schema.ts # Theme V3 shared validation(外观/背景/效果/版式模块、白名单与读性遮罩下限)
├── snippet-schema.ts # Command snippet shared validation, limits, and normalization (UserDBDO + localStorage)
└── types.ts # Shared TypeScript type definitions
@@ -82,7 +82,7 @@ frontend/
│ ├── os-icons.ts # 操作系统品牌图标(内嵌 simple-icons SVG)
│ ├── port.ts # 端口解析与 1-65535 校验
│ ├── regions.ts # DO locationHint 区域选项共享数据(Auto + 白名单)
-│ ├── theme.ts # Theme V2 内置主题、UI CSS 变量与外观预设
+│ ├── theme.ts # Theme V3 内置主题、UI CSS 变量、外观预设、背景/效果合成与版式缩放
│ ├── auth-challenge-dialog.ts # RFC 4256 multi-round authentication prompt UI
│ ├── mobile-terminal.ts # Mobile viewport, shortcut toolbar, clipboard and landscape controller
│ ├── mobile-input.ts # Pure iOS IME diff and one-shot modifier helpers
@@ -308,7 +308,7 @@ release: 发布 vX.Y.Z <主题>版本(如 `release: 发布 v1.10.2 工作流
13. **SFTP selection model** - file selection supports single, Cmd/Ctrl toggle, Shift range and select-all. Batch download reuses the sequential download queue; batch delete waits for all delete/rmdir results before refreshing.
14. **Agent terminal selection context** - “Ask AI assistant” attaches one immutable selection snapshot per tab and never sends it by itself. New selections replace the pending snapshot; successful sends and session teardown clear it. Preserve the untrusted-data/non-authorization boundary in `terminal-selection-context.ts`.
15. **Region inference privacy** - Saving or changing a Cloudflare-direct server host calls the third-party IPinfo service and persists the inferred locationHint. Servers with `jump_server_id` are downstream nodes: never query their hosts, ignore and clear their own region hints, and infer once if they later become direct Auto entries. Keep the provider name and disclosure synchronized across README/code comments; failures must continue to fall back to Cloudflare's default placement.
-16. **Theme editor ownership** - The full visual editor and JSON export live in `docs/theme-editor/index.html` for GitHub Pages and never authenticate against CloudSSH. `scripts/sync-theme-editor.js` keeps its built-in colors and resolved appearance presets aligned with `frontend/src/theme.ts`; the application and Worker share Theme V2 validation through `src/theme-schema.ts`. The application only imports JSON themes and synchronizes the single custom-theme slot through `/api/user/theme` for signed-in users; later imports replace the previous theme, while anonymous themes remain local.
+16. **Theme editor ownership** - The full visual editor and JSON export live in `docs/theme-editor/index.html` for GitHub Pages and never authenticate against CloudSSH. `scripts/sync-theme-editor.js` keeps its built-in colors, resolved appearance presets, background layers and effect presets aligned with `frontend/src/theme.ts`; the application and Worker share Theme V3 validation through `src/theme-schema.ts` (appearance enums + V3 background/effects/typography modules, all declarative and whitelist-validated). The application only imports JSON themes and synchronizes the single custom-theme slot through `/api/user/theme` for signed-in users; later imports replace the previous theme, while anonymous themes remain local. V3 effect/background layers are driven by CSS variables and data attributes only — no user CSS/JS is ever evaluated, and background gradients compose exclusively from whitelist-validated color stops.
17. **Mobile terminal input and recovery** - Mobile shortcuts and the iOS keyCode 229 fallback must continue through `TrzszFilter.processTerminalInput`; never send them directly to the WebSocket, and do not permit any terminal input until `shell_ready`. For iOS IME fallback, capture the textarea baseline on `keydown=229` but flush on the corresponding `keyup` regardless of its key code, since Safari commonly reports 32 for Space and 0 for punctuation; xterm `onData` remains authoritative to prevent duplicate input. Keep the explicit mobile selection mode isolated from desktop mouse auto-copy, map touch drags through xterm's public selection API instead of native long-press selection, and debounce visual viewport refits. Enable background-return visibility recovery only when the device has touch points and a coarse primary pointer, so desktop tab changes do not emit recovery logs or probes. A mobile background return must validate the WebSocket with a bounded ID-matched heartbeat instead of trusting `readyState`; anonymous reconnects may reuse only the current in-memory config, while saved-server reconnects must request a fresh one-time token from `/api/servers/:id/connect`. Never report a connection as online before the replacement Shell is ready.
18. **Saved-server OS detection** - Run OS detection only for signed-in saved servers without a persisted result, through a separate non-blocking SSH exec channel after Shell readiness. Never persist `unknown`; host or port changes must clear the stored OS, and background metadata updates must not change `updated_at` or server ordering. Keep backend canonical keys synchronized with frontend labels/icon fallbacks.
19. **Keyboard-interactive authentication** - Begin user authentication with the RFC 4252 `none` probe and choose only methods advertised by the server, while retaining the bounded compatibility fallback for servers that omit the list. RFC 4256 challenges are event-driven during the SSH auth state. Keep method-specific message type 60 disambiguated by the active auth method, use `partial_success` to advance bounded multi-factor stages, and only fall back without partial success when the server no longer offers the configured primary method. Bind browser responses and `auth_challenge_ack` display acknowledgements to one random challenge ID and originating WebSocket, distinguish an undisplayed challenge from an acknowledged but unanswered challenge, never log responses, clear pending challenges on timeout/reconnect/close, require explicit user action before substituting a stored password, and close authentication timeouts normally so older frontends cannot reconnect repeatedly. Treat ordinary server-side credential rejection as an expected close rather than WebSocket error 1011.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7041b6..9c12c9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.13.0] - 2026-08-27
+
+### Added
+
+- **Theme V3 背景层**:新增 `background` 顶层模块(schemaVersion 3),支持 solid / linear / radial / mesh 四类背景,最多 5 个停靠点(均过白名单颜色校验)、线性角度(0–360)、读性遮罩(scrim,渐变背景强制暗色 ≥0.25 / 浅色 ≥0.35 的下限)与缓慢漂移(drift)动画;背景由 `body::before` 固定全屏层承载,纯 CSS 变量驱动、零 DOM 改动。
+- **Theme V3 效果注册表**:新增 `effects` 模块(scanline / flicker / glow / noise),强度钳制在 0–1;扫描线与闪烁门控从「cyberpunk 风格硬编码」泛化为 `data-fx-*` 属性驱动(Cyberpunk 内置主题迁移为参数化配置,默认视觉不变);噪点使用内联 feTurbulence SVG 纹理,无外部请求。
+- **Theme V3 版式与表面**:新增 `typography` 模块(字号缩放 0.85–1.25、圆角缩放 0.5–2,均叠加在密度/形状档位之上)与独立的 `appearance.blur` 表面模糊档位(none / subtle / strong);动效门控(`data-ui-motion`)与 `prefers-reduced-motion` 双重视角约束背景漂移与闪烁。
+- **两套旗舰内置主题**:内置主题从 5 款扩至 7 款,新增 **CRT Amber**(琥珀磷光深色主题:径向暗角背景 + 扫描线/闪烁/辉光/噪点全开 + 方形 mono 造型)与 **Glass**(蓝紫浅色玻璃态主题:mesh 粉彩漂移背景 + 强表面模糊 + 1.4× 圆角 + 分段控件);两者明暗对比、质感与动效差异显著。
+- **Apple 浅色主题**:内置主题 Glacier 替换为 Apple(macOS 风格浅色),占用 soft 风格预设占位并保持四种 UI 风格全覆盖;终端 ANSI 16 色采用 Apple 系统色无障碍变体,UI 变量取材 Apple 设计语言(#f5f5f7 背景、#0066da 链接蓝、#5856d6 systemIndigo Agent 色),文本/强调/错误色全部通过 4.5:1 对比度并纳入测试断言。
+- **在线主题编辑器支持 V3**:新增背景层(类型/角度/遮罩/动画/停靠点)、效果(四项强度滑杆)与版式缩放(字号/圆角)三组面板及 blur 档位;导出升级为 schemaVersion 3 并携带新模块,导入按与后端同源的钳制/白名单规则消毒;同步脚本提取 `BUILT_IN_BACKGROUND` / `BUILT_IN_EFFECTS` / `BUILT_IN_TYPOGRAPHY` 注入预设。
+
+### Changed
+
+- `THEME_SCHEMA_VERSION` 升至 3;V2 主题数据自动升级(新模块缺省即旧行为),存量 `baseTheme: 'glacier'` 主题导入时优雅降级(字段丢弃、按明暗模式回退基底),旧版 glacier 选择在 localStorage 中一次性迁移到 Standard Dark。
+- 表面模糊由 shadow 预设派生值改为独立 blur 档位完全接管(后置规则同优先级胜出),geometry 变量(圆角/字号)改为 calc 乘法组合缩放。
+
+### Fixed
+
+- 补充既有 lint 清洁(forEach 回调返回值、未使用导入)。
+
## [1.12.2] - 2026-08-27
### Changed
diff --git a/README.md b/README.md
index 84963bd..67aa4e2 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@
- **全功能极客终端**:基于 `@xterm/xterm` 与 `@xterm/addon-webgl` 硬件加速渲染引擎,保证海量日志输出顺滑不卡顿。
- **可靠的终端剪贴板交互**:鼠标完成终端选区后自动复制,右键可直接粘贴;触摸设备点击快捷键栏的复制按钮进入选择模式,拖动选择文本后再次点击完成复制,避免依赖不稳定的长按选区,粘贴则使用独立按钮。粘贴统一经过 xterm.js 原生输入管线,仅在远端应用启用 bracketed paste 模式时发送对应控制序列,并自动规范化换行,兼容 Vim 等交互式编辑器和普通 Shell。
- **移动端终端适配**:针对手机和平板提供动态可视高度、软键盘与安全区适配、iOS 中文输入法兼容、紧凑工具栏、一次性 Ctrl/Alt、Esc/Tab/方向键/Home/End/PgUp/PgDn 等快捷键,以及移动端全屏 Agent/SFTP 面板。页面从后台返回后会主动验证 WebSocket,淘汰表面在线但已失效的连接;匿名会话使用当前内存凭据重新建立 SSH,登录用户的已保存服务器则重新申请一次性连接令牌,且只有收到 `shell_ready` 后才恢复“已连接”状态和终端输入。用户可主动尝试“全屏横屏”;浏览器不支持方向锁定时会回退为手动旋转提示,不会强制改变桌面端布局。移动系统若彻底回收网页,当前 Shell 仍无法无缝续接。
-- **个性化 UI**:Theme V2 系统提供 Standard Dark、Standard Light、Cyberpunk、Glacier、Gruvbox 五款内置主题。配套 [GitHub Pages 主题编辑器](https://newbietan.github.io/CloudSSH/)可实时调整颜色、形状、密度、字体、阴影、动效及按钮/输入框/卡片/标签页样式,并预览登录页、服务器列表、终端 + SFTP 和 AI Agent 面板。主题通过 JSON 文件导入、导出、备份与分享;登录用户在应用中导入后会同步到账号并可跨浏览器恢复,匿名用户仅保存在当前浏览器。
+- **个性化 UI**:Theme V3 系统提供 Standard Dark、Standard Light、Cyberpunk、Apple、Gruvbox、CRT Amber、Glass 七款内置主题。V3 支持渐变/网格背景层(含读性遮罩与缓慢漂移动画)、扫描线/闪烁/辉光/噪点效果注册表、独立表面模糊档位与版式缩放,主体风格间差异显著。配套 [GitHub Pages 主题编辑器](https://newbietan.github.io/CloudSSH/)可实时调整颜色、形状、密度、字体、阴影、动效、背景层、效果及按钮/输入框/卡片/标签页样式,并预览登录页、服务器列表、终端 + SFTP 和 AI Agent 面板。主题通过 JSON 文件导入、导出、备份与分享;登录用户在应用中导入后会同步到账号并可跨浏览器恢复,匿名用户仅保存在当前浏览器。
- **SFTP 图形化文件管理**:集成完整的 SFTP v3 文件传输协议,提供图形化文件浏览器界面。支持目录浏览、文件上传/下载、新建文件夹、文件重命名与删除等操作;支持普通单选、`Cmd/Ctrl` 切换选择、`Shift` 连选、全选,以及批量下载文件和批量删除。基于 SSH 子系统实现,与终端会话并行运行,互不干扰,支持下载队列及上传取消。
- **原生文件传输**:集成 [trzsz.js](https://github.com/trzsz/trzsz.js),支持 `trz`(上传)/ `tsz`(下载)命令进行文件传输,兼容 tmux 会话。还支持拖拽文件到终端窗口直接上传、目录传输及断点续传等高级功能。(需远程服务器安装 [trzsz](https://trzsz.github.io/))
- **中英文界面**:内置简体中文与英文两套 UI 词条,自动跟随浏览器语言并提供手动切换,选择通过 URL 参数或本地存储(`cloudssh_locale`)持久化。
@@ -412,7 +412,7 @@ test 分支(开发/测试) ──合并──> main 分支(生产)
| ------------ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| **前端** | TypeScript + Vite + xterm.js | Web 终端模拟器,WebGL 硬件加速 |
| **国际化** | 自研轻量 i18n(`frontend/src/i18n`) | 简体中文 / English 双语言界面,浏览器语言自动识别与手动切换 |
-| **UI 框架** | Tailwind CSS(Vite/PostCSS 本地构建)+ Theme V2 系统 | 应用支持内置主题切换、自定义主题 JSON 导入及登录账号同步;主题编辑与导出由 GitHub Pages 提供 |
+| **UI 框架** | Tailwind CSS(Vite/PostCSS 本地构建)+ Theme V3 系统 | 应用支持内置主题切换、自定义主题 JSON 导入及登录账号同步;主题编辑与导出由 GitHub Pages 提供 |
| **文件传输** | trzsz.js | 支持 trz/tsz 命令、拖拽上传、断点续传 |
| **AI 助手** | BYOK + OpenAI 兼容接口 | 自带 API Key,支持 DeepSeek 等兼容模型 |
| **后端** | Cloudflare Workers | Serverless 边缘计算 |
diff --git a/README_en.md b/README_en.md
index 55a4807..ab1af70 100644
--- a/README_en.md
+++ b/README_en.md
@@ -94,7 +94,7 @@
- **Geek Terminal Experience**: Powered by `@xterm/xterm` and the `@xterm/addon-webgl` hardware acceleration rendering engine, ensuring silky smooth scrolling even with massive log outputs.
- **Reliable Terminal Clipboard Interaction**: Completing a terminal selection with a mouse automatically copies it, and right-click pastes directly. On touch devices, tapping Copy in the shortcut bar enters selection mode; drag across terminal text and tap Copy again to finish, avoiding unreliable long-press selection, while Paste remains a separate action. Paste data follows xterm.js's native input pipeline, emits bracketed-paste control sequences only when the remote application enables that mode, and normalizes line endings for compatibility with Vim and regular shells.
- **Mobile Terminal Support**: Phones and tablets get dynamic visual-viewport sizing, soft-keyboard and safe-area handling, iOS Chinese IME compatibility, a compact action bar, one-shot Ctrl/Alt, Esc/Tab/arrows/Home/End/PgUp/PgDn shortcuts, and full-screen Agent/SFTP panels. After a page returns from the background, CloudSSH actively validates the WebSocket and replaces connections that only appear open. Anonymous sessions rebuild SSH from the current in-memory credentials, while signed-in saved servers request a fresh one-time connection token; the UI and terminal input return to the connected state only after `shell_ready`. Users may explicitly request fullscreen landscape; unsupported orientation locks fall back to a manual rotation hint without changing desktop layouts. If the mobile OS completely discards the page, the current shell cannot be resumed seamlessly.
-- **Customizable UI**: Theme V2 includes Standard Dark, Standard Light, Cyberpunk, Glacier, and Gruvbox. The companion [GitHub Pages theme editor](https://newbietan.github.io/CloudSSH/) provides live controls for colors, shape, density, font, shadows, motion, and button/input/card/tab styles, with previews for login, server list, terminal + SFTP, and the AI Agent panel. Themes are imported, exported, backed up, and shared as JSON files. Signed-in users sync imported themes to their account for cross-browser restoration, while anonymous users keep them in the current browser only.
+- **Customizable UI**: Theme V3 offers built-in Standard Dark, Standard Light, Cyberpunk, Apple, Gruvbox, CRT Amber, and Glass themes. V3 adds gradient/mesh background layers (with a readability scrim and a slow drift animation), a scanline/flicker/glow/noise effect registry, an independent surface-blur tier, and typography scaling, so themes differ far beyond color swaps. The companion [GitHub Pages theme editor](https://newbietan.github.io/CloudSSH/) provides live controls for colors, shape, density, font, shadows, motion, background layers, effects, and button/input/card/tab styles, with previews for login, server list, terminal + SFTP, and the AI Agent panel. Themes are imported, exported, backed up, and shared as JSON files. Signed-in users sync imported themes to their account for cross-browser restoration, while anonymous users keep them in the current browser only.
- **SFTP Graphical File Manager**: Integrated with a complete SFTP v3 file transfer protocol, providing a graphical file browser interface. Supports directory browsing, file upload/download, creating new folders, file renaming, and deletion, plus plain selection, `Cmd/Ctrl` toggle selection, `Shift` range selection, select all, batch file downloads, and batch deletion. Built on the SSH subsystem, it runs alongside terminal sessions without interference and supports download queues and upload cancellation.
- **Native File Transfer**: Integrated with [trzsz.js](https://github.com/trzsz/trzsz.js), supporting `trz` (upload) / `tsz` (download) commands for file transfer, fully compatible with tmux sessions. Also supports drag-and-drop file upload to the terminal, directory transfer, and resumable transfers. (Requires [trzsz](https://trzsz.github.io/) installed on the remote server)
- **Bilingual UI**: Ships built-in Simplified Chinese and English translations, automatically following the browser language with a manual override; the choice is persisted via a URL parameter or local storage (`cloudssh_locale`).
@@ -412,7 +412,7 @@ test branch (dev/test) ──merge──> main branch (production)
| ------- | ------------ | ------------- |
| **Frontend** | TypeScript + Vite + xterm.js | Web terminal emulator, WebGL hardware acceleration |
| **i18n** | Lightweight custom i18n (`frontend/src/i18n`) | Simplified Chinese / English dual-language UI with automatic browser detection and manual switching |
-| **UI Framework** | Tailwind CSS (local Vite/PostCSS build) + Theme V2 | The app supports built-in theme switching, custom JSON import, and signed-in account sync; editing and export live on GitHub Pages |
+| **UI Framework** | Tailwind CSS (local Vite/PostCSS build) + Theme V3 | The app supports built-in theme switching, custom JSON import, and signed-in account sync; editing and export live on GitHub Pages |
| **File Transfer** | trzsz.js | Supports trz/tsz commands, drag-and-drop upload, resumable transfers |
| **AI Assistant** | BYOK + OpenAI-compatible API | Bring your own API key, supports DeepSeek and other compatible models |
| **Backend** | Cloudflare Workers | Serverless edge computing |
diff --git a/docs/theme-editor/index.html b/docs/theme-editor/index.html
index 714c409..e8bbe29 100644
--- a/docs/theme-editor/index.html
+++ b/docs/theme-editor/index.html
@@ -261,7 +261,7 @@
font-size: 11px;
}
- .setting-row input,
+ .setting-row input:not([type='range']),
.setting-row select {
width: 100%;
min-width: 0;
@@ -274,6 +274,16 @@
outline: none;
}
+ .setting-row:has(input[type='range']) {
+ grid-template-columns: minmax(110px, 1fr) minmax(96px, 1fr) 44px;
+ }
+
+ .setting-row .range-value {
+ font-size: 11px;
+ color: var(--editor-muted);
+ text-align: right;
+ }
+
.setting-row input:focus,
.setting-row select:focus {
border-color: var(--editor-accent);
@@ -365,6 +375,58 @@
--hover-lift: -2px;
font-family: var(--font-ui);
font-size: calc(1em * var(--font-size-scale));
+ position: relative;
+ isolation: isolate;
+ background-color: var(--bg);
+ background-image: var(--app-bg-stack, none);
+ }
+
+ /* V3:预览背景漂移(motion=none 时显式停止) */
+ .theme-scope[data-bg-animation='drift'] {
+ background-size: 200% 200%;
+ animation: theme-editor-bg-drift 40s ease-in-out infinite alternate;
+ }
+
+ .theme-scope[data-ui-motion='none'] {
+ animation: none;
+ }
+
+ @keyframes theme-editor-bg-drift {
+ 0% {
+ background-position: 0% 0%;
+ }
+ 100% {
+ background-position: 100% 100%;
+ }
+ }
+
+ /* V3:预览噪点层(垫在内容之下,仅作用于背景) */
+ .theme-scope[data-fx-noise='on']::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ opacity: var(--fx-noise-opacity, 0.05);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='en'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23en)'/%3E%3C/svg%3E");
+ }
+
+ /* V3:表面模糊档位(后置覆盖 shadow 派生值) */
+ .theme-scope[data-ui-blur='none'] {
+ --surface-blur: 0px;
+ }
+
+ .theme-scope[data-ui-blur='subtle'] {
+ --surface-blur: 6px;
+ }
+
+ .theme-scope[data-ui-blur='strong'] {
+ --surface-blur: 16px;
+ }
+
+ /* V3:预览辉光 */
+ .theme-scope[data-fx-glow='on'] .server-card {
+ box-shadow: var(--shadow-card), 0 4px calc(24px * var(--fx-glow-strength, 0.6)) var(--accent-glow);
}
.theme-scope[data-ui-shape="square"] {
@@ -589,12 +651,19 @@
display: none;
}
- .theme-scope[data-ui-style="standard"] :is(.scanlines, .scanline, .flicker),
- .theme-scope[data-ui-style="soft"] :is(.scanlines, .scanline, .flicker),
- .theme-scope[data-ui-style="dense"] :is(.scanlines, .scanline, .flicker) {
+ .theme-scope:not([data-fx-scanline='on']) :is(.scanlines, .scanline),
+ .theme-scope:not([data-fx-flicker='on']) .flicker {
display: none;
}
+ .theme-scope[data-fx-scanline='on'] .scanlines {
+ opacity: var(--fx-scanline-opacity, 1);
+ }
+
+ .theme-scope[data-fx-flicker='on'] .flicker {
+ animation-duration: var(--fx-flicker-speed, 0.15s);
+ }
+
.theme-scope[data-ui-motion="none"] * {
animation: none !important;
transition: none !important;
@@ -2115,8 +2184,10 @@
-
+
+
+
expand_more
@@ -2400,8 +2471,10 @@
-
+
+
+
upload_file
folder_open
@@ -2714,6 +2787,23 @@ const TRANSLATIONS = {
'group.basic': '主题信息',
'group.appearance': '整体外观',
'group.componentStyles': '组件样式',
+ 'group.themeV3Background': '背景层(V3)',
+ 'group.themeV3Effects': '效果(V3)',
+ 'group.themeV3Typography': '版式缩放(V3)',
+ 'background.type': '背景类型',
+ 'background.angle': '渐变角度',
+ 'background.scrim': '读性遮罩',
+ 'background.animation': '背景动画',
+ 'background.stop1': '停靠点 1',
+ 'background.stop2': '停靠点 2',
+ 'background.stop3': '停靠点 3',
+ 'background.stop4': '停靠点 4',
+ 'effects.scanline': '扫描线',
+ 'effects.flicker': '闪烁',
+ 'effects.glow': '辉光',
+ 'effects.noise': '噪点',
+ 'typography.fontScale': '字号缩放',
+ 'typography.radiusScale': '圆角缩放',
'appearance.name': '主题名称',
'appearance.colorScheme': '明暗模式',
'appearance.style': '整体风格',
@@ -2722,6 +2812,7 @@ const TRANSLATIONS = {
'appearance.font': '字体',
'appearance.shadow': '阴影',
'appearance.motion': '动效',
+ 'appearance.blur': '表面模糊',
'appearance.button': '按钮',
'appearance.input': '输入框',
'appearance.card': '卡片',
@@ -2742,6 +2833,11 @@ const TRANSLATIONS = {
'option.none': '无',
'option.subtle': '轻微',
'option.elevated': '悬浮',
+ 'option.solid': '纯色',
+ 'option.linear': '线性渐变',
+ 'option.radial': '径向渐变',
+ 'option.mesh': '网格渐变',
+ 'option.drift': '缓慢漂移',
'option.reduced': '精简',
'option.full': '完整',
'option.outline': '描边',
@@ -2870,6 +2966,23 @@ const TRANSLATIONS = {
'group.basic': 'THEME INFORMATION',
'group.appearance': 'APPEARANCE',
'group.componentStyles': 'COMPONENT STYLES',
+ 'group.themeV3Background': 'BACKGROUND (V3)',
+ 'group.themeV3Effects': 'EFFECTS (V3)',
+ 'group.themeV3Typography': 'TYPOGRAPHY SCALE (V3)',
+ 'background.type': 'Background type',
+ 'background.angle': 'Gradient angle',
+ 'background.scrim': 'Readability scrim',
+ 'background.animation': 'Background animation',
+ 'background.stop1': 'Stop 1',
+ 'background.stop2': 'Stop 2',
+ 'background.stop3': 'Stop 3',
+ 'background.stop4': 'Stop 4',
+ 'effects.scanline': 'Scanline',
+ 'effects.flicker': 'Flicker',
+ 'effects.glow': 'Glow',
+ 'effects.noise': 'Noise',
+ 'typography.fontScale': 'Font scale',
+ 'typography.radiusScale': 'Radius scale',
'appearance.name': 'Theme name',
'appearance.colorScheme': 'Color scheme',
'appearance.style': 'Style',
@@ -2878,6 +2991,7 @@ const TRANSLATIONS = {
'appearance.font': 'Font',
'appearance.shadow': 'Shadow',
'appearance.motion': 'Motion',
+ 'appearance.blur': 'Surface blur',
'appearance.button': 'Button',
'appearance.input': 'Input',
'appearance.card': 'Card',
@@ -2898,6 +3012,11 @@ const TRANSLATIONS = {
'option.none': 'None',
'option.subtle': 'Subtle',
'option.elevated': 'Elevated',
+ 'option.solid': 'Solid',
+ 'option.linear': 'Linear',
+ 'option.radial': 'Radial',
+ 'option.mesh': 'Mesh',
+ 'option.drift': 'Drift',
'option.reduced': 'Reduced',
'option.full': 'Full',
'option.outline': 'Outline',
@@ -3030,6 +3149,7 @@ const PRESETS = /* THEME_PRESETS_START */ {
"font": "system",
"shadow": "subtle",
"motion": "reduced",
+ "blur": "subtle",
"components": {
"button": "solid",
"input": "boxed",
@@ -3101,6 +3221,7 @@ const PRESETS = /* THEME_PRESETS_START */ {
"font": "system",
"shadow": "subtle",
"motion": "reduced",
+ "blur": "subtle",
"components": {
"button": "solid",
"input": "boxed",
@@ -3173,6 +3294,7 @@ const PRESETS = /* THEME_PRESETS_START */ {
"font": "mono",
"shadow": "none",
"motion": "full",
+ "blur": "none",
"components": {
"button": "outline",
"input": "underline",
@@ -3180,6 +3302,20 @@ const PRESETS = /* THEME_PRESETS_START */ {
"tabs": "underline"
}
},
+ "background": {
+ "type": "linear",
+ "stops": [
+ "#0a0a0a",
+ "#11170c"
+ ],
+ "angle": 165,
+ "scrim": 0.3,
+ "animation": "none"
+ },
+ "effects": {
+ "scanline": 1,
+ "flicker": 1
+ },
"terminal": {
"background": "#0a0a0a",
"foreground": "#4af626",
@@ -3217,9 +3353,9 @@ const PRESETS = /* THEME_PRESETS_START */ {
"--agent-agent-color": "#14d1ff"
}
},
- "glacier": {
- "name": "Glacier",
- "colorScheme": "dark",
+ "apple": {
+ "name": "Apple",
+ "colorScheme": "light",
"appearance": {
"style": "soft",
"shape": "soft",
@@ -3227,6 +3363,7 @@ const PRESETS = /* THEME_PRESETS_START */ {
"font": "system",
"shadow": "elevated",
"motion": "reduced",
+ "blur": "strong",
"components": {
"button": "soft",
"input": "boxed",
@@ -3235,40 +3372,58 @@ const PRESETS = /* THEME_PRESETS_START */ {
}
},
"terminal": {
- "background": "#0a192f",
- "foreground": "#64ffda",
- "cursor": "#e6f1ff",
- "cursorAccent": "#0a192f",
- "selectionBackground": "#112240"
+ "background": "#ffffff",
+ "foreground": "#1d1d1f",
+ "cursor": "#0066da",
+ "cursorAccent": "#ffffff",
+ "selectionBackground": "#b3d7ff",
+ "selectionForeground": "#1d1d1f",
+ "selectionInactiveBackground": "#dde8f7",
+ "black": "#1d1d1f",
+ "red": "#d70015",
+ "green": "#248a3d",
+ "yellow": "#b25000",
+ "blue": "#0040dd",
+ "magenta": "#8944ab",
+ "cyan": "#0b7285",
+ "white": "#8e8e93",
+ "brightBlack": "#6e6e73",
+ "brightRed": "#ff3b30",
+ "brightGreen": "#34c759",
+ "brightYellow": "#c93400",
+ "brightBlue": "#0071e3",
+ "brightMagenta": "#af52de",
+ "brightCyan": "#30b0c7",
+ "brightWhite": "#1d1d1f"
},
"ui": {
- "--bg": "#0a192f",
- "--bg-surface": "#0d2137",
- "--bg-elevated": "#112240",
- "--bg-terminal": "#061526",
- "--text": "#64ffda",
- "--text-muted": "#8892b0",
- "--text-dim": "#495670",
- "--accent": "#64ffda",
- "--accent-secondary": "#e6f1ff",
- "--accent-secondary-light": "#ccd6f6",
- "--border": "#1d3557",
- "--border-strong": "#495670",
- "--error": "#ff6b6b",
- "--error-bg": "#3d0000",
- "--on-accent": "#0a192f",
- "--surface-dot": "#1d3557",
- "--scrollbar-track": "rgba(10, 25, 47, 0.5)",
- "--scrollbar-thumb": "rgba(100, 255, 218, 0.2)",
- "--scrollbar-thumb-hover": "rgba(100, 255, 218, 0.4)",
- "--scanline-tint": "rgba(100, 255, 218, 0.02)",
- "--accent-glow": "rgba(100, 255, 218, 0.08)",
- "--accent-bg": "rgba(100, 255, 218, 0.1)",
- "--modal-overlay": "rgba(0, 0, 0, 0.85)",
- "--on-surface": "#e6f1ff",
- "--on-surface-variant": "#8892b0",
- "--agent-user-color": "#64ffda",
- "--agent-agent-color": "#e6f1ff"
+ "--bg": "#f5f5f7",
+ "--bg-surface": "#ffffff",
+ "--bg-elevated": "#ffffff",
+ "--bg-terminal": "#ffffff",
+ "--text": "#1d1d1f",
+ "--text-muted": "#636368",
+ "--text-dim": "#6e6e73",
+ "--accent": "#0066da",
+ "--accent-secondary": "#0052b4",
+ "--accent-secondary-light": "#cce4ff",
+ "--border": "#d2d2d7",
+ "--border-strong": "#aeaeb2",
+ "--error": "#d70015",
+ "--error-bg": "#ffebe9",
+ "--on-accent": "#ffffff",
+ "--surface-dot": "#e8e8ed",
+ "--scrollbar-track": "rgba(210, 210, 215, 0.45)",
+ "--scrollbar-thumb": "rgba(99, 99, 104, 0.35)",
+ "--scrollbar-thumb-hover": "rgba(99, 99, 104, 0.55)",
+ "--scanline-tint": "transparent",
+ "--accent-glow": "rgba(0, 102, 218, 0.08)",
+ "--accent-bg": "rgba(0, 102, 218, 0.1)",
+ "--modal-overlay": "rgba(0, 0, 0, 0.32)",
+ "--on-surface": "#1d1d1f",
+ "--on-surface-variant": "#636368",
+ "--agent-user-color": "#0066da",
+ "--agent-agent-color": "#5856d6"
}
},
"gruvbox": {
@@ -3281,6 +3436,7 @@ const PRESETS = /* THEME_PRESETS_START */ {
"font": "mono",
"shadow": "none",
"motion": "reduced",
+ "blur": "none",
"components": {
"button": "outline",
"input": "boxed",
@@ -3324,6 +3480,189 @@ const PRESETS = /* THEME_PRESETS_START */ {
"--agent-user-color": "#b8bb26",
"--agent-agent-color": "#83a598"
}
+ },
+ "crt": {
+ "name": "CRT Amber",
+ "colorScheme": "dark",
+ "appearance": {
+ "style": "cyberpunk",
+ "shape": "square",
+ "density": "compact",
+ "font": "mono",
+ "shadow": "none",
+ "motion": "full",
+ "blur": "none",
+ "components": {
+ "button": "outline",
+ "input": "underline",
+ "card": "outlined",
+ "tabs": "underline"
+ }
+ },
+ "background": {
+ "type": "radial",
+ "stops": [
+ "#261b00",
+ "#0f0a00"
+ ],
+ "angle": 160,
+ "scrim": 0.3,
+ "animation": "none"
+ },
+ "effects": {
+ "scanline": 1,
+ "flicker": 0.6,
+ "glow": 0.5
+ },
+ "typography": {
+ "fontScale": 1,
+ "radiusScale": 0.75
+ },
+ "terminal": {
+ "background": "#0f0a00",
+ "foreground": "#ffb000",
+ "cursor": "#ffd257",
+ "cursorAccent": "#0f0a00",
+ "selectionBackground": "#3d2e00",
+ "selectionForeground": "#ffb000",
+ "selectionInactiveBackground": "#2a2000",
+ "black": "#241a00",
+ "red": "#ff6b4a",
+ "green": "#d4a017",
+ "yellow": "#ffb000",
+ "blue": "#f0c040",
+ "magenta": "#e08e39",
+ "cyan": "#c8b04a",
+ "white": "#d9c08a",
+ "brightBlack": "#8a7440",
+ "brightRed": "#ffa080",
+ "brightGreen": "#ffd257",
+ "brightYellow": "#ffe08a",
+ "brightBlue": "#ffe8b0",
+ "brightMagenta": "#ffc27a",
+ "brightCyan": "#e8d89a",
+ "brightWhite": "#fff3c4"
+ },
+ "ui": {
+ "--bg": "#0f0a00",
+ "--bg-surface": "#171000",
+ "--bg-elevated": "#1d1600",
+ "--bg-terminal": "#0f0a00",
+ "--text": "#ffb000",
+ "--text-muted": "#bfa06a",
+ "--text-dim": "#937c46",
+ "--accent": "#ffb000",
+ "--accent-secondary": "#ffd257",
+ "--accent-secondary-light": "#ffe08a",
+ "--border": "#3d2e00",
+ "--border-strong": "#5c470a",
+ "--error": "#ff6b4a",
+ "--error-bg": "#3d1200",
+ "--on-accent": "#0f0a00",
+ "--surface-dot": "#3d2e00",
+ "--scrollbar-track": "rgba(23, 16, 0, 0.5)",
+ "--scrollbar-thumb": "rgba(255, 176, 0, 0.25)",
+ "--scrollbar-thumb-hover": "rgba(255, 176, 0, 0.45)",
+ "--scanline-tint": "rgba(255, 176, 0, 0.03)",
+ "--accent-glow": "rgba(255, 176, 0, 0.12)",
+ "--accent-bg": "rgba(255, 176, 0, 0.12)",
+ "--modal-overlay": "rgba(0, 0, 0, 0.8)",
+ "--on-surface": "#fff3c4",
+ "--on-surface-variant": "#bfa06a",
+ "--agent-user-color": "#ffb000",
+ "--agent-agent-color": "#ffd257"
+ }
+ },
+ "glass": {
+ "name": "Glass",
+ "colorScheme": "light",
+ "appearance": {
+ "style": "soft",
+ "shape": "soft",
+ "density": "comfortable",
+ "font": "system",
+ "shadow": "elevated",
+ "motion": "reduced",
+ "blur": "strong",
+ "components": {
+ "button": "soft",
+ "input": "boxed",
+ "card": "elevated",
+ "tabs": "segmented"
+ }
+ },
+ "background": {
+ "type": "mesh",
+ "stops": [
+ "#d3e3f8",
+ "#e8edf5",
+ "#ece0f6"
+ ],
+ "angle": 135,
+ "scrim": 0.35,
+ "animation": "drift"
+ },
+ "effects": {
+ "glow": 0.4
+ },
+ "typography": {
+ "fontScale": 1.05,
+ "radiusScale": 1.4
+ },
+ "terminal": {
+ "background": "#ffffff",
+ "foreground": "#1a2332",
+ "cursor": "#235cd8",
+ "cursorAccent": "#ffffff",
+ "selectionBackground": "#c4d8f7",
+ "selectionForeground": "#1a2332",
+ "selectionInactiveBackground": "#dde8f7",
+ "black": "#1a2332",
+ "red": "#c03528",
+ "green": "#1e7d3c",
+ "yellow": "#8a5a00",
+ "blue": "#235cd8",
+ "magenta": "#7c4dcc",
+ "cyan": "#0e6e86",
+ "white": "#56637a",
+ "brightBlack": "#56637a",
+ "brightRed": "#d9483b",
+ "brightGreen": "#2a944f",
+ "brightYellow": "#a86e10",
+ "brightBlue": "#3870e8",
+ "brightMagenta": "#8f5ce6",
+ "brightCyan": "#2a8ba3",
+ "brightWhite": "#0e1626"
+ },
+ "ui": {
+ "--bg": "#e8edf5",
+ "--bg-surface": "#f4f7fb",
+ "--bg-elevated": "#ffffff",
+ "--bg-terminal": "#ffffff",
+ "--text": "#1a2332",
+ "--text-muted": "#4d5a70",
+ "--text-dim": "#56637a",
+ "--accent": "#235cd8",
+ "--accent-secondary": "#3870e8",
+ "--accent-secondary-light": "#c4d8f7",
+ "--border": "#c9d4e3",
+ "--border-strong": "#a9b8cc",
+ "--error": "#c03528",
+ "--error-bg": "#f9e3e0",
+ "--on-accent": "#ffffff",
+ "--surface-dot": "#d5deea",
+ "--scrollbar-track": "rgba(201, 212, 227, 0.45)",
+ "--scrollbar-thumb": "rgba(77, 90, 112, 0.3)",
+ "--scrollbar-thumb-hover": "rgba(77, 90, 112, 0.5)",
+ "--scanline-tint": "transparent",
+ "--accent-glow": "rgba(35, 92, 216, 0.1)",
+ "--accent-bg": "rgba(35, 92, 216, 0.08)",
+ "--modal-overlay": "rgba(26, 35, 50, 0.35)",
+ "--on-surface": "#1a2332",
+ "--on-surface-variant": "#4d5a70",
+ "--agent-user-color": "#235cd8",
+ "--agent-agent-color": "#7c4dcc"
+ }
}
} /* THEME_PRESETS_END */;
@@ -3374,6 +3713,9 @@ let activePreset = 'cyberpunk';
let colorScheme = 'dark';
let themeName = 'Cyberpunk';
let appearance = {};
+let background = null;
+let effects = {};
+let typography = null;
const THEME_MAX_BYTES = 64 * 1024;
const invalidColorProperties = new Set();
@@ -3384,6 +3726,7 @@ const APPEARANCE_FIELDS = [
{ key: 'font', labelKey: 'appearance.font', values: ['mono', 'system'] },
{ key: 'shadow', labelKey: 'appearance.shadow', values: ['none', 'subtle', 'elevated'] },
{ key: 'motion', labelKey: 'appearance.motion', values: ['none', 'reduced', 'full'] },
+ { key: 'blur', labelKey: 'appearance.blur', values: ['none', 'subtle', 'strong'] },
];
const COMPONENT_FIELDS = [
@@ -3413,6 +3756,9 @@ function initTheme(presetName) {
...preset.appearance,
components: { ...preset.appearance.components },
};
+ background = preset.background ? structuredClone(preset.background) : null;
+ effects = { ...(preset.effects || {}) };
+ typography = preset.typography ? { ...preset.typography } : null;
renderControls();
applyTheme();
syncThemeSelectors(presetName);
@@ -3454,6 +3800,26 @@ function renderControls() {
))
.join('');
+ // V3:背景层 / 效果 / 版式面板
+ html += `
${t('group.themeV3Background')}
`;
+ html += settingSelect('background.type', background?.type || 'solid', ['solid', 'linear', 'radial', 'mesh'], 'data-background="type"');
+ html += settingRange('background.angle', background?.angle ?? 160, 0, 360, 1, 'data-background="angle"');
+ html += settingRange('background.scrim', background?.scrim ?? 0.3, 0, 0.85, 0.01, 'data-background="scrim"');
+ html += settingSelect('background.animation', background?.animation || 'none', ['none', 'drift'], 'data-background="animation"');
+ for (let i = 0; i < 4; i++) {
+ html += settingInput(`background.stop${i + 1}`, background?.stops?.[i] || '', `data-background-stop="${i}"`);
+ }
+
+ html += `${t('group.themeV3Effects')}
`;
+ html += settingRange('effects.scanline', effects.scanline ?? 0, 0, 1, 0.05, 'data-effect="scanline"');
+ html += settingRange('effects.flicker', effects.flicker ?? 0, 0, 1, 0.05, 'data-effect="flicker"');
+ html += settingRange('effects.glow', effects.glow ?? 0, 0, 1, 0.05, 'data-effect="glow"');
+ html += settingRange('effects.noise', effects.noise ?? 0, 0, 1, 0.01, 'data-effect="noise"');
+
+ html += `${t('group.themeV3Typography')}
`;
+ html += settingRange('typography.fontScale', typography?.fontScale ?? 1, 0.85, 1.25, 0.01, 'data-typography="fontScale"');
+ html += settingRange('typography.radiusScale', typography?.radiusScale ?? 1, 0.5, 2, 0.05, 'data-typography="radiusScale"');
+
for (const group of VARIABLE_GROUPS) {
html += `${t(group.titleKey)}
`;
for (const varName of group.vars) {
@@ -3500,6 +3866,69 @@ function renderControls() {
});
});
+ // V3:背景层 / 效果 / 版式控件事件
+ container.querySelectorAll('[data-background]').forEach(control => {
+ control.addEventListener('input', (event) => {
+ const target = event.target;
+ const key = target.dataset.background;
+ background = normalizeBackgroundState(background);
+ if (key === 'angle' || key === 'scrim') {
+ background[key] = Number(target.value);
+ } else {
+ background[key] = target.value;
+ }
+ markThemeCustomized();
+ applyTheme();
+ });
+ });
+
+ container.querySelectorAll('[data-background-stop]').forEach(input => {
+ input.addEventListener('input', (event) => {
+ const target = event.target;
+ const index = Number(target.dataset.backgroundStop);
+ background = normalizeBackgroundState(background);
+ const stops = [...(background.stops || [])];
+ const value = target.value.trim();
+ if (value === '') {
+ delete stops[index];
+ } else if (isSafeColor(value)) {
+ stops[index] = value;
+ target.setAttribute('aria-invalid', 'false');
+ } else {
+ target.setAttribute('aria-invalid', 'true');
+ }
+ background.stops = stops.filter(stop => stop !== undefined);
+ markThemeCustomized();
+ applyTheme();
+ });
+ });
+
+ container.querySelectorAll('[data-effect]').forEach(input => {
+ input.addEventListener('input', (event) => {
+ const key = event.target.dataset.effect;
+ const value = Number(event.target.value);
+ if (value > 0) effects[key] = value;
+ else delete effects[key];
+ markThemeCustomized();
+ applyTheme();
+ });
+ });
+
+ container.querySelectorAll('[data-typography]').forEach(input => {
+ input.addEventListener('input', (event) => {
+ const key = event.target.dataset.typography;
+ const value = Number(event.target.value);
+ typography = {
+ fontScale: typography?.fontScale ?? 1,
+ radiusScale: typography?.radiusScale ?? 1,
+ [key]: value,
+ };
+ if (typography.fontScale === 1 && typography.radiusScale === 1) typography = null;
+ markThemeCustomized();
+ applyTheme();
+ });
+ });
+
container.querySelectorAll('input[type="color"]').forEach(input => {
input.addEventListener('input', (e) => {
const varName = e.target.dataset.var;
@@ -3566,6 +3995,23 @@ function settingSelect(labelKey, value, values, attribute) {
+ ``;
}
+function settingRange(labelKey, value, min, max, step, attribute) {
+ return ``
+ + ``
+ + `${value}
`;
+}
+
+function normalizeBackgroundState(current) {
+ if (current) return current;
+ return {
+ type: 'linear',
+ stops: ['#0a0a0a'],
+ angle: 160,
+ scrim: 0.3,
+ animation: 'none',
+ };
+}
+
function applyTheme() {
const scope = document.getElementById('theme-scope');
scope.style.colorScheme = colorScheme;
@@ -3576,6 +4022,7 @@ function applyTheme() {
scope.dataset.uiFont = appearance.font;
scope.dataset.uiShadow = appearance.shadow;
scope.dataset.uiMotion = appearance.motion;
+ scope.dataset.uiBlur = appearance.blur;
scope.dataset.componentButton = appearance.components.button;
scope.dataset.componentInput = appearance.components.input;
scope.dataset.componentCard = appearance.components.card;
@@ -3583,6 +4030,40 @@ function applyTheme() {
for (const [varName, val] of Object.entries(theme)) {
scope.style.setProperty(varName, val);
}
+ // V3:背景栈(含读性遮罩)与效果开关(与应用同构)
+ if (background && background.type !== 'solid' && (background.stops || []).length >= 2 && background.stops.every(isSafeColor)) {
+ const channel = colorScheme === 'dark' ? '0, 0, 0' : '255, 255, 255';
+ const scrimAlpha = background.scrim ?? 0.3;
+ const scrim = `linear-gradient(rgba(${channel}, ${scrimAlpha}), rgba(${channel}, ${scrimAlpha}))`;
+ let gradient;
+ if (background.type === 'linear') {
+ gradient = `linear-gradient(${background.angle ?? 160}deg, ${background.stops.join(', ')})`;
+ } else if (background.type === 'radial') {
+ gradient = `radial-gradient(ellipse at 50% 25%, ${background.stops.join(', ')})`;
+ } else {
+ const [a, b, c] = background.stops;
+ gradient = [
+ `radial-gradient(at 18% 22%, ${a} 0px, transparent 55%)`,
+ `radial-gradient(at 82% 28%, ${b || a} 0px, transparent 50%)`,
+ `radial-gradient(at 50% 88%, ${c || b || a} 0px, transparent 60%)`,
+ ].join(', ');
+ }
+ scope.style.setProperty('--app-bg-stack', `${scrim}, ${gradient}`);
+ scope.dataset.bgAnimation = background.animation === 'drift' ? 'drift' : 'none';
+ } else {
+ scope.style.setProperty('--app-bg-stack', 'none');
+ scope.dataset.bgAnimation = 'none';
+ }
+ scope.dataset.fxScanline = effects.scanline ? 'on' : 'off';
+ scope.dataset.fxFlicker = effects.flicker ? 'on' : 'off';
+ scope.dataset.fxGlow = effects.glow ? 'on' : 'off';
+ scope.dataset.fxNoise = effects.noise ? 'on' : 'off';
+ scope.style.setProperty('--fx-scanline-opacity', String(effects.scanline ?? 1));
+ scope.style.setProperty('--fx-flicker-speed', `${0.32 - 0.2 * (effects.flicker ?? 1)}s`);
+ scope.style.setProperty('--fx-glow-strength', String(effects.glow ?? 0.6));
+ scope.style.setProperty('--fx-noise-opacity', String(effects.noise ?? 0.05));
+ scope.style.setProperty('--radius-scale', String(typography?.radiusScale ?? 1));
+ scope.style.setProperty('--type-scale', String(typography?.fontScale ?? 1));
}
// Preset selector
@@ -3633,6 +4114,11 @@ document.getElementById('import-input').addEventListener('change', (e) => {
? data.name.trim().slice(0, 80)
: 'Custom';
appearance = mergeAppearance(fallbackPreset.appearance, importedAppearance);
+ background = sanitizeBackground(data.background, importedScheme)
+ || structuredClone(fallbackPreset.background || null);
+ effects = { ...(fallbackPreset.effects || {}), ...(sanitizeEffects(data.effects) || {}) };
+ typography = sanitizeTypography(data.typography)
+ || (fallbackPreset.typography ? { ...fallbackPreset.typography } : null);
activePreset = requestedBase;
invalidColorProperties.clear();
syncThemeSelectors('custom');
@@ -3660,7 +4146,7 @@ document.getElementById('btn-export').addEventListener('click', () => {
Object.keys(PRESETS['standard-dark'].terminal),
);
const output = {
- schemaVersion: 2,
+ schemaVersion: 3,
name: themeName.trim() || 'Custom',
baseTheme: activePreset || (colorScheme === 'light' ? 'standard-light' : 'cyberpunk'),
colorScheme,
@@ -3678,6 +4164,12 @@ document.getElementById('btn-export').addEventListener('click', () => {
},
ui: safeUiTheme,
};
+ const safeBackground = sanitizeBackground(background, colorScheme);
+ if (safeBackground) output.background = safeBackground;
+ const safeEffects = sanitizeEffects(effects);
+ if (safeEffects) output.effects = safeEffects;
+ const safeTypography = sanitizeTypography(typography);
+ if (safeTypography) output.typography = safeTypography;
const json = JSON.stringify(output, null, 2);
const blob = new Blob([json], { type: 'application/json' });
@@ -3730,6 +4222,38 @@ function sanitizeAppearance(value) {
return Object.keys(result).length > 1 || Object.keys(result.components).length ? result : null;
}
+function sanitizeBackground(value, scheme) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const stops = Array.isArray(value.stops)
+ ? value.stops.filter(s => typeof s === 'string' && isSafeColor(s)).slice(0, 5)
+ : [];
+ if (stops.length === 0) return null;
+ const type = ['solid', 'linear', 'radial', 'mesh'].includes(value.type) ? value.type : 'linear';
+ const animation = value.animation === 'drift' ? 'drift' : 'none';
+ const angle = Math.min(360, Math.max(0, Number(value.angle) || 160));
+ let scrim = Math.min(0.85, Math.max(0, Number(value.scrim) || 0));
+ if (type !== 'solid') scrim = Math.max(scrim, scheme === 'light' ? 0.35 : 0.25);
+ return { type, stops, angle, scrim, animation };
+}
+
+function sanitizeEffects(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
+ const result = {};
+ for (const name of ['scanline', 'flicker', 'glow', 'noise']) {
+ const raw = Number(value[name]);
+ if (Number.isFinite(raw) && raw > 0) result[name] = Math.min(1, raw);
+ }
+ return Object.keys(result).length ? result : undefined;
+}
+
+function sanitizeTypography(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const fontScale = Math.min(1.25, Math.max(0.85, Number(value.fontScale) || 1));
+ const radiusScale = Math.min(2, Math.max(0.5, Number(value.radiusScale) || 1));
+ if (fontScale === 1 && radiusScale === 1) return null;
+ return { fontScale, radiusScale };
+}
+
function mergeAppearance(fallback, imported) {
return {
...fallback,
diff --git a/frontend/index.html b/frontend/index.html
index c8b2251..b2345cb 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -124,8 +124,10 @@
-
+
+
+