diff --git a/README.ja.md b/README.ja.md index 75af1d1a4..58b5ea9e3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -137,16 +137,32 @@ Codex の **セッション管理 → コンテキスト編集(実験的)** ## カスタムテーマ -テーマ 1 つにつき 1 つの `.json` ファイルを、アプリデータディレクトリの `themes` フォルダに置きます。Windows と Linux では実行ファイルと同じディレクトリ、macOS アプリでは `~/Library/Application Support/com.cpa.gui/themes` です(`.app` 以外から実行した場合は実行ファイルと同じディレクトリ)。テーマメニュー下部の **テーマフォルダを開く** でフォルダを作成して開き、**再読み込み** で再起動せずに変更を反映できます。カスタムテーマはファイル名順に組み込みテーマの後へ並びます。読み込むのは最大 64 ファイル、1 ファイル 64 KB までです。無効なファイルはスキップされ、そのファイル名と上限を超えたファイル数がテーマメニューに表示されます。 +カスタムテーマは、アプリデータディレクトリの `themes` フォルダ内のサブフォルダ 1 つにつき 1 つです。Windows と Linux では実行ファイルと同じディレクトリ、macOS アプリでは `~/Library/Application Support/com.cpa.gui/themes` です(`.app` 以外から実行した場合は実行ファイルと同じディレクトリ)。テーマメニュー下部の **テーマフォルダを開く** でフォルダを作成して開き、**再読み込み** で再起動せずに変更を反映できます。カスタムテーマはフォルダ名順に組み込みテーマの後へ並びます。 + +``` +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", @@ -155,23 +171,30 @@ Codex の **セッション管理 → コンテキスト編集(実験的)** "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`。ページ描画前にネイティブウィンドウに表示される色です。 -- `tokens`(必須、空オブジェクト可):色 token は `#RRGGBB` または `#RRGGBBAA`。省略した色は既定のライトまたはダークの値になります。以下の不透明度 token はパーセントで指定します。未知または削除済みの token キーは無視されます。 -- `backdrop`:任意のグラデーション背景。構造は組み込みのミッドナイトブルー、桜ピンクと同じです。 - `windowMaterial`:`solid`(既定)または `translucent`。半透明テーマではサイドバーとワークスペースの背後にネイティブのウィンドウマテリアルが表示されます。macOS 26 以降は Liquid Glass、それ以前の macOS は vibrancy、Windows 11 は Mica、Windows 10 1809 以降は Acrylic です。Linux と古い Windows ではマテリアルがなく、不透明テーマとまったく同じ表示になるため、`windowBackground` とトークンは省略せずに指定してください。マテリアル表示中は `backdrop` は使われません。 -- `tokens` の任意の不透明度キー:`windowMaterialWorkspaceOpacity`(ワークスペース、既定 `"72%"`)、`windowMaterialSidebarOpacity`(サイドバー、既定 `"40%"`)。値は `"0%"`(完全に透明)から `"100%"`(不透明)のパーセント文字列で、`"37.5%"` のような小数も使えます。対応する OS で `windowMaterial` が `translucent` の場合のみ有効です。ルート、body、シェルはネイティブマテリアルを表示するため透明になります。不正な値を含むファイルはスキップされます。マテリアルはテーマの設定であり、独立した設定項目はありません。組み込みテーマは不透明のままです。 -- `$schema` は記述可能で無視されます。それ以外のフィールドがあるとファイルは無効になります。 +- `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`, `sidebarBackground`, `sidebarText`, `sidebarTextStrong`, `sidebarTextMuted`, `sidebarBorder`, `sidebarHover`, `sidebarSelected`, `sidebarSelectedBorder`, `sidebarSelectedShadow`, `popoverBackground`, `overlaySoftSurface`, `overlayHoverBackground`, `scrim`, `controlThumb`, `windowMaterialWorkspaceOpacity`, `windowMaterialSidebarOpacity`。 +利用できる 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`, `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 定義から生成され、テーマファイルから参照できます。 ## クイックスタート diff --git a/README.md b/README.md index 9d3dcfb39..24d52c696 100644 --- a/README.md +++ b/README.md @@ -155,16 +155,32 @@ Manually renamed files without a session ID in their filename or a database path ## Custom Themes -Put one theme per `.json` file in 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 file name order. Up to 64 files of at most 64 KB each are read; an invalid file is skipped and its name is shown in the picker, along with the number of files beyond the limit. +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. + +``` +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", @@ -173,23 +189,30 @@ Put one theme per `.json` file in the `themes` folder of the application data di "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. -- `tokens` (required, may be empty): color tokens accept `#RRGGBB` or `#RRGGBBAA`; omitted colors keep the default light or dark value. The opacity tokens below accept percentages. Unknown or removed token keys are ignored. -- `backdrop`: optional gradient background with the same structure as the built-in Midnight Blue and Sakura Blush themes. - `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`. -- Optional opacity tokens in `tokens`: `windowMaterialWorkspaceOpacity` (workspace, default `"72%"`) and `windowMaterialSidebarOpacity` (sidebar, default `"40%"`). Each accepts a percentage string from `"0%"` (fully transparent) to `"100%"` (opaque), including decimals such as `"37.5%"`. These tokens only affect supported systems when `windowMaterial` is `translucent`; the root, body and shell stay transparent to expose the native material. Invalid values skip the file. This is part of the theme, with no separate material setting; built-in themes remain solid. -- `$schema` is allowed and ignored; any other field makes the file invalid. +- `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`, `sidebarBackground`, `sidebarText`, `sidebarTextStrong`, `sidebarTextMuted`, `sidebarBorder`, `sidebarHover`, `sidebarSelected`, `sidebarSelectedBorder`, `sidebarSelectedShadow`, `popoverBackground`, `overlaySoftSurface`, `overlayHoverBackground`, `scrim`, `controlThumb`, `windowMaterialWorkspaceOpacity`, `windowMaterialSidebarOpacity`. +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`, `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. ## Quick Start diff --git a/README.zh-CN.md b/README.zh-CN.md index d72c0ef33..50d788259 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -149,16 +149,32 @@ Codex 的 **会话管理 → 编辑上下文(实验性)** 仅在点击某个 ## 自定义主题 -每个主题一个 `.json` 文件,放在应用数据目录的 `themes` 文件夹中:Windows 和 Linux 为程序所在目录,macOS 应用为 `~/Library/Application Support/com.cpa.gui/themes`(不以 `.app` 方式运行时为程序所在目录)。主题菜单底部的 **打开主题目录** 会创建并打开该目录,**重新加载** 无需重启即可应用改动。自定义主题按文件名顺序排在内置主题之后。最多读取 64 个文件,单个文件不超过 64 KB;无效文件会被跳过,并在主题菜单中显示文件名及超出上限的文件数。 +每个自定义主题是应用数据目录 `themes` 文件夹下的一个子文件夹:Windows 和 Linux 为程序所在目录,macOS 应用为 `~/Library/Application Support/com.cpa.gui/themes`(不以 `.app` 方式运行时为程序所在目录)。主题菜单底部的 **打开主题目录** 会创建并打开该目录,**重新加载** 无需重启即可应用改动。自定义主题按文件夹名顺序排在内置主题之后。 + +``` +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", @@ -167,23 +183,30 @@ Codex 的 **会话管理 → 编辑上下文(实验性)** 仅在点击某个 "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`,页面绘制前原生窗口显示的颜色。 -- `tokens`(必填,可为空对象):颜色 token 取值为 `#RRGGBB` 或 `#RRGGBBAA`,未填写的颜色使用默认亮色或暗色取值。下述不透明度 token 使用百分比。未知或已移除的 token 键会被忽略。 -- `backdrop`:可选的渐变背景,结构与内置的午夜蓝、樱语红粉主题相同。 - `windowMaterial`:`solid`(默认)或 `translucent`。半透明主题会在侧边栏和工作区后面显示系统原生窗口材质:macOS 26 及以上为液态玻璃,更早的 macOS 为 vibrancy,Windows 11 为 Mica,Windows 10 1809 及以上为 Acrylic。Linux 和更早的 Windows 不支持材质,主题会按不透明主题原样显示,因此 `windowBackground` 和各项 token 仍需填写完整。显示材质时 `backdrop` 渐变不生效。 -- `tokens` 中可选的不透明度键:`windowMaterialWorkspaceOpacity`(工作区,默认 `"72%"`)、`windowMaterialSidebarOpacity`(侧栏,默认 `"40%"`)。取值为 `"0%"`(完全透明)到 `"100%"`(不透明)的百分比字符串,支持 `"37.5%"` 等小数。仅在系统支持材质且 `windowMaterial` 为 `translucent` 时生效;根节点、body 和外壳保持透明以显示原生材质。非法取值会跳过该文件。材质属于主题配置,没有独立设置项;内置主题仍保持不透明。 -- 允许出现 `$schema`(会被忽略);其他任何字段都会使文件无效。 +- `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`, `sidebarBackground`, `sidebarText`, `sidebarTextStrong`, `sidebarTextMuted`, `sidebarBorder`, `sidebarHover`, `sidebarSelected`, `sidebarSelectedBorder`, `sidebarSelectedShadow`, `popoverBackground`, `overlaySoftSurface`, `overlayHoverBackground`, `scrim`, `controlThumb`, `windowMaterialWorkspaceOpacity`, `windowMaterialSidebarOpacity`。 +可用的 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`, `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 定义生成,主题文件可以引用它。 ## 快速开始 diff --git a/package.json b/package.json index df96fad60..f28ee08f8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:ui:settings": "node tests/config-settings-ui.cjs", "test:ui:browser-mock": "node tests/browser-mock-ui.cjs", "screenshots": "node scripts/capture-readme-screenshots.cjs", + "theme:schema": "bun scripts/theme-schema.mjs", "tauri": "tauri" }, "dependencies": { diff --git a/scripts/theme-schema.mjs b/scripts/theme-schema.mjs new file mode 100644 index 000000000..29b7f667a --- /dev/null +++ b/scripts/theme-schema.mjs @@ -0,0 +1,4 @@ +import { writeFileSync } from 'node:fs'; +import { themeSchemaJson } from '../src/themes/themeSchema.ts'; + +writeFileSync(new URL('../theme.schema.json', import.meta.url), themeSchemaJson()); diff --git a/src-tauri/src/desktop_theme.rs b/src-tauri/src/desktop_theme.rs index 67fdff675..de518d39e 100644 --- a/src-tauri/src/desktop_theme.rs +++ b/src-tauri/src/desktop_theme.rs @@ -87,7 +87,7 @@ const CUSTOM_THEME_MAX_BYTES: u64 = 64 * 1024; #[derive(Debug, PartialEq, serde::Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomThemeFile { - file_name: String, + folder: String, content: Option, } @@ -109,6 +109,14 @@ fn read_custom_theme(path: &std::path::Path) -> Option { (content.len() as u64 <= CUSTOM_THEME_MAX_BYTES).then_some(content) } +fn theme_file_in(folder: &std::path::Path) -> Option { + let path = folder.join("theme.json"); + std::fs::symlink_metadata(&path) + .ok() + .filter(|metadata| metadata.is_file()) + .map(|_| path) +} + fn list_theme_files(directory: &std::path::Path) -> Result { let entries = match std::fs::read_dir(directory) { Ok(entries) => entries, @@ -121,18 +129,15 @@ fn list_theme_files(directory: &std::path::Path) -> Result CUSTOM_THEME_LIMIT { files.pop(); omitted += 1; @@ -142,9 +147,9 @@ fn list_theme_files(directory: &std::path::Path) -> Result>(), expected ); diff --git a/src/components/ThemeSelector.css b/src/components/ThemeSelector.css index 67cd04247..719f55cf4 100644 --- a/src/components/ThemeSelector.css +++ b/src/components/ThemeSelector.css @@ -70,6 +70,8 @@ .theme-selector-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .theme-selector-check { position: absolute; top: 3px; right: 3px; padding: 1px; border-radius: var(--radius-full); background: var(--ui-accent); color: var(--primary-contrast); } .theme-selector-custom-notice { padding: 6px 4px 0; } +.theme-selector-custom-problems { padding: 6px 4px 0; } +.theme-selector-hint { flex: none; margin: 0; padding: 6px 4px 0; color: var(--sidebar-text-subtle); font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; } .theme-selector-actions { display: flex; flex: none; justify-content: space-between; gap: 6px; margin-top: 6px; padding-top: 8px; border-top: 1px solid var(--sidebar-popover-edge); } .theme-selector-actions button { display: inline-flex; flex: 0 1 auto; align-items: center; justify-content: center; gap: 5px; min-width: 0; height: 28px; padding: 0 6px; border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-text); font: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } .theme-selector-actions button svg { flex: none; } diff --git a/src/components/ThemeSelector.tsx b/src/components/ThemeSelector.tsx index 28589afaa..1a15a0c0a 100644 --- a/src/components/ThemeSelector.tsx +++ b/src/components/ThemeSelector.tsx @@ -8,9 +8,25 @@ import { useResolvedThemeId, useSystemThemes, useThemePreferenceIssue, useThemeRegistry, } from '../theme'; import { themePreview, type ThemeRegistry } from '../themes/registry'; -import { localizeThemeLabel, type ThemeAppearance, type ThemeDefinition } from '../themes/types'; +import { localizeThemeLabel, type ThemeAppearance, type ThemeDefinition, type ThemeIssueCode } from '../themes/types'; +import type { CustomThemeSkipReason } from '../themes/customThemes'; +import type { MessageKey } from '../i18n/resources'; import './ThemeSelector.css'; +const skipReasonKeys: Record = { + unreadable: 'app.theme.custom.reason.unreadable', + 'invalid-json': 'app.theme.custom.reason.invalidJson', + 'unsupported-version': 'app.theme.custom.reason.unsupportedVersion', + 'invalid-metadata': 'app.theme.custom.reason.invalidMetadata', + 'duplicate-id': 'app.theme.custom.reason.duplicateId', +}; +const issueKeys: Record = { + 'unknown-token': 'app.theme.custom.issue.unknownToken', + 'invalid-token': 'app.theme.custom.issue.invalidToken', + 'invalid-icon': 'app.theme.custom.issue.invalidIcon', + 'invalid-asset': 'app.theme.custom.issue.invalidAsset', +}; + function ThemeThumbnail({ theme }: { theme: ThemeDefinition }) { const preview = themePreview(theme); return ( @@ -63,9 +79,16 @@ export function ThemeSelector({ value, onChange, open, onOpenChange, registry: o const customText = [ openFailed ? t('app.theme.custom.openFailed') : '', custom.failed ? t('app.theme.custom.loadFailed') : '', - custom.skipped.length ? t('app.theme.custom.skipped', { files: custom.skipped.join(', ') }) : '', custom.omitted ? t('app.theme.custom.omitted', { count: custom.omitted }) : '', ].filter(Boolean).join(' '); + const customProblems = [ + ...custom.skipped.map(({ folder, reason }) => + t('app.theme.custom.skippedFile', { folder, reason: t(skipReasonKeys[reason]) })), + ...custom.diagnostics.map(({ folder, issues }) => t('app.theme.custom.diagnostics', { + folder, + fields: issues.map(({ field, code }) => `${field} (${t(issueKeys[code])})`).join(', '), + })), + ]; const openDirectory = () => { setOpenFailed(false); openCustomThemesDirectory().catch(() => setOpenFailed(true)); @@ -174,6 +197,10 @@ export function ThemeSelector({ value, onChange, open, onOpenChange, registry: o ); })} {customText ?
{customText}
: null} + {customProblems.length ?
+ {customProblems.map((problem, index) =>
{problem}
)} +
: null} +

{t('app.theme.custom.folderHint')}

+ + +
Panel
+
Card
+
Section
+
Frame
+
Sunken
+
Dashed
+ setChecked(event.currentTarget.checked)} label="Switch" /> + + + + {dialogOpen ? setDialogOpen(false)} actions={}> + + : null} +
; +} + +Object.assign(window, { + themeContractFixture: { + apply: (id: string) => controller.setPreference(id), + setDialog: (open: boolean) => setDialogState(open), + }, +}); + +createRoot(document.getElementById('root')!).render(); diff --git a/tests/fixtures/theme.tsx b/tests/fixtures/theme.tsx index 18997b15b..4f11714d9 100644 --- a/tests/fixtures/theme.tsx +++ b/tests/fixtures/theme.tsx @@ -19,18 +19,18 @@ let override: AppTheme | null = null; let appearance: AppTheme = system; const calls: { cmd: string; args: unknown }[] = []; const customTheme = (id: string, name: unknown, extra = {}) => JSON.stringify({ - id, name, appearance: 'dark', windowBackground: '#0E1A1F', tokens: { workspaceBackground: '#0E1A1F', accent: '#6FD3C1' }, ...extra, + schemaVersion: 1, id, name, appearance: 'dark', windowBackground: '#0E1A1F', tokens: { workspaceBackground: '#0E1A1F', accent: '#6FD3C1' }, ...extra, }); let customError = params.has('custom-error'); let customOmitted = params.has('custom') ? 3 : 0; let customThemes = params.has('custom') ? [ - { fileName: 'a-harbor.json', content: customTheme('harbor-dark', { en: 'Harbor', 'zh-CN': '港湾' }) }, - { fileName: 'b-broken.json', content: '{' }, - { fileName: 'c-duplicate.json', content: customTheme('dark', 'Shadow') }, - { fileName: 'd-extra.json', content: customTheme('extra-dark', 'Extra', { order: 1 }) }, - { fileName: 'e-large.json', content: null }, + { folder: 'a-harbor', content: customTheme('harbor-dark', { en: 'Harbor', 'zh-CN': '港湾' }) }, + { folder: 'b-broken', content: '{' }, + { folder: 'c-duplicate', content: customTheme('dark', 'Shadow') }, + { folder: 'd-extra', content: customTheme('extra-dark', 'Extra', { order: 1 }) }, + { folder: 'e-large', content: null }, ] : params.has('glass') ? [ - { fileName: 'glass.json', content: customTheme('glass-dark', 'Glass', { windowMaterial: 'translucent' }) }, + { folder: 'glass', content: customTheme('glass-dark', 'Glass', { windowMaterial: 'translucent' }) }, ] : []; const notify = (theme: AppTheme) => emit('tauri://theme-changed', theme); mockWindows('main'); diff --git a/tests/stage2FixesRegression.test.ts b/tests/stage2FixesRegression.test.ts index c4661739d..98f649a0b 100644 --- a/tests/stage2FixesRegression.test.ts +++ b/tests/stage2FixesRegression.test.ts @@ -195,7 +195,7 @@ describe('Stage 2 acceptance fixes regression', () => { it('P2-4: surface-sunken implements subtle background and subtle border', () => { const sunkenDecls = getDeclarations(surfacesRoot, ':where(.surface-sunken)'); expect(sunkenDecls.background).toBe('var(--color-surface-subtle)'); - expect(sunkenDecls.border).toBe('1px solid var(--color-border-subtle)'); + expect(sunkenDecls.border).toBe('var(--card-border-width) solid var(--color-border-subtle)'); const lightBg = resolveToken(sunkenDecls.background, allTokensLight); const lightBorder = resolveToken(sunkenDecls.border, allTokensLight); diff --git a/tests/stateControls.test.tsx b/tests/stateControls.test.tsx index e24210729..e48eb9bc8 100644 --- a/tests/stateControls.test.tsx +++ b/tests/stateControls.test.tsx @@ -39,7 +39,7 @@ const roleExceptions: Record = { 'components/AuthFileSettingsDialog.tsx': [0, 1, 'Catalog loading text.'], 'components/StructuredConfigEditor.tsx': [1, 0, 'Field-level key error.'], 'components/TemplateConfigSection.tsx': [1, 2, 'Field-level validation error, loading text and save status.'], - 'components/ThemeSelector.tsx': [0, 2, 'Sidebar popover notices.'], + 'components/ThemeSelector.tsx': [0, 3, 'Sidebar popover notices and per-folder theme diagnostics.'], 'components/claude-code/ClaudeCodeModelInputs.tsx': [0, 1, 'Field hint.'], 'pages/AgentClientList.tsx': [0, 2, 'Result count and selection summary.'], 'pages/AgentConfigBackupDialog.tsx': [0, 1, 'Loading text.'], diff --git a/tests/theme-contract-ui.cjs b/tests/theme-contract-ui.cjs new file mode 100644 index 000000000..0c1400c8c --- /dev/null +++ b/tests/theme-contract-ui.cjs @@ -0,0 +1,160 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { createServer: createPortProbe } = require('node:net'); + +(async () => { + const root = path.resolve(__dirname, '..'); + const { createServer } = await import('vite'); + + const probe = createPortProbe(); + await new Promise((resolve, reject) => { + probe.once('error', reject); + probe.listen(0, '127.0.0.1', resolve); + }); + const port = probe.address().port; + await new Promise((resolve, reject) => probe.close((error) => (error ? reject(error) : resolve()))); + + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.js'), + cacheDir: 'node_modules/.vite-theme-contract-ui', + logLevel: 'error', + server: { host: '127.0.0.1', port, strictPort: true }, + }); + + let browser; + try { + await server.listen(); + const base = `http://127.0.0.1:${server.httpServer.address().port}`; + browser = await chromium.launch({ headless: true, args: ['--no-proxy-server'] }); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, colorScheme: 'light' }); + const errors = []; + page.on('pageerror', (error) => errors.push(String(error))); + page.setDefaultTimeout(10000); + await page.goto(`${base}/tests/fixtures/theme-contract.html`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.themeContractFixture); + await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' }); + + const apply = async (id) => { + await page.evaluate((themeId) => window.themeContractFixture.apply(themeId), id); + await page.waitForFunction((themeId) => document.documentElement.dataset.themeId === themeId, id); + }; + const setDialog = async (open) => { + await page.evaluate((value) => window.themeContractFixture.setDialog(value), open); + await page.locator('.app-dialog').waitFor({ state: open ? 'attached' : 'detached' }); + }; + const style = (selector, properties) => page.locator(selector).first().evaluate((element, names) => { + const computed = getComputedStyle(element); + return Object.fromEntries(names.map((name) => [name, computed.getPropertyValue(name)])); + }, properties); + const widths = (selector) => style(selector, ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width']); + const inline = (property) => page.evaluate((name) => document.documentElement.style.getPropertyValue(name), property); + const expectWidths = async (selector, expected, label) => { + assert.deepEqual(await widths(selector), { + 'border-top-width': expected, 'border-right-width': expected, 'border-bottom-width': expected, 'border-left-width': expected, + }, `${label} must use ${expected} borders`); + }; + + await apply('graphite-dark'); + await setDialog(true); + const baseline = { + button: await widths('.secondary-button'), + buttonRadius: (await style('.secondary-button', ['border-radius']))['border-radius'], + panel: await widths('.panel'), + panelRadius: (await style('.panel', ['border-radius']))['border-radius'], + panelShadow: (await style('.panel', ['box-shadow']))['box-shadow'], + switchTrack: await widths('.switch-track'), + tabs: await widths('[role="tablist"]'), + dialog: await widths('.app-dialog'), + dialogRadius: (await style('.app-dialog', ['border-radius']))['border-radius'], + input: await widths('.config-dialog-text-input'), + }; + assert.deepEqual(baseline.button, { 'border-top-width': '1px', 'border-right-width': '1px', 'border-bottom-width': '1px', 'border-left-width': '1px' }); + assert.equal(baseline.tabs['border-bottom-width'], '1px'); + assert.equal(baseline.buttonRadius, '8px'); + assert.equal(baseline.panelRadius, '12px'); + await setDialog(false); + assert.equal(await inline('--border-width'), ''); + assert.equal(await inline('--card-border-width'), ''); + assert.equal(await inline('--radius-md'), ''); + + await apply('contract-dark'); + assert.equal(await inline('--border-width'), '2px'); + assert.equal(await inline('--card-border-width'), '3px'); + assert.equal(await inline('--radius-md'), '2px'); + assert.equal(await inline('--shadow-small'), ''); + + await expectWidths('.secondary-button', '2px', 'Secondary buttons'); + await page.locator('.secondary-button').first().hover(); + await expectWidths('.secondary-button', '2px', 'Hovered secondary buttons'); + await page.locator('.secondary-button').first().focus(); + await expectWidths('.secondary-button', '2px', 'Focused secondary buttons'); + await page.mouse.move(0, 0); + await expectWidths('.primary-button', '2px', 'Primary buttons'); + await expectWidths('.secondary-button[disabled]', '2px', 'Disabled buttons'); + await expectWidths('.switch-track', '2px', 'Switches'); + await expectWidths('.select-control', '2px', 'Selects'); + const tabsBorder = await style('[role="tablist"]', ['border-bottom-width', 'border-bottom-style']); + assert.deepEqual(tabsBorder, { 'border-bottom-width': '2px', 'border-bottom-style': 'solid' }, 'Tabs consume the control border width'); + await setDialog(true); + await expectWidths('.app-dialog', '2px', 'Dialogs'); + await expectWidths('.config-dialog-text-input', '2px', 'Dialog inputs'); + for (const surface of ['panel', 'card', 'section', 'frame', 'sunken', 'dashed']) { + await expectWidths(`[data-probe="${surface}"]`, '3px', `Surface ${surface}`); + } + assert.equal((await style('.secondary-button', ['border-radius']))['border-radius'], '2px'); + assert.equal((await style('.panel', ['border-radius']))['border-radius'], '20px'); + assert.equal((await style('.app-dialog', ['border-radius']))['border-radius'], '2px'); + assert.equal((await style('.config-dialog-text-input', ['border-radius']))['border-radius'], '2px'); + await setDialog(false); + + const openMenu = async () => { + if (await page.locator('.select-menu.open').count() === 0) await page.locator('.select-menu-trigger').click(); + await page.locator('.select-menu-dropdown').waitFor(); + return style('.select-menu-dropdown', ['box-shadow']); + }; + const shadow = (await openMenu())['box-shadow']; + assert.match(shadow, /rgb\(0, 0, 255\)|#0000ff/, 'SelectMenu popovers consume the themed shadow'); + await page.keyboard.press('Escape'); + + await apply('graphite-dark'); + assert.deepEqual(await widths('.secondary-button'), baseline.button); + assert.deepEqual(await widths('.panel'), baseline.panel); + assert.deepEqual(await widths('.switch-track'), baseline.switchTrack); + assert.deepEqual(await widths('[role="tablist"]'), baseline.tabs); + assert.equal((await style('.secondary-button', ['border-radius']))['border-radius'], baseline.buttonRadius); + assert.equal((await style('.panel', ['border-radius']))['border-radius'], baseline.panelRadius); + await setDialog(true); + assert.deepEqual(await widths('.app-dialog'), baseline.dialog); + assert.deepEqual(await widths('.config-dialog-text-input'), baseline.input); + assert.equal((await style('.app-dialog', ['border-radius']))['border-radius'], baseline.dialogRadius); + await setDialog(false); + assert.equal((await style('.panel', ['box-shadow']))['box-shadow'], baseline.panelShadow); + for (const property of ['--border-width', '--card-border-width', '--radius-small', '--radius-medium', '--radius-large', '--shadow-small', '--shadow-large', '--shadow-popover']) { + assert.equal(await inline(property), '', `${property} must be cleared when switching to a theme that does not set it`); + } + + await apply('contract-dark'); + await apply('sakura-light'); + assert.ok(await page.locator('style[data-theme-materials]').count() > 0, 'gradient themes keep their overlay rules'); + assert.ok(await page.evaluate(() => document.documentElement.dataset.themeBackdrop === 'gradient')); + await apply('graphite-dark'); + const overlayRules = await page.locator('style[data-theme-materials]').evaluate((element) => element.textContent); + assert.doesNotMatch(overlayRules, /#F7E3ED/, 'leaving a gradient theme replaces the overlay palette'); + assert.match(overlayRules, /#24282D/); + assert.equal(await page.evaluate(() => 'themeBackdrop' in document.documentElement.dataset), false); + assert.equal((await style('.panel', ['box-shadow']))['box-shadow'], baseline.panelShadow); + await apply('light'); + assert.equal(await page.locator('style[data-theme-materials]').count(), 0, 'a theme without tokens removes the overlay rules'); + + assert.deepEqual(errors, []); + console.log('theme contract UI checks passed'); + } finally { + await browser?.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/theme-ui.cjs b/tests/theme-ui.cjs index a53f93b6c..97be88d92 100644 --- a/tests/theme-ui.cjs +++ b/tests/theme-ui.cjs @@ -163,9 +163,15 @@ const assert = require('node:assert/strict'); assert.equal(await page.locator('.theme-selector-group[data-appearance="dark"] .theme-selector-card').last().getAttribute('data-theme-id'), 'harbor-dark'); assert.equal(await card('harbor-dark').innerText(), '港湾'); const customNotice = () => page.locator('.theme-selector-custom-notice'); - for (const name of ['b-broken.json', 'c-duplicate.json', 'd-extra.json', 'e-large.json']) assert.match(await customNotice().innerText(), new RegExp(name.replace('.', '\\.'))); - assert.doesNotMatch(await customNotice().innerText(), /a-harbor/); - assert.match(await customNotice().innerText(), /另有 3 个主题文件超出/); + const customProblems = () => page.locator('.theme-selector-custom-problems'); + const problems = await customProblems().innerText(); + for (const name of ['b-broken', 'c-duplicate', 'd-extra', 'e-large']) assert.match(problems, new RegExp(name)); + assert.doesNotMatch(problems, /a-harbor/); + for (const reason of ['JSON 格式错误', 'id 与已有主题重复', '主题元信息无效', '无法读取该文件']) { + assert.match(problems, new RegExp(reason)); + } + assert.match(await customNotice().innerText(), /另有 3 个主题文件夹超出/); + assert.match(await page.locator('.theme-selector-hint').innerText(), /themes\/<.*>\/theme\.json/); await page.locator('[data-action="open-themes"]').click(); await page.waitForFunction(() => window.themeFixture.calls.some(call => call.cmd === 'open_custom_themes_directory')); await page.evaluate(() => window.themeFixture.setCustomThemes([], true)); @@ -178,6 +184,18 @@ const assert = require('node:assert/strict'); assert.equal(await customNotice().count(), 0); assert.match(await trigger().getAttribute('aria-label'), /主题不可用/); assert.equal(await page.locator('html').getAttribute('data-theme-id'), 'light'); + await page.evaluate(() => window.themeFixture.setCustomThemes([{ folder: 'diag', content: JSON.stringify({ + schemaVersion: 1, id: 'diag-dark', name: 'Diag', appearance: 'dark', windowBackground: '#0E1A1F', + tokens: { accent: '#6FD3C1', spacing: '8px', radiusMedium: '40px', borderWidth: '2px' }, + }) }])); + await page.locator('[data-action="reload-themes"]').click(); + await page.locator('.theme-selector-custom-problems').waitFor(); + assert.equal(await card('diag-dark').count(), 1, 'a theme with discarded tokens still loads'); + const diagnostics = await page.locator('.theme-selector-custom-problems').innerText(); + assert.match(diagnostics, /diag/); + assert.match(diagnostics, /spacing(未知 token)|spacing \(未知 token\)/); + assert.match(diagnostics, /radiusMedium(取值无效)|radiusMedium \(取值无效\)/); + assert.match(diagnostics, /borderWidth(未知 token)|borderWidth \(未知 token\)/); await page.keyboard.press('Escape'); await open('platform=macos&open-error'); await nativeReady(); await openMenu(); diff --git a/tests/themeAccentContrast.test.ts b/tests/themeAccentContrast.test.ts index 7334550d0..9967eda9f 100644 --- a/tests/themeAccentContrast.test.ts +++ b/tests/themeAccentContrast.test.ts @@ -38,7 +38,7 @@ test('default accents keep readable contrast text', async () => { const light = css.slice(css.indexOf(':root {')); const dark = css.slice(css.indexOf(":root[data-theme='dark'] {")); for (const block of [light, dark]) { - expect(contrast(value(block, 'color-accent'), value(block, 'color-accent-contrast'))).toBeGreaterThanOrEqual(4.5); + expect(contrast(value(block, 'color-accent'), value(block, 'color-text-on-accent'))).toBeGreaterThanOrEqual(4.5); } for (const theme of themeRegistry.themes) { const { accent, accentContrast } = theme.tokens; diff --git a/tests/themeContract.test.ts b/tests/themeContract.test.ts new file mode 100644 index 000000000..69b07620f --- /dev/null +++ b/tests/themeContract.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { + isThemeTokenKey, isValidThemeToken, normalizeThemeTokens, themeTokenDefinitions, + themeTokenProperties, type ThemeTokenKey, +} from '../src/themes/tokenContract'; +import { themeSchemaJson } from '../src/themes/themeSchema'; +import { builtinThemes, normalizeTheme } from '../src/themes/registry'; +import { themeOverlayRules } from '../src/themes/materials'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const tokenKeys = Object.keys(themeTokenDefinitions) as ThemeTokenKey[]; + +async function sourceText(): Promise { + const parts: string[] = []; + for (const pattern of ['src/**/*.css', 'src/**/*.ts', 'src/**/*.tsx']) { + for await (const file of new Bun.Glob(pattern).scan(root)) { + if (file === 'src/themes/tokenContract.ts') continue; + parts.push(await readFile(`${root}${file}`, 'utf8')); + } + } + return parts.join('\n'); +} + +describe('theme token contract', () => { + test('keeps every exposed token appearance-only and typed', () => { + expect(tokenKeys.length).toBeGreaterThan(40); + for (const key of tokenKeys) expect(key).not.toMatch(/size|spacing|gap|height|font|line|padding|margin/i); + expect(themeTokenDefinitions.cardBorderWidth.kind).toBe('border-width'); + expect(themeTokenDefinitions.radiusLarge.kind).toBe('radius'); + expect(themeTokenDefinitions.shadowDialog.kind).toBe('shadow'); + expect(themeTokenDefinitions.windowMaterialWorkspaceOpacity.kind).toBe('opacity'); + expect(themeTokenDefinitions.accent.kind).toBe('color'); + }); + + test('validates values by kind and range', () => { + for (const value of ['#ffffff', '#FFFFFF00']) expect(isValidThemeToken('accent', value)).toBe(true); + for (const value of ['red', '#fff', 'rgba(0,0,0,0.5)', 'url(x)', 42, null]) expect(isValidThemeToken('accent', value)).toBe(false); + for (const value of ['0%', '100%', '37.5%']) expect(isValidThemeToken('windowMaterialWorkspaceOpacity', value)).toBe(true); + for (const value of ['-1%', '100.1%', '50']) expect(isValidThemeToken('windowMaterialWorkspaceOpacity', value)).toBe(false); + for (const value of ['0px', '4px', '2.5px']) expect(isValidThemeToken('controlBorderWidth', value)).toBe(true); + for (const value of ['-1px', '4.5px', '5px', '2', '2em']) expect(isValidThemeToken('controlBorderWidth', value)).toBe(false); + for (const value of ['0px', '24px']) expect(isValidThemeToken('radiusMedium', value)).toBe(true); + for (const value of ['25px', '-2px', '50%']) expect(isValidThemeToken('radiusMedium', value)).toBe(false); + expect(isValidThemeToken('radiusFull', '9999px')).toBe(true); + expect(isValidThemeToken('radiusFull', '10000px')).toBe(false); + for (const value of ['none', '0 1px 2px rgba(45, 42, 38, 0.08)', '0 4px 16px #00000040, inset 0 0 0 1px #ffffff20']) + expect(isValidThemeToken('shadowSmall', value)).toBe(true); + for (const value of ['0 1px 2px', '1px solid red', 'url(x)', '0 1px 2px var(--x)']) + expect(isValidThemeToken('shadowSmall', value)).toBe(false); + }); + + test('normalizes tokens into the runtime whitelist', () => { + const issues: string[] = []; + const tokens = normalizeThemeTokens({ + accent: '#112233', unknownToken: '#112233', radiusMedium: '8px', borderWidth: '2px', + spacing: '8px', shadowSmall: 'none', surface: '#zzzzzz', + }, (issue) => issues.push(`${issue.code}:${issue.field}`)); + expect(tokens).toEqual({ accent: '#112233', radiusMedium: '8px', shadowSmall: 'none' }); + expect(issues).toEqual(['unknown-token:unknownToken', 'unknown-token:borderWidth', 'unknown-token:spacing', 'invalid-token:surface']); + }); + + test('built-in themes only use registered tokens and valid values', () => { + for (const theme of builtinThemes) { + const issues: string[] = []; + normalizeTheme(theme, (issue) => issues.push(`${issue.code}:${issue.field}`)); + expect(issues).toEqual([]); + for (const key of Object.keys(theme.tokens)) expect(isThemeTokenKey(key)).toBe(true); + } + }); + + test('every exposed property is consumed by styles or the generated overlay rules', async () => { + const source = await sourceText(); + const overlay = themeOverlayRules(normalizeTheme({ + id: 'probe-dark', name: 'Probe', appearance: 'dark', order: 99, windowBackground: '#000000', + tokens: { accent: '#ffffff' }, + })); + const unaccounted = tokenKeys.flatMap((key) => themeTokenProperties[key] + .filter((property) => !source.includes(property) && !overlay.includes(property))); + expect(unaccounted).toEqual([]); + }); + + test('tokens without CSS properties are still consumed programmatically', () => { + const propertyless = tokenKeys.filter((key) => themeTokenProperties[key].length === 0); + expect(propertyless).toEqual(['overlayHoverBackground']); + const overlay = themeOverlayRules(normalizeTheme({ + id: 'probe-overlay-dark', name: 'Probe', appearance: 'dark', order: 99, windowBackground: '#000000', + tokens: { accent: '#ffffff', overlayHoverBackground: '#112233' }, + })); + expect(overlay).toContain('--theme-sticky-hover-background:#112233'); + }); + + test('generated theme.schema.json matches the contract', async () => { + expect(await readFile(`${root}theme.schema.json`, 'utf8')).toBe(themeSchemaJson()); + const schema = JSON.parse(themeSchemaJson()) as { + required: string[]; + properties: { tokens: { additionalProperties: boolean; properties: Record } }; + }; + expect(schema.required).toContain('schemaVersion'); + expect(schema.properties.tokens.additionalProperties).toBe(false); + expect(Object.keys(schema.properties.tokens.properties).sort()).toEqual([...tokenKeys].sort()); + }); +}); diff --git a/tests/themeRegistry.test.ts b/tests/themeRegistry.test.ts index 9c167ab88..feb8a1ffa 100644 --- a/tests/themeRegistry.test.ts +++ b/tests/themeRegistry.test.ts @@ -94,11 +94,16 @@ describe('theme registry and application', () => { expect(themeRegistry.resolveSystem('dark', { ...remembered, dark: id }).id).toBe('dark'); } }); - test('rejects collisions, reserved IDs, invalid native colors', () => { + test('rejects collisions, reserved IDs and invalid native colors but drops invalid tokens', () => { for (const theme of [ { ...custom, id: 'dark' }, { ...custom, id: 'system' }, { ...custom, id: '../bad' }, - { ...custom, windowBackground: '#11223344' }, { ...custom, tokens: { surface: 'url(bad)' } }, + { ...custom, windowBackground: '#11223344' }, ]) expect(() => createThemeRegistry([...themeRegistry.themes, theme as ThemeDefinition])).toThrow(); + const issues: string[] = []; + const dropped = normalizeTheme({ ...custom, tokens: { surface: 'url(bad)', spacing: '8px', accent: '#abcdef' } }, + (issue) => issues.push(`${issue.code}:${issue.field}`)); + expect(dropped.tokens).toEqual({ accent: '#abcdef' }); + expect(issues).toEqual(['invalid-token:surface', 'unknown-token:spacing']); }); test('removed themes leave the picker and safely fall back for saved selections', () => { for (const id of ['mist-blue-light', 'rose-paper-light', 'cyberpunk-dark', 'pine-dark']) { diff --git a/tests/window-material-ui.cjs b/tests/window-material-ui.cjs index 25d029c1f..1ba33c066 100644 --- a/tests/window-material-ui.cjs +++ b/tests/window-material-ui.cjs @@ -16,7 +16,7 @@ const base = process.env.THEME_TEST_URL || 'http://127.0.0.1:1422'; }))); const reload = async (tokens, windowMaterial = 'translucent') => { await page.evaluate(async ({ tokens, windowMaterial, appearance }) => { - window.themeFixture.setCustomThemes([{ fileName: 'glass.json', content: JSON.stringify({ + window.themeFixture.setCustomThemes([{ folder: 'glass', content: JSON.stringify({ id: 'glass-dark', name: 'Glass', appearance, windowBackground: '#0E1A1F', windowMaterial, tokens: { workspaceBackground: '#0E1A1F', accent: '#6FD3C1', ...tokens }, }) }]); diff --git a/tests/windowMaterialOpacity.test.ts b/tests/windowMaterialOpacity.test.ts index 15ffd16d2..2d0ad87b3 100644 --- a/tests/windowMaterialOpacity.test.ts +++ b/tests/windowMaterialOpacity.test.ts @@ -14,7 +14,7 @@ describe('window material opacity tokens', () => { const definition = normalizeTheme({ ...base, tokens: { [key]: value } }); expect(definition.tokens[key as keyof typeof themeOpacityTokenProperties]).toBe(value); const { order: _order, ...external } = definition; - const result = parseCustomThemes([{ fileName: 'glass.json', content: JSON.stringify(external) }]); + const result = parseCustomThemes([{ folder: 'glass', content: JSON.stringify(external) }]); expect(result.skipped).toEqual([]); expect(result.themes[0].tokens).toEqual(definition.tokens); const styles = new Map(); @@ -32,11 +32,12 @@ describe('window material opacity tokens', () => { } for (const value of ['-1%', '100.1%', '50', '50 %', 'NaN%', 'Infinity%', '#ffffff', 'var(--x)', '', 50, null, [], {}]) { const definition = { ...base, tokens: { [key]: value } } as unknown as ThemeDefinition; - expect(() => normalizeTheme(definition)).toThrow('Invalid theme color'); + expect(normalizeTheme(definition).tokens).toEqual({}); const { order: _order, ...external } = definition; - expect(parseCustomThemes([{ fileName: 'bad.json', content: JSON.stringify(external) }])).toEqual({ - themes: [], skipped: ['bad.json'], - }); + const result = parseCustomThemes([{ folder: 'bad', content: JSON.stringify(external) }]); + expect(result.skipped).toEqual([]); + expect(result.themes[0].tokens).toEqual({}); + expect(result.diagnostics).toEqual([{ folder: 'bad', issues: [{ field: key, code: 'invalid-token' }] }]); } }); } diff --git a/theme.schema.json b/theme.schema.json new file mode 100644 index 000000000..b99e27870 --- /dev/null +++ b/theme.schema.json @@ -0,0 +1,450 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EasyCLIProxyAPI theme", + "description": "A theme loaded from /themes//theme.json.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "id", + "name", + "appearance", + "windowBackground", + "tokens" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schemaVersion": { + "const": 1 + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "name": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + { + "type": "object", + "minProperties": 1, + "propertyNames": { + "enum": [ + "zh-CN", + "zh-TW", + "ja", + "en" + ] + }, + "additionalProperties": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + } + ] + }, + "description": { + "oneOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + { + "type": "object", + "minProperties": 1, + "propertyNames": { + "enum": [ + "zh-CN", + "zh-TW", + "ja", + "en" + ] + }, + "additionalProperties": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + } + ] + }, + "appearance": { + "enum": [ + "light", + "dark" + ] + }, + "windowBackground": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "windowMaterial": { + "enum": [ + "solid", + "translucent" + ] + }, + "icon": { + "type": "string", + "description": "Relative path inside the theme folder to a png, jpg, jpeg, webp, svg or avif icon.", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?(?:/|$))(?!.*\\\\).+\\.(?:png|jpe?g|webp|svg|avif)$" + }, + "assets": { + "type": "object", + "description": "Named relative paths inside the theme folder.", + "propertyNames": { + "pattern": "^[a-zA-Z][a-zA-Z0-9-]{0,31}$" + }, + "additionalProperties": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?(?:/|$))(?!.*\\\\).+\\.(?:png|jpe?g|webp|svg|avif)$" + } + }, + "backdrop": { + "type": "object", + "additionalProperties": false, + "required": [ + "layers", + "sidebar", + "panel" + ], + "properties": { + "layers": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { + "type": "object", + "required": [ + "kind", + "stops" + ], + "properties": { + "kind": { + "enum": [ + "linear", + "radial" + ] + }, + "angle": { + "type": "number", + "minimum": 0, + "maximum": 360 + }, + "center": { + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "minItems": 2, + "maxItems": 2 + }, + "size": { + "type": "array", + "items": { + "type": "number", + "minimum": 1, + "maximum": 200 + }, + "minItems": 2, + "maxItems": 2 + }, + "stops": { + "type": "array", + "minItems": 2, + "maxItems": 8, + "items": { + "type": "object", + "required": [ + "color", + "at" + ], + "properties": { + "color": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "at": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "sidebar": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "panel": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "inset": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "hover": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + } + } + }, + "tokens": { + "type": "object", + "additionalProperties": false, + "description": "Appearance tokens. Sizes, spacing, layout and typography are not themeable.", + "properties": { + "windowMaterialWorkspaceOpacity": { + "type": "string", + "pattern": "^(?:100(?:\\.0+)?|\\d{1,2}(?:\\.\\d+)?)%$", + "description": "Percentage from 0% to 100%." + }, + "windowMaterialSidebarOpacity": { + "type": "string", + "pattern": "^(?:100(?:\\.0+)?|\\d{1,2}(?:\\.\\d+)?)%$", + "description": "Percentage from 0% to 100%." + }, + "workspaceBackground": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "surface": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "softSurface": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "hoverBackground": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "textPrimary": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "textSecondary": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "textTertiary": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "textMuted": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "border": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "borderSubtle": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "borderStrong": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "borderHover": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "panelShadow": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "accent": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "accentStrong": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "accentSoft": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "accentContrast": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "accentShadow": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "focusRing": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "primaryButtonBackground": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "primaryButtonHoverBackground": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "primaryButtonText": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "primaryButtonBorder": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "primaryButtonHoverBorder": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "controlActiveBackground": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "controlActiveThumb": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "controlThumb": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "sidebarBackground": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "sidebarText": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "sidebarTextStrong": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "sidebarTextMuted": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "sidebarBorder": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "sidebarHover": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "sidebarSelected": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "sidebarSelectedBorder": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "sidebarSelectedShadow": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "popoverBackground": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "overlaySoftSurface": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "overlayHoverBackground": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "scrim": { + "type": "string", + "pattern": "^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$" + }, + "radiusSmall": { + "type": "string", + "pattern": "^(?:[0-9]|1\\d|2[0-4])(?:\\.\\d+)?px$", + "description": "Corner radius from 0px to 24px." + }, + "radiusMedium": { + "type": "string", + "pattern": "^(?:[0-9]|1\\d|2[0-4])(?:\\.\\d+)?px$", + "description": "Corner radius from 0px to 24px." + }, + "radiusLarge": { + "type": "string", + "pattern": "^(?:[0-9]|1\\d|2[0-4])(?:\\.\\d+)?px$", + "description": "Corner radius from 0px to 24px." + }, + "radiusFull": { + "type": "string", + "pattern": "^(?:0|[1-9]\\d{0,3})(?:\\.\\d+)?px$", + "description": "Corner radius from 0px to 9999px." + }, + "controlBorderWidth": { + "type": "string", + "pattern": "^(?:[0-3](?:\\.\\d+)?|4(?:\\.0+)?)px$", + "description": "Border width from 0px to 4px." + }, + "cardBorderWidth": { + "type": "string", + "pattern": "^(?:[0-3](?:\\.\\d+)?|4(?:\\.0+)?)px$", + "description": "Border width from 0px to 4px." + }, + "shadowExtraSmall": { + "type": "string", + "pattern": "^(?:none|(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\))(?:\\s*,\\s*(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)))*)$", + "description": "none or comma-separated box shadows: [inset] x y [blur] [spread] color." + }, + "shadowSmall": { + "type": "string", + "pattern": "^(?:none|(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\))(?:\\s*,\\s*(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)))*)$", + "description": "none or comma-separated box shadows: [inset] x y [blur] [spread] color." + }, + "shadowMedium": { + "type": "string", + "pattern": "^(?:none|(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\))(?:\\s*,\\s*(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)))*)$", + "description": "none or comma-separated box shadows: [inset] x y [blur] [spread] color." + }, + "shadowLarge": { + "type": "string", + "pattern": "^(?:none|(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\))(?:\\s*,\\s*(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)))*)$", + "description": "none or comma-separated box shadows: [inset] x y [blur] [spread] color." + }, + "shadowPopover": { + "type": "string", + "pattern": "^(?:none|(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\))(?:\\s*,\\s*(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)))*)$", + "description": "none or comma-separated box shadows: [inset] x y [blur] [spread] color." + }, + "shadowDialog": { + "type": "string", + "pattern": "^(?:none|(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\))(?:\\s*,\\s*(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)))*)$", + "description": "none or comma-separated box shadows: [inset] x y [blur] [spread] color." + }, + "shadowSpotlight": { + "type": "string", + "pattern": "^(?:none|(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\))(?:\\s*,\\s*(?:inset\\s+)?(?:0|-?\\d+(?:\\.\\d+)?px)(?:\\s+(?:0|-?\\d+(?:\\.\\d+)?px)){1,3}\\s+(?:#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)))*)$", + "description": "none or comma-separated box shadows: [inset] x y [blur] [spread] color." + } + } + } + } +}