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 @@ - + + + @@ -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 @@ - + + +