diff --git a/README.ja.md b/README.ja.md index 409cfb4a9..ac0db88a4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -135,71 +135,6 @@ Codex の **セッション管理 → コンテキスト編集(実験的)** - プロバイダーのクォータとアカウント利用状態を確認。 - macOS メニューバーまたは Windows システムトレイからバックグラウンド動作を継続。 -## カスタムテーマ - -カスタムテーマは、アプリデータディレクトリの `themes` フォルダ内のサブフォルダ 1 つにつき 1 つです。Windows と Linux では実行ファイルと同じディレクトリ、macOS アプリでは `~/Library/Application Support/com.cpa.gui/themes` です(`.app` 以外から実行した場合は実行ファイルと同じディレクトリ)。テーマメニュー下部の **テーマフォルダを開く** でフォルダを作成して開き、**再読み込み** で再起動せずに変更を反映できます。カスタムテーマはフォルダ名順に組み込みテーマの後へ並びます。テーマセレクターはダイアログで開き、名前・説明・テーマ ID での検索、提供元(すべて・組み込み・カスタム)での絞り込み、自動モードでのライトとダークの個別選択に対応しています。 - -``` -themes/ - harbor-dark/ - theme.json - assets/ - icon.png - backdrop.webp -``` - -- 読み込むのは 1 階層のサブフォルダのみです。`themes/` 直下の JSON ファイル、`theme.json` のないフォルダ、シンボリックリンクのフォルダと `theme.json` は無視されます。 -- 最大 64 個のテーマフォルダ、1 ファイル 64 KB までを読み込みます。上限を超えた数はテーマメニューに表示されます。読み込めない、JSON が不正、メタ情報が無効、id が他のテーマと重複、または未対応の `schemaVersion` を持つフォルダはスキップされ、フォルダ名と理由がテーマメニューに表示されます。 - -```json -{ - "$schema": "./theme.schema.json", - "schemaVersion": 1, - "id": "harbor-dark", - "name": { "en": "Harbor", "zh-CN": "港湾", "ja": "ハーバー" }, - "description": "Deep teal surfaces with a sea-glass accent", - "appearance": "dark", - "windowBackground": "#0E1A1F", - "windowMaterial": "translucent", - "icon": "assets/icon.png", - "assets": { "backdropImage": "assets/backdrop.webp" }, - "tokens": { - "workspaceBackground": "#0E1A1F", - "surface": "#14242B", - "textPrimary": "#E6F1F2", - "border": "#2A434C", - "accent": "#6FD3C1", - "accentContrast": "#0E1A1F", - "sidebarBackground": "#0B151A", - "radiusMedium": "6px", - "controlBorderWidth": "2px", - "cardBorderWidth": "1px", - "shadowSmall": "0 1px 2px #00000014", - "windowMaterialWorkspaceOpacity": "72%", - "windowMaterialSidebarOpacity": "40%" - } -} -``` - -- `schemaVersion`(必須):現在は `1`。それ以外の値ではファイルをスキップします。省略時は `1` として扱います。 -- `id`(必須):英小文字・数字・`-` で、先頭は英字。組み込みテーマ(`light`, `dark`, `warm-sand-light`, `graphite-dark`, `midnight-dark`, `sakura-light`)や他のカスタムテーマと重複できません。 -- `name`(必須)と `description`:文字列、または `en`・`zh-CN`・`zh-TW`・`ja` をキーとするオブジェクト。 -- `appearance`(必須):`light` または `dark`。テーマが表示されるグループを決めます。 -- `windowBackground`(必須):`#RRGGBB`。ページ描画前にネイティブウィンドウに表示される色です。 -- `windowMaterial`:`solid`(既定)または `translucent`。半透明テーマではサイドバーとワークスペースの背後にネイティブのウィンドウマテリアルが表示されます。macOS 26 以降は Liquid Glass、それ以前の macOS は vibrancy、Windows 11 は Mica、Windows 10 1809 以降は Acrylic です。Linux と古い Windows ではマテリアルがなく、不透明テーマとまったく同じ表示になるため、`windowBackground` とトークンは省略せずに指定してください。マテリアル表示中は `backdrop` は使われません。 -- `backdrop`:任意のグラデーション背景。構造は組み込みのミッドナイトブルー、桜ピンクと同じです。 -- `icon` と `assets`:テーマフォルダ内の相対パス(`png`、`jpg`、`jpeg`、`webp`、`svg`、`avif`)を予約します。絶対パス、`..`、URL、バックスラッシュは使用できません。無効な項目は破棄されます。現在のバージョンは検証して保持するだけで、これらのファイルはまだ表示しません。 -- `tokens`(必須、空オブジェクト可):外観トークンのみです。色は `#RRGGBB` または `#RRGGBBAA`、不透明度は `"0%"` から `"100%"`、角丸は `0px` から `24px`(`radiusFull` は最大 `9999px`)、枠線の太さは `0px` から `4px`、影は `none` またはカンマ区切りの `[inset] x y [blur] [spread] color` です。未知のキーと不正な値は項目ごとに破棄され、テーマの残りは読み込まれ、無視されたフィールドがテーマメニューに表示されます。コントロールのサイズ、余白、レイアウト、タイポグラフィは対象外です。`spacing`、`fontFamily`、ボタンの高さなどは無視されます。 -- `$schema` は記述可能で無視されます。それ以外のトップレベルフィールドがあるとファイルは無効になります。 - -これらのトークンは共通コントロールとサーフェスに反映されます:ボタン、スイッチ、入力欄とセレクト、タブ、ダイアログとポップオーバー、カード・パネル・セクション、サイドバー、スクリムです。 - -利用できる token キー:`workspaceBackground`, `surface`, `softSurface`, `hoverBackground`, `textPrimary`, `textSecondary`, `textTertiary`, `textMuted`, `border`, `borderSubtle`, `borderStrong`, `borderHover`, `panelShadow`, `accent`, `accentStrong`, `accentSoft`, `accentContrast`, `accentShadow`, `primaryButtonBackground`, `primaryButtonHoverBackground`, `primaryButtonText`, `primaryButtonBorder`, `primaryButtonHoverBorder`, `controlActiveBackground`, `controlActiveThumb`, `focusRing`, `meterLow`, `meterMedium`, `meterHigh`, `sidebarBackground`, `sidebarText`, `sidebarTextStrong`, `sidebarTextMuted`, `sidebarBorder`, `sidebarHover`, `sidebarSelected`, `sidebarSelectedBorder`, `sidebarSelectedShadow`, `popoverBackground`, `overlaySoftSurface`, `overlayHoverBackground`, `scrim`, `controlThumb`, `radiusSmall`, `radiusMedium`, `radiusLarge`, `radiusFull`, `controlBorderWidth`, `cardBorderWidth`, `shadowExtraSmall`, `shadowSmall`, `shadowMedium`, `shadowLarge`, `shadowPopover`, `shadowDialog`, `shadowSpotlight`, `windowMaterialWorkspaceOpacity`, `windowMaterialSidebarOpacity`。リポジトリの `theme.schema.json` は同じ token 定義から生成され、テーマファイルから参照できます。 - -検索欄は角丸の枠とコントロール全体のフォーカス表示を共有します。ネイティブ選択欄は閉じた状態の外観を統一し、システムの選択メニューを維持します。 - -リクエスト成功率、認証情報の利用可能率、残りのクォータは、低・中・高の連続した配色を共有します。`tokens` の `meterLow`、`meterMedium`、`meterHigh` で変更でき、不明な値はゼロと区別されます。OAuth、使用履歴、高度な機能は、文字のみの横方向の下線付きタブと共通の余白を使用します。その他の少数のサブページにはセグメント型ナビゲーションを使います。 - ## クイックスタート 1. [GitHub Releases](https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest) diff --git a/README.md b/README.md index 0619b66b1..0c503193c 100644 --- a/README.md +++ b/README.md @@ -153,71 +153,6 @@ Manually renamed files without a session ID in their filename or a database path - Review provider quotas and account availability. - Keep the application available from the macOS menu bar or Windows system tray. -## Custom Themes - -Each custom theme is a folder inside the `themes` folder of the application data directory: next to the executable on Windows and Linux, and `~/Library/Application Support/com.cpa.gui/themes` for the macOS app (next to the executable when run outside the `.app` bundle). The **Open themes folder** button at the bottom of the theme picker creates and opens it; **Reload** applies changes without restarting. Custom themes appear after the built-in themes in folder-name order. The theme picker opens in a dialog where you can search by name, description or theme ID, filter by source (all, built-in or custom), and pick separate light and dark themes while the mode is set to Auto. - -``` -themes/ - harbor-dark/ - theme.json - assets/ - icon.png - backdrop.webp -``` - -- Only one level of folders is read. JSON files directly in `themes/`, folders without `theme.json`, symlinked folders and a symlinked `theme.json` are ignored. -- Up to 64 theme folders of at most 64 KB each are read; folders beyond the limit are reported in the picker. A folder that cannot be read, contains invalid JSON, has invalid metadata, reuses another theme's id or declares an unsupported `schemaVersion` is skipped, and the picker shows its name and the reason. - -```json -{ - "$schema": "./theme.schema.json", - "schemaVersion": 1, - "id": "harbor-dark", - "name": { "en": "Harbor", "zh-CN": "港湾", "ja": "ハーバー" }, - "description": "Deep teal surfaces with a sea-glass accent", - "appearance": "dark", - "windowBackground": "#0E1A1F", - "windowMaterial": "translucent", - "icon": "assets/icon.png", - "assets": { "backdropImage": "assets/backdrop.webp" }, - "tokens": { - "workspaceBackground": "#0E1A1F", - "surface": "#14242B", - "textPrimary": "#E6F1F2", - "border": "#2A434C", - "accent": "#6FD3C1", - "accentContrast": "#0E1A1F", - "sidebarBackground": "#0B151A", - "radiusMedium": "6px", - "controlBorderWidth": "2px", - "cardBorderWidth": "1px", - "shadowSmall": "0 1px 2px #00000014", - "windowMaterialWorkspaceOpacity": "72%", - "windowMaterialSidebarOpacity": "40%" - } -} -``` - -- `schemaVersion` (required): currently `1`. Any other value skips the file; a missing value is treated as `1`. -- `id` (required): lowercase letters, digits and `-`, starting with a letter. It must not reuse a built-in ID (`light`, `dark`, `warm-sand-light`, `graphite-dark`, `midnight-dark`, `sakura-light`) or another custom theme's ID. -- `name` (required) and `description`: a string or an object keyed by `en`, `zh-CN`, `zh-TW` and `ja`. -- `appearance` (required): `light` or `dark`; the theme is listed in that group. -- `windowBackground` (required): `#RRGGBB`, the native window color shown before the page paints. -- `windowMaterial`: `solid` (default) or `translucent`. A translucent theme shows the native window material behind the sidebar and workspace: Liquid Glass on macOS 26 and later, vibrancy on earlier macOS, Mica on Windows 11 and Acrylic on Windows 10 1809 and later. Linux and older Windows versions have no material and render the theme exactly like a solid one, so keep `windowBackground` and the tokens complete. While a material is shown it replaces `backdrop`. -- `backdrop`: optional gradient background with the same structure as the built-in Midnight Blue and Sakura Blush themes. -- `icon` and `assets`: reserved relative paths inside the theme folder (`png`, `jpg`, `jpeg`, `webp`, `svg`, `avif`). They must not be absolute, contain `..`, be URLs or use backslashes. An invalid entry is dropped. The current version validates and keeps them but does not display these files yet. -- `tokens` (required, may be empty): appearance tokens only. Colors accept `#RRGGBB` or `#RRGGBBAA`; opacities accept `"0%"` to `"100%"`; corner radii accept `0px` to `24px` (`radiusFull` up to `9999px`); border widths accept `0px` to `4px`; shadows accept `none` or comma-separated `[inset] x y [blur] [spread] color`. Unknown keys and invalid values are dropped individually, the rest of the theme still loads, and the picker lists each ignored field. Control sizes, spacing, layout and typography cannot be themed: keys such as `spacing`, `fontFamily` or button heights are ignored. -- `$schema` is allowed and ignored; any other top-level field makes the file invalid. - -The tokens drive the shared controls and surfaces: buttons, switches, inputs and selects, tabs, dialogs and popovers, cards, panels and sections, the sidebar and the window scrim. - -Available token keys: `workspaceBackground`, `surface`, `softSurface`, `hoverBackground`, `textPrimary`, `textSecondary`, `textTertiary`, `textMuted`, `border`, `borderSubtle`, `borderStrong`, `borderHover`, `panelShadow`, `accent`, `accentStrong`, `accentSoft`, `accentContrast`, `accentShadow`, `primaryButtonBackground`, `primaryButtonHoverBackground`, `primaryButtonText`, `primaryButtonBorder`, `primaryButtonHoverBorder`, `controlActiveBackground`, `controlActiveThumb`, `focusRing`, `meterLow`, `meterMedium`, `meterHigh`, `sidebarBackground`, `sidebarText`, `sidebarTextStrong`, `sidebarTextMuted`, `sidebarBorder`, `sidebarHover`, `sidebarSelected`, `sidebarSelectedBorder`, `sidebarSelectedShadow`, `popoverBackground`, `overlaySoftSurface`, `overlayHoverBackground`, `scrim`, `controlThumb`, `radiusSmall`, `radiusMedium`, `radiusLarge`, `radiusFull`, `controlBorderWidth`, `cardBorderWidth`, `shadowExtraSmall`, `shadowSmall`, `shadowMedium`, `shadowLarge`, `shadowPopover`, `shadowDialog`, `shadowSpotlight`, `windowMaterialWorkspaceOpacity`, `windowMaterialSidebarOpacity`. `theme.schema.json` in the repository is generated from the same token definitions and can be referenced from a theme file. - -Search fields share rounded frames and a single focus indicator around the complete control. Native selects use matching closed surfaces while retaining system option menus. - -Percentage meters for request success, credential availability, and remaining quota share a continuous low–medium–high color scale. Customize it with `meterLow`, `meterMedium`, and `meterHigh` in `tokens`; unknown values stay distinct from zero. OAuth, Usage, and Advanced Features share text-only horizontal underline tabs with matching spacing; other small secondary navigation groups use segmented controls. - ## Quick Start 1. Download the package for your operating system from diff --git a/README.zh-CN.md b/README.zh-CN.md index 890cb80c9..b8ce3d820 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -147,71 +147,6 @@ Codex 的 **会话管理 → 编辑上下文(实验性)** 仅在点击某个 - 查看 Provider 配额和账号可用状态。 - 通过 macOS 菜单栏或 Windows 系统托盘保持软件在后台运行。 -## 自定义主题 - -每个自定义主题是应用数据目录 `themes` 文件夹下的一个子文件夹:Windows 和 Linux 为程序所在目录,macOS 应用为 `~/Library/Application Support/com.cpa.gui/themes`(不以 `.app` 方式运行时为程序所在目录)。主题菜单底部的 **打开主题目录** 会创建并打开该目录,**重新加载** 无需重启即可应用改动。自定义主题按文件夹名顺序排在内置主题之后。主题选择器以弹窗形式打开,可按名称、描述或主题 ID 搜索,按来源(全部、内置、自定义)筛选,自动模式下还可分别选择浅色与深色主题。 - -``` -themes/ - harbor-dark/ - theme.json - assets/ - icon.png - backdrop.webp -``` - -- 只读取一层子文件夹。直接放在 `themes/` 下的 JSON 文件、没有 `theme.json` 的文件夹、符号链接文件夹和符号链接的 `theme.json` 都会被忽略。 -- 最多读取 64 个主题文件夹,单个文件不超过 64 KB;超出上限的数量会显示在主题菜单中。无法读取、JSON 格式错误、元信息无效、id 与其他主题重复或 `schemaVersion` 不受支持的文件夹会被跳过,并在主题菜单中显示文件夹名和原因。 - -```json -{ - "$schema": "./theme.schema.json", - "schemaVersion": 1, - "id": "harbor-dark", - "name": { "en": "Harbor", "zh-CN": "港湾", "ja": "ハーバー" }, - "description": "Deep teal surfaces with a sea-glass accent", - "appearance": "dark", - "windowBackground": "#0E1A1F", - "windowMaterial": "translucent", - "icon": "assets/icon.png", - "assets": { "backdropImage": "assets/backdrop.webp" }, - "tokens": { - "workspaceBackground": "#0E1A1F", - "surface": "#14242B", - "textPrimary": "#E6F1F2", - "border": "#2A434C", - "accent": "#6FD3C1", - "accentContrast": "#0E1A1F", - "sidebarBackground": "#0B151A", - "radiusMedium": "6px", - "controlBorderWidth": "2px", - "cardBorderWidth": "1px", - "shadowSmall": "0 1px 2px #00000014", - "windowMaterialWorkspaceOpacity": "72%", - "windowMaterialSidebarOpacity": "40%" - } -} -``` - -- `schemaVersion`(必填):当前为 `1`。其他值会跳过该文件;缺少时按 `1` 处理。 -- `id`(必填):小写字母、数字和 `-`,以字母开头;不能与内置主题(`light`, `dark`, `warm-sand-light`, `graphite-dark`, `midnight-dark`, `sakura-light`)或其他自定义主题重复。 -- `name`(必填)和 `description`:字符串,或以 `en`、`zh-CN`、`zh-TW`、`ja` 为键的对象。 -- `appearance`(必填):`light` 或 `dark`,决定主题出现在哪个分组。 -- `windowBackground`(必填):`#RRGGBB`,页面绘制前原生窗口显示的颜色。 -- `windowMaterial`:`solid`(默认)或 `translucent`。半透明主题会在侧边栏和工作区后面显示系统原生窗口材质:macOS 26 及以上为液态玻璃,更早的 macOS 为 vibrancy,Windows 11 为 Mica,Windows 10 1809 及以上为 Acrylic。Linux 和更早的 Windows 不支持材质,主题会按不透明主题原样显示,因此 `windowBackground` 和各项 token 仍需填写完整。显示材质时 `backdrop` 渐变不生效。 -- `backdrop`:可选的渐变背景,结构与内置的午夜蓝、樱语红粉主题相同。 -- `icon` 和 `assets`:预留的主题文件夹内相对路径(`png`、`jpg`、`jpeg`、`webp`、`svg`、`avif`)。不能是绝对路径、包含 `..`、URL 或反斜杠;无效项会被丢弃。当前版本只校验并保留,不会显示这些文件。 -- `tokens`(必填,可为空对象):只包含外观类 token。颜色取值为 `#RRGGBB` 或 `#RRGGBBAA`;不透明度为 `"0%"` 到 `"100%"`;圆角为 `0px` 到 `24px`(`radiusFull` 最多 `9999px`);边框粗细为 `0px` 到 `4px`;阴影为 `none` 或逗号分隔的 `[inset] x y [blur] [spread] color`。未知键和非法取值会逐项丢弃,其余主题仍然可用,主题菜单会列出每个被忽略的字段。控件尺寸、间距、布局和字体不开放:`spacing`、`fontFamily`、按钮高度等键会被忽略。 -- 允许出现 `$schema`(会被忽略);其他任何顶层字段都会使文件无效。 - -这些 token 作用于公共控件与表面:按钮、开关、输入框与下拉框、Tabs、弹窗与浮层、卡片、面板与分区、侧边栏和遮罩。 - -可用的 token 键:`workspaceBackground`, `surface`, `softSurface`, `hoverBackground`, `textPrimary`, `textSecondary`, `textTertiary`, `textMuted`, `border`, `borderSubtle`, `borderStrong`, `borderHover`, `panelShadow`, `accent`, `accentStrong`, `accentSoft`, `accentContrast`, `accentShadow`, `primaryButtonBackground`, `primaryButtonHoverBackground`, `primaryButtonText`, `primaryButtonBorder`, `primaryButtonHoverBorder`, `controlActiveBackground`, `controlActiveThumb`, `focusRing`, `meterLow`, `meterMedium`, `meterHigh`, `sidebarBackground`, `sidebarText`, `sidebarTextStrong`, `sidebarTextMuted`, `sidebarBorder`, `sidebarHover`, `sidebarSelected`, `sidebarSelectedBorder`, `sidebarSelectedShadow`, `popoverBackground`, `overlaySoftSurface`, `overlayHoverBackground`, `scrim`, `controlThumb`, `radiusSmall`, `radiusMedium`, `radiusLarge`, `radiusFull`, `controlBorderWidth`, `cardBorderWidth`, `shadowExtraSmall`, `shadowSmall`, `shadowMedium`, `shadowLarge`, `shadowPopover`, `shadowDialog`, `shadowSpotlight`, `windowMaterialWorkspaceOpacity`, `windowMaterialSidebarOpacity`。仓库中的 `theme.schema.json` 由同一份 token 定义生成,主题文件可以引用它。 - -搜索框共用圆角外框,聚焦反馈覆盖整个控件。原生选择框统一闭合外观,并保留系统选项菜单。 - -请求成功率、凭证可用率和剩余额度共用连续的低—中—高配色。可通过 `tokens` 中的 `meterLow`、`meterMedium`、`meterHigh` 自定义,未知值与零值保持区分。OAuth、使用记录和高级功能统一使用纯文字顶部下划线标签页,并保持相同的导航间距;其他少量二级分类使用分段导航。 - ## 快速开始 1. 前往 [GitHub Releases](https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest) diff --git a/docs/release-notes/v0.3.47/en.md b/docs/release-notes/v0.3.47/en.md index e8b462542..e0b0054c4 100644 --- a/docs/release-notes/v0.3.47/en.md +++ b/docs/release-notes/v0.3.47/en.md @@ -2,8 +2,6 @@ ## Added -- Added custom JSON themes. Open the themes directory and reload themes from the appearance menu without restarting; invalid theme files are reported in the menu. -- Custom themes can use native translucent materials: Mica / Acrylic on Windows and Liquid Glass / vibrancy on macOS, with separate sidebar and workspace opacity settings. Availability depends on the OS; built-in themes remain opaque. ## Improved @@ -17,7 +15,7 @@ - Fixed UI freezes after opening dialogs and black windows after restoring in affected Linux NVIDIA + Wayland environments by no longer forcing X11 preference there. Explicit user-selected graphics backends remain respected. - Fixed incorrect restored window positions and interface sizes on macOS setups with displays using different scaling factors. - Fixed floating notices showing page content through their backgrounds and making text difficult to read, including improved notice surfaces for translucent themes. -- Custom theme loading now ignores unknown or removed color keys instead of rejecting the whole theme. Also fixed model-picker layering and Escape handling inside dialogs. +- Fixed model-picker layering and Escape handling inside dialogs. ## Selected Previous Updates diff --git a/docs/release-notes/v0.3.47/ja.md b/docs/release-notes/v0.3.47/ja.md index 924c99e51..f1f5b553c 100644 --- a/docs/release-notes/v0.3.47/ja.md +++ b/docs/release-notes/v0.3.47/ja.md @@ -2,8 +2,6 @@ ## 追加 -- カスタム JSON テーマに対応しました。外観メニューからテーマフォルダーを開き、再起動せずにテーマを再読み込みできます。無効なテーマファイルはメニューに表示されます。 -- カスタムテーマで OS 標準の半透明効果を利用できます。Windows の Mica / Acrylic、macOS の Liquid Glass / vibrancy に対応し、サイドバーとワークスペースの不透明度を個別に調整できます。利用できる効果は OS に依存し、組み込みテーマは引き続き不透明です。 ## 改善 @@ -17,7 +15,7 @@ - 一部の Linux NVIDIA + Wayland 環境で、ダイアログを開くと画面が停止したり、ウィンドウを復元すると黒くなったりする問題に対応しました。該当する環境では X11 を優先する設定を自動適用せず、ユーザーが明示した描画バックエンド設定も維持します。 - macOS で拡大率の異なる複数のディスプレイを使用した際、復元したウィンドウの位置や画面サイズが正しくない問題を修正しました。 - フローティング通知の背景にページ内容が透けて文字が読みにくくなる問題を修正し、半透明テーマでの通知表示も改善しました。 -- カスタムテーマ内の未知または廃止された色のキーを無視し、テーマ全体が無効になることを防ぎます。また、ダイアログ内のモデル選択メニューの重なり順と Esc キーによる閉じる動作を修正しました。 +- ダイアログ内のモデル選択メニューの重なり順と Esc キーによる閉じる動作を修正しました。 ## 過去の主な更新 diff --git a/docs/release-notes/v0.3.47/zh-CN.md b/docs/release-notes/v0.3.47/zh-CN.md index 24e1ba351..f968490dc 100644 --- a/docs/release-notes/v0.3.47/zh-CN.md +++ b/docs/release-notes/v0.3.47/zh-CN.md @@ -2,8 +2,6 @@ ## 新增 -- 支持加载自定义 JSON 主题,可在外观菜单中打开主题目录、重新加载主题,无需重启;无效主题文件会显示提示。 -- 自定义主题支持系统原生半透明材质:Windows 的 Mica / Acrylic,以及 macOS 的液态玻璃 / vibrancy,并可分别调整侧栏和工作区的不透明度。效果取决于系统支持,内置主题仍保持不透明。 ## 优化 @@ -17,7 +15,7 @@ - 修复部分 Linux NVIDIA + Wayland 环境下打开弹窗后界面冻结或恢复窗口后黑屏的问题:检测到对应环境时不再强制优先 X11,保留用户手动设置的图形后端。 - 修复 macOS 混合缩放多显示器环境下,恢复窗口时位置偏移或界面尺寸异常的问题。 - 修复浮动提示背景透出页面内容、文字难以辨认的问题,并改善半透明主题下的提示效果。 -- 加载自定义主题时忽略未知或已移除的颜色键,避免整份主题因此失效;同时修复弹窗内模型选择器的层级和 Esc 关闭行为。 +- 修复弹窗内模型选择器的层级和 Esc 关闭行为。 ## 部分历史更新 diff --git a/docs/release-notes/v0.3.47/zh-TW.md b/docs/release-notes/v0.3.47/zh-TW.md index 386e190e4..1b01e1b70 100644 --- a/docs/release-notes/v0.3.47/zh-TW.md +++ b/docs/release-notes/v0.3.47/zh-TW.md @@ -2,8 +2,6 @@ ## 新增 -- 支援載入自訂 JSON 佈景主題,可在外觀選單中開啟主題目錄、重新載入主題,無須重新啟動;無效的主題檔案會顯示提示。 -- 自訂主題支援系統原生半透明材質:Windows 的 Mica / Acrylic,以及 macOS 的液態玻璃 / vibrancy,並可分別調整側邊欄與工作區的不透明度。效果取決於系統支援,內建主題仍保持不透明。 ## 改善 @@ -17,7 +15,7 @@ - 修復部分 Linux NVIDIA + Wayland 環境下開啟對話框後介面凍結或還原視窗後黑畫面的問題:偵測到對應環境時不再強制優先使用 X11,並保留使用者手動設定的圖形後端。 - 修復 macOS 混合縮放的多顯示器環境下,還原視窗時位置偏移或介面尺寸異常的問題。 - 修復浮動提示背景透出頁面內容、文字難以辨識的問題,並改善半透明主題下的提示效果。 -- 載入自訂主題時忽略未知或已移除的色彩鍵,避免整份主題因此失效;同時修復對話框內模型選擇器的顯示層級與 Esc 關閉行為。 +- 修復對話框內模型選擇器的顯示層級與 Esc 關閉行為。 ## 部分歷史更新 diff --git a/src-tauri/src/desktop_theme.rs b/src-tauri/src/desktop_theme.rs index de518d39e..64c2d1484 100644 --- a/src-tauri/src/desktop_theme.rs +++ b/src-tauri/src/desktop_theme.rs @@ -83,12 +83,15 @@ pub(crate) fn save_theme_selection(selection: ThemeSelection) -> Result<(), Stri const CUSTOM_THEME_LIMIT: usize = 64; const CUSTOM_THEME_MAX_BYTES: u64 = 64 * 1024; +const CUSTOM_THEME_IMAGE_MAX_BYTES: u64 = 8 * 1024 * 1024; #[derive(Debug, PartialEq, serde::Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomThemeFile { folder: String, content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + backdrop_image: Option, } #[derive(Debug, Default, PartialEq, serde::Serialize)] @@ -117,6 +120,86 @@ fn theme_file_in(folder: &std::path::Path) -> Option { .map(|_| path) } +fn read_backdrop_image(folder: &std::path::Path, content: &str) -> Option { + use base64::Engine; + use std::io::Read; + use std::path::Component; + + let theme: serde_json::Value = serde_json::from_str(content).ok()?; + let asset = theme.get("assets")?.get("backdropImage")?.as_str()?; + if asset.is_empty() || asset.contains(['\\', ':']) { + return None; + } + let relative = std::path::Path::new(asset); + let mime = match relative + .extension()? + .to_str()? + .to_ascii_lowercase() + .as_str() + { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "avif" => "image/avif", + _ => return None, + }; + let mut path = folder.to_path_buf(); + for component in relative.components() { + let Component::Normal(name) = component else { + return None; + }; + path.push(name); + if std::fs::symlink_metadata(&path) + .ok()? + .file_type() + .is_symlink() + { + return None; + } + } + let metadata = std::fs::metadata(&path).ok()?; + if !metadata.is_file() || metadata.len() > CUSTOM_THEME_IMAGE_MAX_BYTES { + return None; + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .ok()? + .take(CUSTOM_THEME_IMAGE_MAX_BYTES + 1) + .read_to_end(&mut bytes) + .ok()?; + if bytes.len() as u64 > CUSTOM_THEME_IMAGE_MAX_BYTES { + return None; + } + let valid = match mime { + "image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "image/jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]), + "image/webp" => bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP"), + "image/avif" => { + let size = bytes + .get(..4) + .and_then(|value| value.try_into().ok()) + .map(u32::from_be_bytes) + .unwrap_or(0) as usize; + size >= 16 + && size <= bytes.len() + && bytes.get(4..8) == Some(b"ftyp") + && (bytes + .get(8..12) + .is_some_and(|brand| matches!(brand, b"avif" | b"avis")) + || bytes[16..size] + .chunks_exact(4) + .any(|brand| matches!(brand, b"avif" | b"avis"))) + } + _ => false, + }; + valid.then(|| { + format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) + }) +} + fn list_theme_files(directory: &std::path::Path) -> Result { let entries = match std::fs::read_dir(directory) { Ok(entries) => entries, @@ -147,9 +230,16 @@ fn list_theme_files(directory: &std::path::Path) -> Result").unwrap(); + std::fs::write(directory.join("outside.png"), b"\x89PNG\r\n\x1a\n").unwrap(); + let oversized = std::fs::File::create(folder.join("assets/large.png")).unwrap(); + oversized.set_len(CUSTOM_THEME_IMAGE_MAX_BYTES + 1).unwrap(); + for asset in [ + "assets/missing.png", + "assets/invalid.png", + "assets/wrong.jpg", + "assets/snow.svg", + "assets/large.png", + "../outside.png", + "assets/../../outside.png", + "assets\\snow.png", + "/outside.png", + "C:/outside.png", + "", + ] { + let content = theme(asset); + std::fs::write(folder.join("theme.json"), &content).unwrap(); + let list = list_theme_files(&directory).unwrap(); + assert_eq!(list.files.len(), 1); + assert_eq!(list.files[0].content.as_deref(), Some(content.as_str())); + assert_eq!(list.files[0].backdrop_image, None, "{asset}"); + assert!(serde_json::to_value(&list.files[0]) + .unwrap() + .get("backdropImage") + .is_none()); + } + for content in ["{", "{}", r#"{"assets":{"backdropImage":1}}"#] { + assert_eq!(read_backdrop_image(&folder, content), None); + } + #[cfg(unix)] + { + std::os::unix::fs::symlink( + directory.join("outside.png"), + folder.join("assets/link.png"), + ) + .unwrap(); + std::os::unix::fs::symlink(folder.join("assets"), folder.join("linked")).unwrap(); + for asset in ["assets/link.png", "linked/snow.png"] { + assert_eq!(read_backdrop_image(&folder, &theme(asset)), None); + } + } + std::fs::remove_dir_all(directory).unwrap(); + } + #[test] fn follows_the_freedesktop_color_scheme_values() { assert_eq!(portal_color_scheme(1), Some(tauri::Theme::Dark)); diff --git a/src/mocks/browserMockRuntime.ts b/src/mocks/browserMockRuntime.ts index 1525f2cce..7dd3b5551 100644 --- a/src/mocks/browserMockRuntime.ts +++ b/src/mocks/browserMockRuntime.ts @@ -1011,7 +1011,14 @@ export function createBrowserMockRuntime( case 'detect_core_platform': return { os: 'windows', arch: 'x86_64', assetOs: 'windows', assetArch: 'amd64', archiveKind: 'zip' }; case 'get_linux_system_theme': return 'light'; case 'detect_window_material': return null; - case 'list_custom_themes': return { files: [{ folder: 'harbor-dark', content: JSON.stringify(MOCK_CUSTOM_THEME) }], omitted: 0 }; + case 'list_custom_themes': return { files: [ + { folder: 'harbor-dark', content: JSON.stringify(MOCK_CUSTOM_THEME) }, + { folder: 'harbor-image-dark', content: JSON.stringify({ + ...MOCK_CUSTOM_THEME, id: 'harbor-image-dark', + name: { en: 'Harbor Image', 'zh-CN': '港湾·图片', 'zh-TW': '港灣·圖片', ja: 'ハーバー・画像' }, + assets: { backdropImage: 'assets/harbor.png' }, + }), backdropImage: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' }, + ], omitted: 0 }; case 'set_app_locale': case 'open_external_url': case 'open_oauth_url': diff --git a/src/styles/theme-backdrop.css b/src/styles/theme-backdrop.css index 25500c925..3626e804b 100644 --- a/src/styles/theme-backdrop.css +++ b/src/styles/theme-backdrop.css @@ -1,17 +1,23 @@ -:root[data-theme-backdrop='gradient'] #root { +:root[data-theme-backdrop] #root { background: var(--theme-backdrop-image) center / 100% 100% no-repeat var(--theme-backdrop-color); } -:root[data-theme-backdrop='gradient'] :is(.immersive-window, .app-shell, .workspace), -:root[data-theme-backdrop='gradient'] .workspace .content { +:root[data-theme-backdrop='image'] #root { + background-size: cover; + background-position: var(--theme-backdrop-position, center); + background-repeat: no-repeat; +} + +:root[data-theme-backdrop] :is(.immersive-window, .app-shell, .workspace), +:root[data-theme-backdrop] .workspace .content { background: transparent; } -:root[data-theme-backdrop='gradient'] .sidebar { +:root[data-theme-backdrop] .sidebar { background: var(--theme-backdrop-sidebar); } -:root[data-theme-backdrop='gradient'] .immersive-window .window-titlebar { +:root[data-theme-backdrop] .immersive-window .window-titlebar { background: var(--theme-backdrop-panel); } diff --git a/src/themes/applyTheme.ts b/src/themes/applyTheme.ts index 00cd987b2..79473cdee 100644 --- a/src/themes/applyTheme.ts +++ b/src/themes/applyTheme.ts @@ -1,14 +1,14 @@ import { themeTokenProperties, type PlatformWindowMaterial, type ThemeDefinition } from './types'; -import { backdropImage } from './backdrop'; +import { backdropImage, themeBackdropImage, themeImagePosition } from './backdrop'; import { themeMaterialTokens, themeOverlayRules, themeOverlayPalette } from './materials'; export const themeWindowMaterial = (theme: ThemeDefinition, platform: PlatformWindowMaterial | null) => - theme.windowMaterial === 'translucent' ? platform : null; + theme.windowMaterial === 'translucent' && !theme.backdropImage ? platform : null; export function createThemeApplicator(document: Document, platformMaterial: () => PlatformWindowMaterial | null = () => null) { const ownedProperties = [...Object.values(themeTokenProperties).flat(), '--theme-backdrop-image', '--theme-backdrop-color', '--theme-backdrop-sidebar', - '--theme-backdrop-panel', '--theme-sticky-background', '--theme-sticky-hover-background']; + '--theme-backdrop-panel', '--theme-backdrop-position', '--theme-sticky-background', '--theme-sticky-hover-background']; let overlayStyle = document.querySelector('style[data-theme-materials]') ?? undefined; return (theme: ThemeDefinition) => { const root = document.documentElement; @@ -36,18 +36,20 @@ export function createThemeApplicator(document: Document, platformMaterial: () = '--theme-overlay-soft-surface': palette.softSurface, })) root.style.setProperty(property, value); } - if (theme.backdrop) { - root.dataset.themeBackdrop = 'gradient'; - const canvas = theme.backdrop.layers.map((layer) => + if (theme.backdrop || theme.backdropImage) { + root.dataset.themeBackdrop = theme.backdropImage ? 'image' : 'gradient'; + const canvas = [...(theme.backdrop?.layers ?? []).map((layer) => `${backdropImage({ ...theme.backdrop!, layers: [layer] })} center / 100vw 100vh no-repeat fixed`, - ).join(', '); + ), ...(theme.backdropImage ? [`url("${theme.backdropImage}") ${themeImagePosition(theme)} / cover no-repeat fixed`] : [])].join(', '); const base = theme.tokens.workspaceBackground ?? theme.windowBackground; - const hover = theme.backdrop.hover ?? theme.backdrop.panel; + const panel = theme.backdrop?.panel ?? theme.tokens.surface ?? theme.windowBackground; + const hover = theme.backdrop ? theme.backdrop.hover ?? panel : theme.tokens.hoverBackground ?? panel; for (const [property, value] of Object.entries({ - '--theme-backdrop-image': backdropImage(theme.backdrop), + '--theme-backdrop-image': themeBackdropImage(theme), + '--theme-backdrop-position': themeImagePosition(theme), '--theme-backdrop-color': theme.tokens.workspaceBackground ?? theme.windowBackground, - '--theme-backdrop-sidebar': theme.backdrop.sidebar, - '--theme-backdrop-panel': theme.backdrop.panel, + '--theme-backdrop-sidebar': theme.backdrop?.sidebar ?? theme.tokens.sidebarBackground ?? theme.windowBackground, + '--theme-backdrop-panel': panel, '--theme-sticky-background': `${canvas} ${base}`, '--theme-sticky-hover-background': `linear-gradient(${hover}, ${hover}), ${canvas} ${base}`, })) { @@ -70,7 +72,7 @@ export function createThemeApplicator(document: Document, platformMaterial: () = if (document.body) document.body.style.backgroundColor = ''; } else { root.style.backgroundColor = theme.windowBackground; - if (document.body) document.body.style.backgroundColor = theme.backdrop ? '' : theme.windowBackground; + if (document.body) document.body.style.backgroundColor = theme.backdrop || theme.backdropImage ? '' : theme.windowBackground; } }; } diff --git a/src/themes/backdrop.ts b/src/themes/backdrop.ts index 1bf431b23..453bebb46 100644 --- a/src/themes/backdrop.ts +++ b/src/themes/backdrop.ts @@ -1,4 +1,4 @@ -import type { ThemeBackdrop, ThemeGradient, ThemeGradientStop } from './types'; +import type { ThemeBackdrop, ThemeDefinition, ThemeGradient, ThemeGradientStop } from './types'; const color = (value: unknown): value is string => typeof value === 'string' && /^#(?:[\da-f]{6}|[\da-f]{8})$/i.test(value); const numberInRange = (value: unknown, min: number, max: number): value is number => @@ -11,6 +11,7 @@ export function normalizeBackdrop(backdrop: ThemeBackdrop, id: string): ThemeBac if (!backdrop || !color(backdrop.sidebar) || !color(backdrop.panel) || (backdrop.inset !== undefined && !color(backdrop.inset)) || (backdrop.hover !== undefined && !color(backdrop.hover)) + || (backdrop.imagePosition !== undefined && !pair(backdrop.imagePosition, 0, 100)) || !Array.isArray(backdrop.layers) || !numberInRange(backdrop.layers.length, 1, 4)) return invalid(); const layers = backdrop.layers.map((layer): ThemeGradient => { if (!layer || !Array.isArray(layer.stops) || !numberInRange(layer.stops.length, 2, 8)) return invalid(); @@ -32,6 +33,7 @@ export function normalizeBackdrop(backdrop: ThemeBackdrop, id: string): ThemeBac return Object.freeze({ layers: Object.freeze(layers), sidebar: backdrop.sidebar, panel: backdrop.panel, ...(backdrop.inset === undefined ? {} : { inset: backdrop.inset }), ...(backdrop.hover === undefined ? {} : { hover: backdrop.hover }), + ...(backdrop.imagePosition === undefined ? {} : { imagePosition: Object.freeze([...backdrop.imagePosition]) as readonly [number, number] }), }); } @@ -43,3 +45,12 @@ export function backdropImage(backdrop: ThemeBackdrop): string { : `radial-gradient(ellipse ${layer.size[0]}% ${layer.size[1]}% at ${layer.center[0]}% ${layer.center[1]}%, ${stops})`; }).join(', '); } + +export function themeBackdropImage(theme: ThemeDefinition): string { + return [theme.backdrop ? backdropImage(theme.backdrop) : '', + theme.backdropImage ? `url("${theme.backdropImage}")` : ''].filter(Boolean).join(', '); +} + +export function themeImagePosition(theme: ThemeDefinition): string { + return theme.backdrop?.imagePosition?.map(value => `${value}%`).join(' ') ?? 'center'; +} diff --git a/src/themes/customThemes.ts b/src/themes/customThemes.ts index f065e95d4..efac7b90b 100644 --- a/src/themes/customThemes.ts +++ b/src/themes/customThemes.ts @@ -1,7 +1,7 @@ import { builtinThemes, normalizeTheme } from './registry'; import type { ThemeDefinition, ThemeIssue } from './types'; -export type CustomThemeFile = { folder: string; content: string | null }; +export type CustomThemeFile = { folder: string; content: string | null; backdropImage?: string }; export type CustomThemeList = { files: CustomThemeFile[]; omitted: number }; export type CustomThemeSkipReason = 'unreadable' | 'invalid-json' | 'unsupported-version' | 'invalid-metadata' | 'duplicate-id'; export type SkippedCustomTheme = { folder: string; reason: CustomThemeSkipReason }; @@ -12,7 +12,7 @@ const allowedKeys = new Set(['$schema', 'schemaVersion', 'id', 'name', 'descript const isRecord = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); -function parseCustomTheme(record: Record, order: number, issues: ThemeIssue[]): ThemeDefinition { +function parseCustomTheme(record: Record, order: number, issues: ThemeIssue[], backdropImage?: string): ThemeDefinition { if (Object.keys(record).some((key) => !allowedKeys.has(key)) || record.name === undefined || typeof record.windowBackground !== 'string' || (record.$schema !== undefined && typeof record.$schema !== 'string') @@ -20,7 +20,7 @@ function parseCustomTheme(record: Record, order: number, issues throw new Error('Invalid custom theme'); } const { $schema: _schema, schemaVersion: _version, ...definition } = record; - return normalizeTheme({ ...definition, order } as ThemeDefinition, (issue) => issues.push(issue)); + return normalizeTheme({ ...definition, order, backdropImage } as ThemeDefinition, (issue) => issues.push(issue)); } export function parseCustomThemes(files: readonly CustomThemeFile[], builtins: readonly ThemeDefinition[] = builtinThemes): CustomThemes { @@ -52,7 +52,7 @@ export function parseCustomThemes(files: readonly CustomThemeFile[], builtins: r const issues: ThemeIssue[] = []; let theme: ThemeDefinition; try { - theme = parseCustomTheme(record, first + themes.length, issues); + theme = parseCustomTheme(record, first + themes.length, issues, file.backdropImage); } catch { skipped.push({ folder: file.folder, reason: 'invalid-metadata' }); continue; diff --git a/src/themes/registry.ts b/src/themes/registry.ts index ef5749832..9fdf0cbc1 100644 --- a/src/themes/registry.ts +++ b/src/themes/registry.ts @@ -1,13 +1,13 @@ import { isThemeId, isThemeLabel, type SystemThemes, type ThemeAppearance, type ThemeDefinition, type ThemeIssue, type ThemeLabel, type ThemePreview } from './types'; import { normalizeThemeTokens } from './tokenContract'; -import { isThemeAssetPath, normalizeThemeAssets } from './resources'; +import { isThemeAssetPath, isThemeImageDataUrl, normalizeThemeAssets } from './resources'; import { warmSand } from './warmSand'; import { graphite } from './graphite'; import { midnight } from './midnight'; import { light } from './light'; import { dark } from './dark'; import { sakura } from './sakura'; -import { backdropImage, normalizeBackdrop } from './backdrop'; +import { backdropImage, normalizeBackdrop, themeBackdropImage, themeImagePosition } from './backdrop'; export function normalizeTheme(definition: ThemeDefinition, report: (issue: ThemeIssue) => void = () => {}): ThemeDefinition { if (!isThemeId(definition.id) || definition.id === 'system') throw new Error(`Invalid theme ID: ${definition.id}`); @@ -29,13 +29,15 @@ export function normalizeTheme(definition: ThemeDefinition, report: (issue: Them } const assets = normalizeThemeAssets(definition.assets, (field) => report({ field: `assets.${field}`, code: 'invalid-asset' })); const label = (value: ThemeLabel) => typeof value === 'string' ? value : Object.freeze({ ...value }); - const { icon: _icon, assets: _assets, ...rest } = definition; + const { icon: _icon, assets: _assets, backdropImage: image, ...rest } = definition; return Object.freeze({ ...rest, schemaVersion: 1, ...(definition.name === undefined ? {} : { name: label(definition.name) }), ...(definition.description === undefined ? {} : { description: label(definition.description) }), ...(icon === undefined ? {} : { icon }), ...(assets === undefined ? {} : { assets }), + ...(assets?.backdropImage && /\.(?:png|jpe?g|webp|avif)$/i.test(assets.backdropImage) + && isThemeImageDataUrl(image) ? { backdropImage: image } : {}), tokens: Object.freeze(tokens), ...(definition.backdrop === undefined ? {} : { backdrop: normalizeBackdrop(definition.backdrop, definition.id) }), }); @@ -71,7 +73,9 @@ export type ThemeRegistry = ReturnType; export function themePreview(theme: ThemeDefinition): ThemePreview { const dark = theme.appearance === 'dark'; return { - background: `${theme.backdrop ? `${backdropImage(theme.backdrop)}, ` : ''}${theme.tokens.workspaceBackground ?? theme.windowBackground}`, + background: theme.backdropImage + ? `${themeBackdropImage(theme)} ${themeImagePosition(theme)} / cover no-repeat ${theme.tokens.workspaceBackground ?? theme.windowBackground}` + : `${theme.backdrop ? `${backdropImage(theme.backdrop)}, ` : ''}${theme.tokens.workspaceBackground ?? theme.windowBackground}`, sidebar: theme.backdrop?.sidebar ?? theme.tokens.sidebarBackground ?? (dark ? '#0f1218' : '#f7f8fa'), surface: theme.backdrop?.panel ?? theme.tokens.surface ?? (dark ? '#141720' : '#ffffff'), accent: theme.tokens.accent ?? (dark ? '#f5f5f5' : '#222222'), diff --git a/src/themes/resources.ts b/src/themes/resources.ts index 658806c5e..b622a220b 100644 --- a/src/themes/resources.ts +++ b/src/themes/resources.ts @@ -8,6 +8,11 @@ export function isThemeAssetPath(value: unknown): value is string { return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..'); } +export function isThemeImageDataUrl(value: unknown): value is string { + return typeof value === 'string' && value.length <= 11_184_840 + && /^data:image\/(?:png|jpeg|webp|avif);base64,[A-Za-z0-9+/]+={0,2}$/.test(value); +} + export function normalizeThemeAssets( input: unknown, report: (field: string) => void, diff --git a/src/themes/themeSchema.ts b/src/themes/themeSchema.ts index 5f2b72f90..7eacfe740 100644 --- a/src/themes/themeSchema.ts +++ b/src/themes/themeSchema.ts @@ -37,7 +37,7 @@ export function buildThemeSchema(): Record { }, assets: { type: 'object', - description: 'Named relative paths inside the theme folder.', + description: 'Named relative paths inside the theme folder. backdropImage displays a PNG, JPEG, WebP or AVIF background (up to 8 MiB); other entries are reserved.', propertyNames: { pattern: '^[a-zA-Z][a-zA-Z0-9-]{0,31}$' }, additionalProperties: { type: 'string', pattern: themeAssetPathPattern }, }, @@ -80,6 +80,7 @@ export function buildThemeSchema(): Record { panel: { type: 'string', pattern: themeTokenKinds.color.schema.pattern }, inset: { type: 'string', pattern: themeTokenKinds.color.schema.pattern }, hover: { type: 'string', pattern: themeTokenKinds.color.schema.pattern }, + imagePosition: { type: 'array', items: { type: 'number', minimum: 0, maximum: 100 }, minItems: 2, maxItems: 2 }, }, }, tokens: { diff --git a/src/themes/types.ts b/src/themes/types.ts index cbea075f2..b445c1fd2 100644 --- a/src/themes/types.ts +++ b/src/themes/types.ts @@ -19,6 +19,7 @@ export type ThemeBackdrop = Readonly<{ panel: string; inset?: string; hover?: string; + imagePosition?: readonly [number, number]; }>; export type ThemePreview = { background: string; sidebar: string; surface: string; accent: string }; export type ThemeWindowMaterial = 'solid' | 'translucent'; @@ -37,6 +38,7 @@ export type ThemeDefinition = { windowMaterial?: ThemeWindowMaterial; icon?: string; assets?: Readonly>; + backdropImage?: string; tokens: Readonly; backdrop?: ThemeBackdrop; }; diff --git a/tests/cssThemeLint.test.ts b/tests/cssThemeLint.test.ts index 95bd9e2dc..04122409e 100644 --- a/tests/cssThemeLint.test.ts +++ b/tests/cssThemeLint.test.ts @@ -21,6 +21,7 @@ const EXCLUDED_DIRECTORIES = [ const REGISTERED_THEME_VARIABLES = new Set([ ...Object.values(themeTokenProperties).flat().filter((name) => name.startsWith('--theme-')), '--theme-backdrop-image', + '--theme-backdrop-position', '--theme-backdrop-color', '--theme-backdrop-sidebar', '--theme-backdrop-panel', diff --git a/tests/theme-images-ui.cjs b/tests/theme-images-ui.cjs new file mode 100644 index 000000000..e9544c714 --- /dev/null +++ b/tests/theme-images-ui.cjs @@ -0,0 +1,110 @@ +const { chromium, webkit } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { createServer: createPortProbe } = require('node:net'); + +const image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='; +const definition = { + schemaVersion: 1, id: 'mountain-dark', name: 'Mountain', appearance: 'dark', + windowBackground: '#142130', windowMaterial: 'translucent', + assets: { backdropImage: 'assets/mountain.png' }, + tokens: { + workspaceBackground: '#142130', surface: '#24384E', accent: '#F4C28F', + textPrimary: '#F5F1EA', popoverBackground: '#1C2D40', + }, + backdrop: { + layers: [{ kind: 'linear', angle: 180, stops: [{ color: '#14213044', at: 0 }, { color: '#14213099', at: 100 }] }], + sidebar: '#142130CC', panel: '#24384EDD', imagePosition: [50, 25], + }, +}; + +(async () => { + const { createServer } = await import('vite'); + const react = (await import('@vitejs/plugin-react')).default; + const probe = createPortProbe(); + await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); + const port = probe.address().port; + await new Promise(resolve => probe.close(resolve)); + const server = await createServer({ + configFile: false, root: path.resolve(__dirname, '..'), cacheDir: 'node_modules/.vite-theme-images', + plugins: [react()], logLevel: 'error', optimizeDeps: { entries: ['tests/fixtures/theme.html'] }, + server: { host: '127.0.0.1', port, strictPort: true }, + }); + let cases = 0; + try { + await server.listen(); + for (const engine of [chromium, webkit]) { + const browser = await engine.launch({ headless: true }); + try { + const page = await browser.newPage({ locale: 'zh-CN' }); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await page.route('**/__theme_fixture_selection', route => route.fulfill({ json: null })); + await page.goto(`http://127.0.0.1:${port}/tests/fixtures/theme.html?platform=macos&material=liquid-glass`); + await page.waitForFunction(() => window.themeFixture); + const load = async (content, backdropImage) => { + await page.evaluate(async ({ content, backdropImage }) => { + window.themeFixture.setCustomThemes([{ folder: 'mountain', content: JSON.stringify(content), backdropImage }]); + const theme = await import('/src/theme.ts'); + await theme.reloadCustomThemes(); + theme.initializeTheme().setPreference('mountain-dark'); + }, { content, backdropImage }); + await page.waitForFunction(() => document.documentElement.dataset.themeId === 'mountain-dark'); + }; + await load(definition, image); + const decoded = await page.evaluate(async source => { + const picture = new Image(); + picture.src = source; + await picture.decode(); + return picture.naturalWidth > 0 && picture.naturalHeight > 0; + }, image); + assert.equal(decoded, true, `${engine.name()}: valid image decodes`); + for (const [width, height] of [[1280, 800], [1920, 600], [800, 1200], [640, 600], [1440, 900]]) { + await page.setViewportSize({ width, height }); + const paint = await page.evaluate(() => { + const root = document.getElementById('root'); + const style = getComputedStyle(root); + const rect = root.getBoundingClientRect(); + return { + image: style.backgroundImage, size: style.backgroundSize, position: style.backgroundPosition, + repeat: style.backgroundRepeat, width: rect.width, height: rect.height, + material: document.documentElement.dataset.windowMaterial, + sticky: getComputedStyle(document.documentElement).getPropertyValue('--theme-sticky-background'), + sidebar: getComputedStyle(document.querySelector('.sidebar')).backgroundColor, + }; + }); + assert.ok(paint.image.includes(image), `${engine.name()}: photo is painted`); + assert.ok(paint.image.includes('linear-gradient'), 'tint remains above photo'); + assert.ok(paint.size.split(',').every(size => size.trim() === 'cover'), 'every layer covers the full window'); + assert.ok(paint.position.split(',').every(position => position.trim() === '50% 25%'), 'focal point stays anchored on resize'); + assert.ok(paint.repeat.split(',').every(repeat => repeat.trim() === 'no-repeat')); + assert.ok(paint.width >= width && paint.height >= height, 'paint surface covers viewport without blank margins'); + assert.equal(paint.material, undefined, 'native material cannot cover image'); + assert.ok(paint.sticky.includes('50% 25% / cover no-repeat fixed')); + assert.notEqual(paint.sidebar, 'rgba(0, 0, 0, 0)'); + cases++; + } + await page.locator('.theme-selector-trigger:visible').click(); + const menu = page.locator('.theme-selector-menu'); + await menu.waitFor(); + const background = await menu.evaluate(element => getComputedStyle(element).backgroundColor); + assert.equal(background, 'rgb(28, 45, 64)', 'theme picker retains an opaque overlay surface'); + await page.keyboard.press('Escape'); + await page.evaluate(async () => (await import('/src/theme.ts')).initializeTheme().setPreference('light')); + assert.equal(await page.locator('#root').evaluate(element => getComputedStyle(element).backgroundImage), 'none'); + await load(definition, undefined); + await page.waitForFunction(() => document.documentElement.dataset.windowMaterial === 'liquid-glass'); + const fallback = await page.evaluate(() => document.documentElement.style.getPropertyValue('--theme-backdrop-image')); + assert.ok(fallback.includes('gradient') && !fallback.includes('data:image'), 'missing photo restores gradient and material behavior'); + await load({ ...definition, backdrop: undefined }, image); + assert.ok((await page.locator('#root').evaluate(element => getComputedStyle(element).backgroundImage)).includes(image)); + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } + } + console.log(`theme-images-ui: passed ${cases} resize scenarios (2 engines × 5 sizes), image decode, overlay, switch and fallback checks`); + } finally { + await server.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/themeImages.test.ts b/tests/themeImages.test.ts new file mode 100644 index 000000000..f0e8953a8 --- /dev/null +++ b/tests/themeImages.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from 'bun:test'; +import { parseCustomThemes } from '../src/themes/customThemes'; +import { builtinThemes, createThemeRegistry, themePreview } from '../src/themes/registry'; +import { createThemeApplicator, themeWindowMaterial } from '../src/themes/applyTheme'; +import { themeBackdropImage } from '../src/themes/backdrop'; + +const image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='; +const definition = { + schemaVersion: 1, id: 'mountain-dark', name: 'Mountain', appearance: 'dark', + windowBackground: '#142130', windowMaterial: 'translucent', + assets: { backdropImage: 'assets/mountain.png' }, + tokens: { surface: '#24384E', workspaceBackground: '#142130', accent: '#F4C28F' }, + backdrop: { + layers: [{ kind: 'linear', angle: 180, stops: [{ color: '#14213044', at: 0 }, { color: '#14213099', at: 100 }] }], + sidebar: '#142130CC', panel: '#24384EDD', imagePosition: [50, 25], + }, +}; +const parse = (value: unknown = definition, data: string | undefined = image) => + parseCustomThemes([{ folder: 'mountain', content: JSON.stringify(value), backdropImage: data }]); + +test('loads backend-resolved images through the registry and renders matching preview artwork', () => { + const { themes, skipped, diagnostics } = parse(); + expect([skipped, diagnostics]).toEqual([[], []]); + const theme = createThemeRegistry([...builtinThemes, ...themes]).get('mountain-dark')!; + expect(theme.backdropImage).toBe(image); + expect(themeBackdropImage(theme)).toContain(`, url("${image}")`); + expect(themePreview(theme).background).toContain('50% 25% / cover no-repeat'); + for (const material of ['liquid-glass', 'vibrancy', 'mica', 'acrylic'] as const) { + expect(themeWindowMaterial(theme, material)).toBeNull(); + } + expect(Object.isFrozen(theme.backdrop!.imagePosition)).toBe(true); +}); + +test('missing or unsafe image payloads preserve the theme and original material fallback', () => { + for (const data of ['', 'https://example.com/photo.jpg', 'file:///photo.png', + 'data:image/svg+xml;base64,PHN2Zy8+', 'data:image/png;base64,AA==");color:red;/*', + `data:image/png;base64,${'A'.repeat(11_184_841)}`]) { + const { themes, skipped } = parse(definition, data); + expect(skipped).toEqual([]); + expect(themes[0].backdropImage).toBeUndefined(); + expect(themeBackdropImage(themes[0])).not.toContain('url('); + expect(themeWindowMaterial(themes[0], 'mica')).toBe('mica'); + } + const missing = parseCustomThemes([{ folder: 'mountain', content: JSON.stringify(definition) }]); + expect(missing.themes[0].backdropImage).toBeUndefined(); + for (const assets of [{}, { backdropImage: '../photo.png' }, { backdropImage: 'assets/photo.svg' }]) { + expect(parse({ ...definition, assets }).themes[0].backdropImage).toBeUndefined(); + } + expect(parse({ ...definition, backdropImage: image }).skipped[0].reason).toBe('invalid-metadata'); +}); + +test('rejects invalid image focal positions', () => { + for (const imagePosition of [[-1, 50], [50, 101], [50], [0, 50, 100], ['50%', 25], null]) { + expect(parse({ ...definition, backdrop: { ...definition.backdrop, imagePosition } }).skipped[0].reason).toBe('invalid-metadata'); + } +}); + +test('switching image, image-only and default themes clears artwork, focal position and material state', () => { + const properties = new Map(); + const root = { dataset: {} as Record, style: { + setProperty: (key: string, value: string) => properties.set(key, value), + removeProperty: (key: string) => properties.delete(key), + } }; + const document = { documentElement: root, body: { style: {} }, head: { append() {} }, querySelector: () => null, + createElement: () => ({ dataset: {}, textContent: '', remove() {} }) } as unknown as Document; + const apply = createThemeApplicator(document, () => 'mica'); + const theme = parse().themes[0]; + apply(theme); + expect(root.dataset.themeBackdrop).toBe('image'); + expect(root.dataset.windowMaterial).toBeUndefined(); + expect(properties.get('--theme-backdrop-position')).toBe('50% 25%'); + expect(properties.get('--theme-sticky-background')).toContain('50% 25% / cover no-repeat fixed'); + expect(properties.get('--theme-backdrop-image')).toBe(themeBackdropImage(theme)); + apply(parse({ ...definition, backdrop: undefined }).themes[0]); + expect(properties.get('--theme-backdrop-position')).toBe('center'); + expect(properties.get('--theme-backdrop-sidebar')).toBe('#142130'); + apply(builtinThemes[0]); + expect(root.dataset.themeBackdrop).toBeUndefined(); + for (const property of ['--theme-backdrop-image', '--theme-backdrop-position', '--theme-sticky-background']) { + expect(properties.has(property)).toBe(false); + } + apply(parse(definition, '').themes[0]); + expect(root.dataset.themeBackdrop).toBe('gradient'); + expect(root.dataset.windowMaterial).toBe('mica'); +}); diff --git a/theme.schema.json b/theme.schema.json index 4c9dd5f49..16d9c531d 100644 --- a/theme.schema.json +++ b/theme.schema.json @@ -98,7 +98,7 @@ }, "assets": { "type": "object", - "description": "Named relative paths inside the theme folder.", + "description": "Named relative paths inside the theme folder. backdropImage displays a PNG, JPEG, WebP or AVIF background (up to 8 MiB); other entries are reserved.", "propertyNames": { "pattern": "^[a-zA-Z][a-zA-Z0-9-]{0,31}$" }, @@ -201,6 +201,16 @@ "hover": { "type": "string", "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "imagePosition": { + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "minItems": 2, + "maxItems": 2 } } },