Onyx: a tactile stone-grey theme with broad subtle blur (#10338)

* Onyx: add the finished stone-grey theme and document its blur range

* Onyx: distinguish grey construction lines from gold dimensions
This commit is contained in:
Nathan Dane authored and GitHub committed 2026-11-04 16:35:28 +00:00
1 parent 40b1b70dec
commit e3f99de8af
9 files changed
+1342 -57

No files matched your search

+667
View File
@@ -0,0 +1,667 @@
{
"order": 260,
"provenance": "Original SolidCraft stone-grey palette and semantic icon-role mapping",
"format": "solidcraft-theme/1",
"name": "Onyx",
"base": "dark",
"colors": {
"accent": "#9eafbd",
"accent_soft": "#3b4852",
"agent_busy": "#9eafbd",
"agent_ok": "#a5b9a5",
"app_bar": "#1c1e20",
"app_bar_hover": "#dededc16",
"app_bar_hover_button": "#dededc1c",
"app_bar_hover_strong": "#dededc28",
"app_bar_hover_subtle": "#dededc0c",
"app_bar_separator": "#383c3f",
"app_bar_tab_active": "#303235",
"app_bar_text": "#dededc",
"axis_x": "#d7938f",
"axis_y": "#a5b9a5",
"axis_z": "#98b2c8",
"bend_down": "#b8a7c3",
"bend_up": "#a5b9a5",
"body": "#93989c",
"body_edge": "#60666b",
"body_selected": "#3b4852",
"border": "#3d4145",
"box_window": "#9eafbd",
"chip": "#758b9b",
"construction_geom": "#b9a78e",
"construction_plane": "#b9a78e24",
"continuity": [
"#a5b9a5",
"#98b2c8",
"#c7b28b",
"#b8a7c3"
],
"control_border": "#4a4f54",
"control_fill": "#303235",
"cube_border": "#50565b",
"cube_face": "#34373a",
"cube_hover": "#53616c",
"cube_text": "#dededc",
"dialog_bg": "#2b2d2f",
"dialog_body": "#2b2d2f",
"dialog_border": "#3d4145",
"dialog_footer": "#27292b",
"dialog_label": "#dededc",
"dialog_rule": "#383c3f",
"dimension": "#c7b28b",
"dimension_driven": "#a59474",
"error": "#d7938f",
"field": "#1c1e20",
"form_accent": "#9d8faa",
"form_dim": "#c6c8c9",
"form_error": "#d7938f",
"form_error_fill": "#493334",
"form_fill": "#b9abc5",
"form_ink": "#dededc",
"form_primary": "#9eafbd",
"form_primary_fill": "#9eafbd",
"form_soft": "#343039",
"form_success": "#a5b9a5",
"form_success_fill": "#344139",
"form_tab_ink": "#b9abc5",
"glyph_bg": "#34373a",
"glyph_edge": "#d7938f",
"glyph_text": "#d7938f",
"grid": "#252729",
"grid_major": "#2e3235",
"hover": "#3b4852",
"hover_edge": "#b1c1ce",
"hover_edge_halo": "#181a1c",
"hover_profile": "#9eafbd30",
"hover_profile_edge": "#b1c1ce",
"icon": "#dededc",
"icons.agent": "#b8a7c3",
"icons.highlight": "#dededc",
"icons.marker_halo": "#1c1e20cc",
"interference": "#d7938f",
"joint_axis": "#98b2c8",
"joint_manipulator": "#9eafbd",
"logo": [
"#c9d4dc",
"#9eafbd",
"#647986"
],
"manipulator": "#9eafbd",
"mesh_accent": "#a29772",
"mesh_fill": "#beb48d",
"mesh_ink": "#beb48d",
"mesh_soft": "#34332b",
"nav_dim": "#b5b7b8",
"nav_field": "#1c1e20",
"nav_hover": "#43494f",
"nav_press": "#59636c",
"nav_tip": "#2b2d2f",
"nav_tip_ink": "#dededc",
"nav_tip_key_border": "#3b3e41",
"nav_viewport": "#27292b",
"on_accent": "#17191b",
"origin_plane": "#b9a78e2c",
"origin_plane_edge": "#b9a78e94",
"origin_plane_hover": "#9eafbd60",
"origin_point": "#b5b7b8",
"overlay": "#2b2d2f",
"panel": "#2b2d2f",
"panel_header": "#303235",
"preview_add": "#9eafbd",
"preview_cut": "#d7938f68",
"preview_new": "#c7b28b",
"preview_sketch": "#a5b9a5",
"profile_fill": "#9eafbd28",
"render_accent": "#a88e88",
"render_fill": "#c1a49e",
"render_ink": "#c1a49e",
"render_soft": "#38302f",
"rubber_band": "#9eafbd",
"section_cap": "#c7b28b",
"sel_edge": "#b1c1ce",
"sel_edge_rim": "#d0d9df",
"sel_face": "#3b4852",
"sel_profile": "#3b4852",
"sel_profile_edge": "#b1c1ce",
"sel_vertex": "#9eafbd",
"selected_bg": "#3b4852",
"selected_ink": "#dededc",
"sheet_fill": "#9eb9bd",
"sheet_ink": "#9eb9bd",
"sheet_soft": "#29363a",
"sketch": "#9eafbd",
"sketch_accent": "#b2b995",
"sketch_centerline": "#b5b7b8",
"sketch_conflict": "#d7938f",
"sketch_construction": "#b5b7b8",
"sketch_fixed": "#dededc",
"sketch_halo": "#181a1c",
"sketch_ink": "#b2b995",
"sketch_locked": "#a5b9a5",
"sketch_lost": "#d7938f",
"sketch_point": "#dededc",
"sketch_point_free": "#9eafbd",
"sketch_projected": "#b8a7c3",
"sketch_soft": "#32352c",
"surface_accent": "#a6947d",
"surface_fill": "#c0ae96",
"surface_ink": "#c0ae96",
"surface_soft": "#35322e",
"tab_on_fill_dark": "#17191b",
"text": "#dededc",
"text_dim": "#c6c8c9",
"timeline": "#2b2d2f",
"timeline_item": "#303235",
"timeline_item_error": "#493334",
"timeline_marker": "#b5b7b8",
"toolbar": "#2b2d2f",
"viewport_bottom": "#1b1d1f",
"viewport_top": "#222426",
"warning": "#c7b28b"
},
"radii": {
"radius_sm": 3,
"radius_md": 6,
"radius_lg": 8,
"radius_xl": 10,
"control_radius": 4,
"sketch_mode_radius": 6
},
"icon_palette": {
"ink": "#1b1d1f",
"line": "#d7d9d8",
"passive-1": "#b9bdc0",
"passive-2": "#8d9398",
"passive-3": "#646c72",
"guide": "#a2a8ac",
"solid": [
"#c9d4dc",
"#b2c1cc",
"#9eafbd"
],
"surface": [
"#d6cab8",
"#c0ae96",
"#a6947d"
],
"mesh": [
"#d4ccad",
"#beb48d",
"#a29772"
],
"form": [
"#d0c5d9",
"#b9abc5",
"#9d8faa"
],
"sketch": [
"#c9cfb3",
"#b2b995",
"#979f78"
],
"constraint": [
"#e1b7b2",
"#d7938f",
"#b77875"
],
"construction": [
"#d2c6b4",
"#b9a78e",
"#9f8c73"
]
},
"options": [
{
"id": "opacity",
"type": "range",
"default": 80,
"min": 60,
"max": 100
},
{
"id": "blur",
"type": "range",
"default": 60,
"max": 60
},
{
"id": "tab_colours",
"type": "bool",
"default": false
},
{
"id": "tab_fade_ms",
"type": "range",
"default": 240
},
{
"id": "accent",
"type": "choice",
"default": "slate",
"label": {
"en": "Accent",
"de": "Akzent",
"ja": "アクセント",
"ko": "강조색",
"zh": "强调色"
},
"help": {
"en": "Selection, highlights and Model controls; workspace and status colours keep their meaning.",
"de": "Auswahl, Hervorhebungen und Modell-Steuerung; Arbeitsbereichs- und Statusfarben behalten ihre Bedeutung.",
"ja": "選択・強調・モデルの操作色。ワークスペースや状態の色の意味は変えません。",
"ko": "선택, 강조 및 모델 조작의 색상입니다. 작업 공간과 상태 색상의 의미는 유지됩니다.",
"zh": "更改选择、高亮与模型控件的色调;工作空间和状态色保持原有含义。"
},
"values": [
{
"id": "slate",
"label": {
"en": "Slate blue",
"de": "Schieferblau",
"ja": "石板の青",
"ko": "슬레이트 블루",
"zh": "岩蓝"
}
},
{
"id": "silver",
"label": {
"en": "Silver",
"de": "Silber",
"ja": "シルバー",
"ko": "은색",
"zh": "银灰"
}
},
{
"id": "sage",
"label": {
"en": "Sage",
"de": "Salbei",
"ja": "セージ",
"ko": "세이지",
"zh": "鼠尾草绿"
}
}
]
},
{
"id": "contrast",
"type": "choice",
"default": "soft",
"label": {
"en": "Contrast",
"de": "Kontrast",
"ja": "コントラスト",
"ko": "대비",
"zh": "对比度"
},
"help": {
"en": "Soft stone layers or stronger separation between panels and fields.",
"de": "Weiche Steinschichten oder stärkere Trennung von Flächen und Feldern.",
"ja": "柔らかな石の階調か、パネルと入力欄を明確に分ける階調を選びます。",
"ko": "부드러운 석재 톤 또는 패널과 입력란을 더 뚜렷하게 구분합니다.",
"zh": "选择柔和的石色层次,或增强面板与输入框的区分。"
},
"values": [
{
"id": "soft",
"label": {
"en": "Soft",
"de": "Weich",
"ja": "ソフト",
"ko": "부드럽게",
"zh": "柔和"
}
},
{
"id": "defined",
"label": {
"en": "Defined",
"de": "Deutlich",
"ja": "くっきり",
"ko": "뚜렷하게",
"zh": "分明"
}
}
]
},
{
"id": "brightness",
"type": "range",
"effect": "surface_brightness",
"default": 100,
"min": 60,
"max": 120,
"step": 1,
"unit": "%",
"label": {
"en": "Brightness",
"de": "Helligkeit",
"ja": "明るさ",
"ko": "밝기",
"zh": "亮度"
},
"help": {
"en": "Adjust stone surfaces, viewport backgrounds and grid brightness. 100% keeps the authored tones; text and semantic colours stay unchanged.",
"de": "Helligkeit von Steinflächen, Ansichtsflächen und Raster. 100% erhält die ursprünglichen Töne; Text und Bedeutungsfarben bleiben unverändert.",
"ja": "石調の面・ビューポート背景・グリッドの明るさを調整します。100%は元の階調です。文字と意味のある色は変えません。",
"ko": "석재 표면·뷰포트 배경·격자의 밝기를 조절합니다. 100%는 원래 톤을 유지하며 글자와 의미 색상은 바꾸지 않습니다.",
"zh": "调整石色表面、视口背景和网格的亮度。100%保留原有色调;文字和语义颜色保持不变。"
}
},
{
"id": "foreground_colours",
"type": "choice",
"default": "vibrant",
"label": {
"en": "Foreground colours",
"de": "Vordergrundfarben",
"ja": "前景の色",
"ko": "전경 색상",
"zh": "前景颜色"
},
"help": {
"en": "Vibrant strengthens axes, icons and guides. Muted keeps the original quiet palette. Accent controls selection.",
"de": "Leuchtend verstärkt Achsen, Symbole und Hilfen. Gedämpft behält die ruhige ursprüngliche Palette. Akzent steuert die Auswahl.",
"ja": "鮮やかは軸・アイコン・作図ガイドを強調します。落ち着いた配色は元の控えめな色を保ちます。選択の色はアクセントで設定します。",
"ko": "선명함은 축·아이콘·가이드를 강조합니다. 차분함은 기존의 은은한 팔레트를 유지합니다. 선택 색상은 강조색 설정을 따릅니다.",
"zh": "鲜艳增强轴、图标和辅助线的色彩。柔和保留原有的沉静配色。选择颜色仍由强调色控制。"
},
"values": [
{
"id": "muted",
"label": {
"en": "Muted",
"de": "Gedämpft",
"ja": "落ち着いた",
"ko": "차분함",
"zh": "柔和"
}
},
{
"id": "vibrant",
"label": {
"en": "Vibrant",
"de": "Leuchtend",
"ja": "鮮やか",
"ko": "선명함",
"zh": "鲜艳"
}
}
]
}
],
"overlays": [
{
"when": {
"opt": "contrast",
"eq": "defined"
},
"colors": {
"app_bar": "#151719",
"panel": "#25282a",
"dialog_bg": "#25282a",
"dialog_body": "#25282a",
"dialog_footer": "#202325",
"panel_header": "#373b3e",
"field": "#171a1c",
"control_fill": "#383d41",
"border": "#4e555b",
"dialog_border": "#4e555b",
"dialog_rule": "#444b51",
"control_border": "#626c73",
"text": "#eeeeec",
"text_dim": "#ced1d2",
"form_ink": "#eeeeec",
"form_dim": "#ced1d2",
"dialog_label": "#eeeeec",
"toolbar": "#25282a",
"timeline": "#25282a"
}
},
{
"when": {
"opt": "accent",
"eq": "silver"
},
"colors": {
"accent": "#b7bdc2",
"manipulator": "#b7bdc2",
"joint_manipulator": "#b7bdc2",
"preview_add": "#b7bdc2",
"rubber_band": "#b7bdc2",
"box_window": "#b7bdc2",
"form_primary": "#b7bdc2",
"form_primary_fill": "#b7bdc2",
"sketch": "#b7bdc2",
"sketch_point_free": "#b7bdc2",
"sel_vertex": "#b7bdc2",
"accent_soft": "#454b50",
"hover": "#454b50",
"selected_bg": "#454b50",
"sel_face": "#454b50",
"sel_profile": "#454b50",
"body_selected": "#454b50",
"sel_edge": "#d5dadf",
"sel_profile_edge": "#d5dadf",
"hover_edge": "#d5dadf",
"hover_profile_edge": "#d5dadf",
"sel_edge_rim": "#dde0e3",
"hover_profile": "#b7bdc230",
"origin_plane_hover": "#b7bdc260",
"profile_fill": "#b7bdc228",
"cube_hover": "#454b50",
"chip": "#b7bdc2",
"logo": [
"#dde0e3",
"#c9cfd4",
"#b7bdc2"
]
},
"icon_palette": {
"solid": [
"#dde0e3",
"#c9cfd4",
"#b7bdc2"
]
}
},
{
"when": {
"opt": "accent",
"eq": "sage"
},
"colors": {
"accent": "#aabcae",
"manipulator": "#aabcae",
"joint_manipulator": "#aabcae",
"preview_add": "#aabcae",
"rubber_band": "#aabcae",
"box_window": "#aabcae",
"form_primary": "#aabcae",
"form_primary_fill": "#aabcae",
"sketch": "#aabcae",
"sketch_point_free": "#aabcae",
"sel_vertex": "#aabcae",
"accent_soft": "#414d45",
"hover": "#414d45",
"selected_bg": "#414d45",
"sel_face": "#414d45",
"sel_profile": "#414d45",
"body_selected": "#414d45",
"sel_edge": "#c2d2c5",
"sel_profile_edge": "#c2d2c5",
"hover_edge": "#c2d2c5",
"hover_profile_edge": "#c2d2c5",
"sel_edge_rim": "#d1ddd4",
"hover_profile": "#aabcae30",
"origin_plane_hover": "#aabcae60",
"profile_fill": "#aabcae28",
"cube_hover": "#414d45",
"chip": "#aabcae",
"logo": [
"#d1ddd4",
"#becec2",
"#aabcae"
]
},
"icon_palette": {
"solid": [
"#d1ddd4",
"#becec2",
"#aabcae"
]
}
},
{
"when": {
"opt": "foreground_colours",
"eq": "vibrant"
},
"colors": {
"axis_x": "#ff6d79",
"axis_y": "#4fd681",
"axis_z": "#4da7ff",
"agent_busy": "#4da7ff",
"agent_ok": "#4fd681",
"warning": "#ffd166",
"error": "#ff6d79",
"form_error": "#ff6d79",
"form_success": "#4fd681",
"glyph_edge": "#ff6d79",
"glyph_text": "#ff6d79",
"interference": "#ff6d79",
"sketch_conflict": "#ff6d79",
"sketch_lost": "#ff6d79",
"sketch_locked": "#4fd681",
"sketch_projected": "#c78bff",
"dimension": "#ffd166",
"dimension_driven": "#ffb166",
"construction_geom": "#ffb166",
"sketch_centerline": "#b5b7b8",
"sketch_construction": "#b5b7b8",
"joint_axis": "#42d9dd",
"bend_down": "#c78bff",
"bend_up": "#4fd681",
"preview_cut": "#ff6d7968",
"preview_new": "#ffd166",
"preview_sketch": "#4fd681",
"section_cap": "#ffd166",
"sketch_accent": "#4fd681",
"sketch_ink": "#4fd681",
"surface_accent": "#ffb166",
"surface_fill": "#ffb166",
"surface_ink": "#ffb166",
"mesh_accent": "#ffd166",
"mesh_fill": "#ffd166",
"mesh_ink": "#ffd166",
"form_accent": "#c78bff",
"form_fill": "#c78bff",
"form_tab_ink": "#c78bff",
"sheet_fill": "#63cbd5",
"sheet_ink": "#63cbd5",
"render_accent": "#ff6d79",
"render_fill": "#ff6d79",
"render_ink": "#ff6d79",
"icons.agent": "#c78bff",
"continuity": [
"#4fd681",
"#4da7ff",
"#ffd166",
"#c78bff"
],
"origin_plane_edge": "#ffb16694",
"sheet_soft": "#283a3e"
},
"icon_palette": {
"ink": "#1b1d1f",
"line": "#d7d9d8",
"passive-1": "#d3d8dc",
"passive-2": "#b2bbc2",
"passive-3": "#929fa9",
"guide": "#b4c8d6",
"solid": [
"#82c1ff",
"#62b2ff",
"#4da7ff"
],
"surface": [
"#ffc894",
"#ffba78",
"#ffb166"
],
"mesh": [
"#ffdf94",
"#ffd778",
"#ffd166"
],
"form": [
"#d8aeff",
"#ce99ff",
"#c78bff"
],
"sketch": [
"#84e2a7",
"#64db90",
"#4fd681"
],
"constraint": [
"#ff99a1",
"#ff7f89",
"#ff6d79"
],
"construction": [
"#ffc894",
"#ffba78",
"#ffb166"
]
}
}
],
"tabs": {
"MODEL": {
"viewport_top": "#24292d",
"viewport_bottom": "#1b2023",
"panel": "#303438"
},
"SURFACE": {
"viewport_top": "#292622",
"viewport_bottom": "#211e1b",
"panel": "#36332f"
},
"MESH": {
"viewport_top": "#282820",
"viewport_bottom": "#202019",
"panel": "#35352e"
},
"FORM": {
"viewport_top": "#26232b",
"viewport_bottom": "#1e1b22",
"panel": "#343137"
},
"SHEET METAL": {
"viewport_top": "#22282a",
"viewport_bottom": "#1b2022",
"panel": "#303638"
},
"RENDER": {
"viewport_top": "#2b2423",
"viewport_bottom": "#221c1b",
"panel": "#373130"
},
"SKETCH": {
"viewport_top": "#252921",
"viewport_bottom": "#1d211a",
"panel": "#33362f"
}
},
"borders": {
"primary_inset": {
"width": 0.5,
"gap": 1,
"color": "#dce2e70e"
}
},
"requires": 2
}
+1
View File
@@ -0,0 +1 @@
| assets/styles/ui/themes/onyx.json | SolidCraft contributors | original Onyx stone-grey palette, option overlays and semantic icon-role mapping; existing SolidCraft artwork is unchanged | MIT OR Apache-2.0 |
+21
View File
@@ -1037,6 +1037,27 @@ fn shipped_themes_draw_dimensions_in_their_own_readable_colour() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn onyx_keeps_gold_dimensions_distinct_from_grey_construction_lines() {
use crate::theme_files::{OptionValue, builtin, resolve_with};
let onyx = builtin().iter().find(|theme| theme.id == "onyx").unwrap();
for (foreground, gold, driven) in [("muted", [199, 178, 139], [165, 148, 116]), ("vibrant", [255, 209, 102], [255, 177, 102])] {
let values = std::collections::BTreeMap::from([("foreground_colours".into(), OptionValue::Choice(foreground.into()))]);
let t = resolve_with(onyx, &values);
assert_eq!(rgb(t.dimension), gold, "{foreground}: retain authored dimensions");
assert_eq!(rgb(t.dimension_driven), driven, "{foreground}: retain authored driven dimensions");
for construction in [t.sketch_construction, t.sketch_centerline] {
assert_eq!(rgb(construction), [181, 183, 184], "{foreground}: construction stays grey");
for dimension in [t.dimension, t.dimension_driven] {
assert!(normal(rgb(dimension), rgb(construction)) >= FLOOR, "{foreground}: dimensions remain distinct");
}
for ground in [t.viewport_top, t.viewport_bottom] {
assert!(crate::theme::contrast_ratio(construction, ground) >= 3.0, "{foreground}: readable construction lines");
}
}
}
}
#[test]
fn the_shader_gets_the_modes_colours_and_its_stripes() {
for dark in [false, true] {
+2 -2
View File
@@ -7376,7 +7376,7 @@ mod tests {
assert!(crate::scenario::rect_of("prefs:theme_notice:blur").is_some(), "changed raw value must reappear");
h.call(
"ui.prefs",
json!({"theme_import":{"format":"solidcraft-theme/1","name":"Future blur","base":"dark","requires":2,
json!({"theme_import":{"format":"solidcraft-theme/1","name":"Future blur","base":"dark","requires":crate::theme_files::REQUIREMENTS_LEVEL + 1,
"options":[{"id":"blur","type":"range","default":18}]}}),
);
h.frames(2);
@@ -7386,7 +7386,7 @@ mod tests {
h.frames(2);
assert!(crate::scenario::rect_of("prefs:theme_notice:newer").is_none());
assert!(active_theme_file(&h.app).unwrap().options.is_empty(), "unsupported tables were discarded, never auto-restored");
h.app.preferences.custom_themes.iter_mut().find(|f| f.id == "future-blur").unwrap().requires = 3;
h.app.preferences.custom_themes.iter_mut().find(|f| f.id == "future-blur").unwrap().requires = crate::theme_files::REQUIREMENTS_LEVEL + 2;
h.frames(2);
assert!(crate::scenario::rect_of("prefs:theme_notice:newer").is_some());
for identity in 0..80 {
+82 -3
View File
@@ -1368,13 +1368,92 @@ impl Tokens {
}
let scale = percent.clamp(40.0, 100.0) / 100.0;
let body_inks = [self.text, self.form_ink, self.dialog_label];
for fill in [&mut self.panel, &mut self.panel_header, &mut self.dialog_bg, &mut self.dialog_body, &mut self.dialog_footer, &mut self.overlay]
{
*fill = readable_fill(*fill, &body_inks, scale);
let all_inks = [self.text, self.form_ink, self.dialog_label, self.text_dim, self.form_dim];
for fill in [
&mut self.panel,
&mut self.panel_header,
&mut self.toolbar,
&mut self.timeline,
&mut self.dialog_bg,
&mut self.dialog_body,
&mut self.dialog_footer,
&mut self.overlay,
] {
let protected = readable_fill(*fill, &all_inks, scale);
// Some authored dim inks cannot meet the target without an opaque fill.
// Keep the existing primary-text floor in that case so opacity still works.
*fill = if protected.a() < 255 { protected } else { readable_fill(*fill, &body_inks, scale) };
}
self.nav_tip = readable_fill(self.nav_tip, &[self.nav_tip_ink], scale);
}
/// Scale neutral backing colours before opacity protection; semantic ink stays authored.
pub(crate) fn apply_surface_brightness(&mut self, percent: f32) {
if !percent.is_finite() || percent == 100.0 {
return;
}
let scale = percent.clamp(60.0, 120.0) / 100.0;
let adjust = |color: &mut Color32| {
let [r, g, b, a] = color.to_srgba_unmultiplied();
if a > 0 {
let channel = |v: u8| (f32::from(v) * scale).round().clamp(0.0, 255.0) as u8;
*color = Color32::from_rgba_unmultiplied(channel(r), channel(g), channel(b), a);
}
};
for color in [
&mut self.panel,
&mut self.panel_header,
&mut self.toolbar,
&mut self.timeline,
&mut self.timeline_item,
&mut self.dialog_bg,
&mut self.dialog_body,
&mut self.dialog_footer,
&mut self.overlay,
&mut self.field,
&mut self.control_fill,
&mut self.app_bar,
&mut self.app_bar_tab_active,
&mut self.nav_tip,
&mut self.nav_field,
&mut self.nav_viewport,
&mut self.glyph_bg,
&mut self.cube_face,
&mut self.viewport_top,
&mut self.viewport_bottom,
&mut self.grid,
&mut self.grid_major,
&mut self.border,
&mut self.dialog_border,
&mut self.dialog_rule,
&mut self.control_border,
&mut self.app_bar_separator,
&mut self.nav_tip_key_border,
&mut self.cube_border,
&mut self.file_background,
&mut self.file_surface,
&mut self.file_border,
&mut self.file_recovery_well,
] {
adjust(color);
}
for stroke in [
&mut self.borders.primary,
&mut self.borders.secondary,
&mut self.borders.tertiary,
&mut self.borders.container,
&mut self.borders.control,
]
.into_iter()
.flatten()
{
adjust(&mut stroke.color);
}
if let Some(inset) = &mut self.borders.primary_inset {
adjust(&mut inset.stroke.color);
}
}
/// These tokens with the 3D view's background one flat colour (#7688), or as they are for `None`.
pub fn with_background(mut self, colour: Option<[u8; 3]>) -> Tokens {
if let Some([r, g, b]) = colour {
+539 -45
View File
@@ -95,22 +95,22 @@ pub struct ThemeFile {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub overlays: Vec<Overlay>,
/// Levels newer than [`REQUIREMENTS_LEVEL`] disable options and overlays.
#[serde(default = "requirements_level", skip_serializing_if = "is_current_level")]
#[serde(default = "default_requirements_level", skip_serializing_if = "is_default_requirements_level")]
pub requires: u64,
}
/// Theme option/overlay format level understood by this app.
pub const REQUIREMENTS_LEVEL: u64 = 1;
pub const REQUIREMENTS_LEVEL: u64 = 2;
pub const MAX_OPTIONS: usize = 16;
pub const MAX_OVERLAYS: usize = 32;
const STANDARD_OPTIONS: [&str; 4] = ["opacity", "blur", "tab_colours", "tab_fade_ms"];
fn requirements_level() -> u64 {
REQUIREMENTS_LEVEL
fn default_requirements_level() -> u64 {
1
}
fn is_current_level(level: &u64) -> bool {
*level == REQUIREMENTS_LEVEL
fn is_default_requirements_level(level: &u64) -> bool {
*level == 1
}
impl Default for ThemeFile {
@@ -126,7 +126,7 @@ impl Default for ThemeFile {
tabs: BTreeMap::new(),
options: Vec::new(),
overlays: Vec::new(),
requires: REQUIREMENTS_LEVEL,
requires: default_requirements_level(),
}
}
}
@@ -169,9 +169,18 @@ pub enum OptionKind {
},
}
/// Explicit theme-local continuous effects; custom option ids and ordering remain authored.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OptionEffect {
SurfaceBrightness,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OptionDef {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect: Option<OptionEffect>,
#[serde(flatten)]
pub kind: OptionKind,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
@@ -278,6 +287,9 @@ impl ThemeFile {
/// Base tables are deliberately left alone; only completely valid declarations replace these.
pub(crate) fn set_option_tables(&mut self, source: &Value) -> Result<(), String> {
let options = if self.needs_newer() { Vec::new() } else { parse_options(source.get("options"))? };
if self.requires < 2 && options.iter().any(|option| option.effect.is_some()) {
return Err("option effects require `requires` of at least 2".into());
}
let overlays = if self.needs_newer() { Vec::new() } else { parse_overlays(source.get("overlays"), &options)? };
self.options = options;
self.overlays = overlays;
@@ -573,12 +585,15 @@ pub fn from_value(v: &Value) -> Result<ThemeFile, String> {
_ => return Err("`base` must be \"dark\" or \"light\"".into()),
};
let requires = match v.get("requires") {
None => REQUIREMENTS_LEVEL,
None => default_requirements_level(),
Some(value) => value.as_u64().ok_or("`requires` must be a nonnegative integer")?,
};
let layer = parse_layer(v)?;
let tabs = parse_tabs(v.get("tabs"))?;
let options = if requires > REQUIREMENTS_LEVEL { Vec::new() } else { parse_options(v.get("options"))? };
if requires < 2 && options.iter().any(|option| option.effect.is_some()) {
return Err("option effects require `requires` of at least 2".into());
}
let overlays = if requires > REQUIREMENTS_LEVEL { Vec::new() } else { parse_overlays(v.get("overlays"), &options)? };
Ok(ThemeFile {
id: slug(name),
@@ -728,6 +743,11 @@ fn parse_option(v: &Value) -> Result<OptionDef, String> {
let id = v.get("id").and_then(Value::as_str).filter(|id| valid_option_id(id)).ok_or("`id` must match [a-z][a-z0-9_]{0,31}")?;
let kind = v.get("type").and_then(Value::as_str).ok_or("`type` must be bool, choice or range")?;
let reserved = STANDARD_OPTIONS.contains(&id);
let effect = match v.get("effect") {
None => None,
Some(Value::String(effect)) if effect == "surface_brightness" && !reserved => Some(OptionEffect::SurfaceBrightness),
Some(_) => return Err("`effect` must be surface_brightness on a custom range option".into()),
};
let kind = if id == "tab_colours" || (!reserved && kind == "bool") {
if kind != "bool" {
return Err(format!("`{id}.type` must be bool"));
@@ -796,9 +816,16 @@ fn parse_option(v: &Value) -> Result<OptionDef, String> {
return Err("`type` must be bool, choice or range".into());
};
// Standard definitions are app-owned: even malformed presentation fields are ignored.
if effect.is_some()
&& !matches!(&kind, OptionKind::Range { default, min, max, step, unit }
if *default == 100.0 && *min >= 60.0 && *max <= 120.0 && *min <= 100.0 && *max >= 100.0 && *step == 1.0 && unit.as_deref() == Some("%"))
{
return Err("surface_brightness requires a percent range inside 60 to 120, default 100 and step 1".into());
}
if reserved {
return Ok(OptionDef {
id: id.into(),
effect,
kind,
label: BTreeMap::new(),
help: BTreeMap::new(),
@@ -818,6 +845,7 @@ fn parse_option(v: &Value) -> Result<OptionDef, String> {
.transpose()?;
Ok(OptionDef {
id: id.into(),
effect,
kind,
label: option_text(v.get("label"), "label", 40)?,
help: option_text(v.get("help"), "help", 160)?,
@@ -837,6 +865,9 @@ fn parse_options(v: Option<&Value>) -> Result<Vec<OptionDef>, String> {
if !ids.insert(option.id.clone()) {
return Err(format!("`options.id` duplicates {}", option.id));
}
if option.effect.is_some() && options.iter().any(|previous: &OptionDef| previous.effect == option.effect) {
return Err("`options.effect` may declare surface_brightness only once".into());
}
options.push(option);
}
let mut parents = std::collections::BTreeSet::new();
@@ -931,7 +962,8 @@ fn usable_options(theme: &ThemeFile) -> Vec<OptionDef> {
if theme.needs_newer() || theme.options.len() > MAX_OPTIONS {
return Vec::new();
}
serde_json::to_value(&theme.options).ok().and_then(|v| parse_options(Some(&v)).ok()).unwrap_or_default()
let options = serde_json::to_value(&theme.options).ok().and_then(|v| parse_options(Some(&v)).ok()).unwrap_or_default();
if theme.requires < 2 && options.iter().any(|option| option.effect.is_some()) { Vec::new() } else { options }
}
impl OptionDef {
@@ -1091,6 +1123,14 @@ pub(crate) fn resolve_for_tab(theme: &ThemeFile, saved: &BTreeMap<String, Option
}
tab_panel = surfaces.contains_key("panel");
}
for option in &options {
if option.effect == Some(OptionEffect::SurfaceBrightness)
&& option_active(&option.id, &options, &values)
&& let Some(OptionValue::Num(percent)) = values.get(&option.id)
{
tokens.apply_surface_brightness(*percent);
}
}
crate::theme_options::apply(theme, &values, &mut tokens);
if tab_panel {
tokens.protect_tab_panel();
@@ -1269,7 +1309,7 @@ pub fn export_file(theme: &ThemeFile) -> Value {
if !theme.overlays.is_empty() {
obj.insert("overlays".into(), json!(theme.overlays));
}
if !is_current_level(&theme.requires) {
if !is_default_requirements_level(&theme.requires) {
obj.insert("requires".into(), json!(theme.requires));
}
}
@@ -1559,8 +1599,19 @@ mod tests {
assert_eq!(export_file(&file)["options"][1]["default"], 240.0);
}
for file in builtin() {
assert!(file.tabs.is_empty(), "{}", file.id);
assert_eq!(resolve_for_tab(file, &BTreeMap::new(), Some("SKETCH")), resolve(file));
let off = BTreeMap::from([("tab_colours".into(), OptionValue::Bool(false))]);
let mut without_tabs = file.clone();
without_tabs.tabs.clear();
for tab in crate::theme::TAB_NAMES {
assert_eq!(resolve_for_tab(file, &off, Some(tab)), resolve_with(file, &off), "{} {tab}: tabs off", file.id);
assert_eq!(resolve_for_tab(&without_tabs, &BTreeMap::new(), Some(tab)), resolve(file), "{} {tab}: no tables", file.id);
assert_eq!(
resolve_for_tab(file, &BTreeMap::new(), Some(tab)),
resolve_for_tab(&without_tabs, &BTreeMap::new(), Some(tab)),
"{} {tab}: authored defaults leave tab surfaces off",
file.id
);
}
}
}
@@ -1889,7 +1940,7 @@ mod tests {
#[test]
fn newer_requirements_keep_base_tables_without_reading_future_option_syntax() {
let source = json!({"format":FORMAT,"name":"Future","base":"light","requires":2,"colors":{"panel":"#123456"},
let source = json!({"format":FORMAT,"name":"Future","base":"light","requires":REQUIREMENTS_LEVEL + 1,"colors":{"panel":"#123456"},
"radii":{"radius_sm":0},"options":{"future":"syntax"},"overlays":false});
let theme = from_value(&source).unwrap();
assert!(theme.needs_newer());
@@ -1919,6 +1970,8 @@ mod tests {
let mut only_fills = original;
only_fills.panel = faded.panel;
only_fills.panel_header = faded.panel_header;
only_fills.toolbar = faded.toolbar;
only_fills.timeline = faded.timeline;
only_fills.dialog_bg = faded.dialog_bg;
only_fills.dialog_body = faded.dialog_body;
only_fills.dialog_footer = faded.dialog_footer;
@@ -1944,6 +1997,8 @@ mod tests {
for (role, fill) in [
("panel", faded.panel),
("panel_header", faded.panel_header),
("toolbar", faded.toolbar),
("timeline", faded.timeline),
("dialog_bg", faded.dialog_bg),
("dialog_body", faded.dialog_body),
("dialog_footer", faded.dialog_footer),
@@ -1987,9 +2042,11 @@ mod tests {
}
#[test]
fn all_shipped_themes_keep_the_original_resolver_output_and_serialization() {
fn original_shipped_themes_keep_the_original_resolver_output_and_serialization() {
assert_eq!(builtin().len(), BUILTIN.len());
for theme in builtin() {
for id in ORIGINAL_BUILTIN_IDS {
let theme = find(id, &[]).unwrap();
assert!(theme.tabs.is_empty(), "{}", theme.id);
let gruvbox = matches!(theme.id.as_str(), "gruvbox-dark" | "gruvbox-light");
assert!(!theme.needs_newer());
assert_eq!(!theme.options.is_empty(), gruvbox);
@@ -2004,6 +2061,32 @@ mod tests {
}
}
#[test]
fn shipped_theme_defaults_and_definition_roundtrips_preserve_all_tables() {
assert_eq!(builtin().len(), BUILTIN.len());
for theme in builtin() {
assert!(!theme.needs_newer(), "{}", theme.id);
let (defaults, notices) = effective_values(theme, &BTreeMap::new());
assert!(notices.is_empty(), "{}", theme.id);
assert_eq!(defaults.len(), theme.options.len(), "{}: every declaration is valid", theme.id);
assert_eq!(resolve_with(theme, &defaults), resolve(theme), "{}", theme.id);
let stored = serde_json::to_value(theme).unwrap();
assert_eq!(stored.get("requires").is_none(), theme.requires == 1, "{}", theme.id);
for (key, present) in [("options", !theme.options.is_empty()), ("overlays", !theme.overlays.is_empty()), ("tabs", !theme.tabs.is_empty())]
{
assert_eq!(stored.get(key).is_some(), present, "{}: {key}", theme.id);
}
let restored: ThemeFile = serde_json::from_value(stored).unwrap();
assert_eq!(&restored, theme, "{}", theme.id);
let exported = export_file(theme);
let mut imported = parse(&exported.to_string()).unwrap();
// Built-in identity is assigned by the source filename, not by the exported name.
imported.id.clone_from(&theme.id);
assert_eq!(&imported, theme, "{}", theme.id);
assert_eq!(resolve(&imported), resolve(theme), "{}", theme.id);
}
}
#[test]
fn malformed_saved_definitions_and_comparisons_resolve_without_panics() {
let mut theme = from_value(&option_theme(
@@ -2025,7 +2108,7 @@ mod tests {
assert_eq!(resolve(&theme), resolve_base(&theme));
theme = original;
}
theme.requires = 2;
theme.requires = REQUIREMENTS_LEVEL + 1;
assert_eq!(resolve(&theme), resolve_base(&theme));
assert!(effective_values(&theme, &BTreeMap::new()).0.is_empty());
}
@@ -2318,36 +2401,440 @@ mod tests {
}
}
#[test]
fn onyx_defaults_offer_broad_subtle_blur_and_independent_material_choices() {
let file = find("onyx", &[]).unwrap();
assert_eq!(file.base, Base::Dark);
assert!(!file.needs_newer());
assert_eq!(file.options.len(), 8, "seven controls plus the Fade child");
assert_eq!(builtin().last().map(|theme| theme.id.as_str()), Some("onyx"), "existing picker order is retained");
let (values, notices) = effective_values(file, &BTreeMap::new());
assert!(notices.is_empty());
assert_eq!(values["opacity"], OptionValue::Num(80.0));
assert_eq!(values["blur"], OptionValue::Num(60.0));
assert_eq!(values["tab_colours"], OptionValue::Bool(false));
assert_eq!(values["tab_fade_ms"], OptionValue::Num(240.0));
assert_eq!(values["contrast"], OptionValue::Choice("soft".into()));
assert_eq!(values["brightness"], OptionValue::Num(100.0));
assert!(file.options.iter().all(|option| option.id != "edge_finish"));
assert_eq!(export_file(file)["requires"], 2);
assert_eq!(values["accent"], OptionValue::Choice("slate".into()));
assert_eq!(values["foreground_colours"], OptionValue::Choice("vibrant".into()));
assert_eq!(
ordered_options(file).iter().map(|o| o.id.as_str()).collect::<Vec<_>>(),
["opacity", "blur", "tab_colours", "tab_fade_ms", "accent", "contrast", "brightness", "foreground_colours"]
);
let opacity = file.options.iter().find(|o| o.id == "opacity").unwrap();
let OptionKind::Range { min, max, .. } = &opacity.kind else { panic!("opacity must be a range") };
assert_eq!((*min, *max), (60.0, 100.0));
let faded = resolve(file);
let full = resolve_with(file, &BTreeMap::from([("opacity".into(), OptionValue::Num(100.0))]));
assert!(faded.panel.a() < full.panel.a(), "the default exposes a little of the scene");
assert_eq!((faded.toolbar, faded.timeline), (faded.panel, faded.panel), "workspace surfaces share the same material");
assert_eq!(full.panel.a(), 255);
assert_eq!(faded.text, full.text);
assert!(faded.borders.primary_inset.is_some());
assert!(faded.borders.bevel.is_none());
assert_eq!(faded.radius_md, 6);
assert_eq!(faded.viewport_top, resolve_base(file).viewport_top, "tab colours start off");
for def in file.options.iter().filter(|def| !crate::theme_options::standard(&def.id)) {
for language in ["en", "de", "ja", "ko", "zh"] {
assert!(def.label.contains_key(language) && def.help.contains_key(language));
if let OptionKind::Choice { values, .. } = &def.kind {
assert!(values.iter().all(|value| value.label.contains_key(language)));
}
}
}
let roundtrip = from_value(&export_file(file)).unwrap();
assert_eq!(roundtrip.options, file.options);
assert_eq!(roundtrip.overlays, file.overlays);
assert_eq!(roundtrip.tabs, file.tabs);
assert_eq!(resolve(&roundtrip), faded);
}
#[test]
fn onyx_options_compose_and_keep_small_text_readable() {
use crate::theme::{TabAccent, composite, contrast_ratio};
let file = find("onyx", &[]).unwrap();
let ordinary = resolve(file);
for contrast in ["soft", "defined"] {
for accent in ["slate", "silver", "sage"] {
for opacity in [60.0, 80.0, 100.0] {
for tabs in [false, true] {
let values = BTreeMap::from([
("contrast".into(), OptionValue::Choice(contrast.into())),
("accent".into(), OptionValue::Choice(accent.into())),
("opacity".into(), OptionValue::Num(opacity)),
("tab_colours".into(), OptionValue::Bool(tabs)),
]);
let base = resolve_with(file, &values);
assert_eq!((base.toolbar, base.timeline), (base.panel, base.panel));
let expected = match accent {
"silver" => "#b7bdc2",
"sage" => "#aabcae",
_ => "#9eafbd",
};
assert_eq!(base.accent, parse_color(expected).unwrap());
assert!(base.borders.primary_inset.is_some());
assert!(base.borders.bevel.is_none());
assert_eq!(base.error, ordinary.error, "accent and contrast must not recolour status");
assert_eq!(base.warning, ordinary.warning);
assert_eq!(base.agent_busy, ordinary.agent_busy);
assert_eq!(base.agent_ok, ordinary.agent_ok);
assert_eq!((base.axis_x, base.axis_y, base.axis_z), (ordinary.axis_x, ordinary.axis_y, ordinary.axis_z));
assert_eq!(
export_icon_palette(base.icon_palette)["solid"][2],
"#4da7ff",
"foreground glyphs retain the default Vibrant palette"
);
if contrast == "defined" {
assert_ne!(base.field, ordinary.field, "defined contrast separates recessed fields");
}
for tab in ["MODEL", "SURFACE", "MESH", "FORM", "SHEET METAL", "RENDER", "SKETCH"] {
let tokens = resolve_for_tab(file, &values, Some(tab));
assert_eq!(tokens.accent, base.accent, "tab surfaces must not replace the selected accent");
assert_eq!(tokens.borders, base.borders);
assert_eq!(tokens.icon_palette, base.icon_palette);
if tabs {
assert_ne!(tokens.viewport_top, base.viewport_top, "{tab} has a quiet authored tint");
} else {
assert_eq!(tokens, base);
}
let workspace = TabAccent::for_tab(&tokens, tab);
assert!(contrast_ratio(workspace.ink, workspace.soft) >= 4.5);
assert!(contrast_ratio(workspace.on_fill(&tokens), workspace.color) >= 4.5);
for fill in [
tokens.panel,
tokens.panel_header,
tokens.toolbar,
tokens.timeline,
tokens.dialog_bg,
tokens.dialog_body,
tokens.dialog_footer,
tokens.overlay,
] {
for background in [Color32::BLACK, Color32::WHITE, tokens.viewport_top, tokens.viewport_bottom] {
let ground = composite(fill, background);
for ink in [tokens.text, tokens.text_dim, tokens.form_ink, tokens.form_dim, tokens.dialog_label] {
assert!(
contrast_ratio(composite(ink, ground), ground) >= 4.5,
"{contrast}/{accent}/{opacity}/{tabs}/{tab}: {ink:?} on {fill:?} over {background:?}"
);
}
}
}
assert!(contrast_ratio(tokens.text_dim, tokens.field) >= 4.5);
assert!(contrast_ratio(tokens.text, tokens.control_fill) >= 4.5);
}
}
}
}
}
}
#[test]
fn surface_brightness_is_explicit_continuous_and_precedes_opacity_protection() {
use crate::theme::{composite, contrast_ratio};
let file = find("onyx", &[]).unwrap();
let source = export_file(file);
let index = file.options.iter().position(|option| option.id == "brightness").unwrap();
for (key, value) in [
("effect", json!("unknown")),
("effect", json!(true)),
("type", json!("bool")),
("min", json!(59)),
("max", json!(121)),
("default", json!(90)),
("step", json!(2)),
("unit", json!("px")),
] {
let mut invalid = source.clone();
invalid["options"][index][key] = value;
assert!(from_value(&invalid).is_err(), "invalid brightness {key}");
}
let mut legacy = source.clone();
legacy["requires"] = json!(1);
assert!(from_value(&legacy).unwrap_err().contains("requires"));
assert_eq!(export_file(&from_value(&source).unwrap()), source);
let mut plain = file.clone();
plain.options.iter_mut().for_each(|option| option.effect = None);
assert_eq!(resolve(file), resolve(&plain), "100% preserves the exact authored palette");
let values = |brightness, opacity| {
BTreeMap::from([
("brightness".into(), OptionValue::Num(brightness)),
("opacity".into(), OptionValue::Num(opacity)),
("tab_colours".into(), OptionValue::Bool(true)),
])
};
let dark = resolve_for_tab(file, &values(60.0, 100.0), Some("SHEET METAL"));
let middle = resolve_for_tab(file, &values(100.0, 100.0), Some("SHEET METAL"));
let bright = resolve_for_tab(file, &values(120.0, 100.0), Some("SHEET METAL"));
assert!(dark.viewport_top.r() < middle.viewport_top.r() && middle.viewport_top.r() < bright.viewport_top.r());
assert!(dark.panel.r() < middle.panel.r() && middle.panel.r() < bright.panel.r());
assert_eq!((dark.panel.a(), bright.panel.a()), (middle.panel.a(), middle.panel.a()));
assert_eq!((dark.text, dark.axis_x, dark.selected_bg), (bright.text, bright.axis_x, bright.selected_bg));
for brightness in [60.0, 120.0] {
let translucent = resolve_for_tab(file, &values(brightness, 60.0), Some("SHEET METAL"));
for background in [Color32::BLACK, Color32::WHITE] {
assert!(contrast_ratio(translucent.text, composite(translucent.panel, background)) >= 4.5);
}
}
}
fn brightness_fixture() -> Value {
json!({"format":FORMAT,"name":"Brightness","base":"dark","requires":2,
"colors":{"panel":"#505050","viewport_top":"#646464","axis_x":"#12345678"},
"options":[{"id":"exposure","type":"range","effect":"surface_brightness",
"default":100,"min":60,"max":120,"step":1,"unit":"%"}]})
}
#[test]
fn surface_brightness_validation_matches_import_and_preference_updates() {
let source = brightness_fixture();
let theme = from_value(&source).unwrap();
assert_eq!(theme.options[0].effect, Some(OptionEffect::SurfaceBrightness));
assert_eq!(export_file(&theme)["requires"], 2);
assert_eq!(serde_json::to_value(&theme).unwrap()["requires"], 2);
assert_eq!(parse(&export_file(&theme).to_string()).unwrap(), theme);
assert_eq!(serde_json::from_value::<ThemeFile>(serde_json::to_value(&theme).unwrap()).unwrap(), theme);
for (min, max) in [(60, 120), (60, 100), (100, 120), (80, 110)] {
let mut valid = source.clone();
valid["options"][0]["min"] = json!(min);
valid["options"][0]["max"] = json!(max);
let imported = from_value(&valid).unwrap();
let mut restored = theme.clone();
restored.set_option_tables(&valid).unwrap();
assert_eq!(restored, imported);
}
let mut cases = Vec::new();
for (key, value) in [
("effect", json!(null)),
("effect", json!("unknown")),
("effect", json!({"surface_brightness":true})),
("effect", json!(["surface_brightness"])),
("effect", json!(true)),
("min", json!(59)),
("min", json!(101)),
("max", json!(121)),
("max", json!(99)),
("default", json!(90)),
("step", json!(0)),
("step", json!(2)),
("unit", json!("px")),
] {
let mut invalid = source.clone();
invalid["options"][0][key] = value;
cases.push(invalid);
}
for option in [
json!({"id":"exposure","type":"bool","default":true,"effect":"surface_brightness"}),
json!({"id":"exposure","type":"choice","default":"a","values":[{"id":"a","label":{}},{"id":"b","label":{}}],
"effect":"surface_brightness"}),
] {
let mut invalid = source.clone();
invalid["options"][0] = option;
cases.push(invalid);
}
for key in ["default", "min", "max", "step"] {
for value in [Value::Null, json!("NaN"), json!("Infinity"), json!(1e100), json!(-1e100)] {
let mut invalid = source.clone();
invalid["options"][0][key] = value;
cases.push(invalid);
}
}
for id in STANDARD_OPTIONS {
let mut invalid = source.clone();
invalid["options"][0]["id"] = json!(id);
cases.push(invalid);
}
let mut duplicate = source.clone();
let mut second = duplicate["options"][0].clone();
second["id"] = json!("another_exposure");
duplicate["options"].as_array_mut().unwrap().push(second);
cases.push(duplicate);
for level in [None, Some(0), Some(1)] {
let mut invalid = source.clone();
invalid.as_object_mut().unwrap().remove("requires");
if let Some(level) = level {
invalid["requires"] = json!(level);
}
assert!(from_value(&invalid).unwrap_err().contains("requires"));
let mut legacy = ThemeFile { requires: level.unwrap_or(1), ..theme.clone() };
let before = legacy.clone();
assert!(legacy.set_option_tables(&invalid).unwrap_err().contains("requires"));
assert_eq!(legacy, before, "invalid updates are atomic");
assert_eq!(resolve(&legacy), resolve_base(&legacy), "restored legacy tables cannot enable effects");
}
for invalid in cases {
assert!(parse(&invalid.to_string()).is_err(), "{invalid}");
let mut restored = theme.clone();
assert!(restored.set_option_tables(&invalid).is_err(), "{invalid}");
assert_eq!(restored, theme, "invalid updates are atomic");
}
for number in ["NaN", "Infinity", "-Infinity", "1e400"] {
let invalid = source.to_string().replace("\"default\":100", &format!("\"default\":{number}"));
assert!(parse(&invalid).is_err(), "{number}");
}
for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
for field in ["default", "min", "max", "step"] {
let mut damaged = theme.clone();
let OptionKind::Range { default, min, max, step, .. } = &mut damaged.options[0].kind else { panic!("range fixture") };
match field {
"default" => *default = invalid,
"min" => *min = invalid,
"max" => *max = invalid,
_ => *step = invalid,
}
assert_eq!(resolve(&damaged), resolve_base(&damaged), "non-finite {field} disables restored effects");
}
}
}
#[test]
fn surface_brightness_saved_values_are_bounded_continuous_explicit_and_parent_gated() {
let source = brightness_fixture();
let theme = from_value(&source).unwrap();
let saved = |value| BTreeMap::from([("exposure".into(), value)]);
for (input, expected) in [(-100.0, 60.0), (59.0, 60.0), (121.0, 120.0), (f32::MAX, 120.0)] {
let values = saved(OptionValue::Num(input));
let (effective, notices) = effective_values(&theme, &values);
assert_eq!(effective["exposure"], OptionValue::Num(expected));
assert_eq!(notices, [OptionNotice { id: "exposure".into(), kind: OptionNoticeKind::Clamped }]);
assert_eq!(resolve_with(&theme, &values), resolve_with(&theme, &saved(OptionValue::Num(expected))));
}
for (input, kind) in [
(OptionValue::Num(f32::NAN), OptionNoticeKind::NonFinite),
(OptionValue::Num(f32::INFINITY), OptionNoticeKind::NonFinite),
(OptionValue::Num(f32::NEG_INFINITY), OptionNoticeKind::NonFinite),
(OptionValue::Bool(true), OptionNoticeKind::WrongType),
(OptionValue::Choice("100".into()), OptionNoticeKind::WrongType),
] {
let values = saved(input);
let (effective, notices) = effective_values(&theme, &values);
assert_eq!(effective["exposure"], OptionValue::Num(100.0));
assert_eq!(notices, [OptionNotice { id: "exposure".into(), kind }]);
assert_eq!(resolve_with(&theme, &values), resolve(&theme));
}
for (percent, channel) in [(60.0, 60), (60.5, 61), (100.0, 100), (120.0, 120)] {
let values = saved(OptionValue::Num(percent));
assert!(effective_values(&theme, &values).1.is_empty());
assert_eq!(resolve_with(&theme, &values).viewport_top, Color32::from_gray(channel));
}
let mut implicit = source.clone();
implicit["options"][0].as_object_mut().unwrap().remove("effect");
implicit["options"][0]["id"] = json!("brightness");
let implicit = from_value(&implicit).unwrap();
assert_eq!(resolve_with(&implicit, &BTreeMap::from([("brightness".into(), OptionValue::Num(60.0))])), resolve_base(&implicit));
let mut gated = source;
gated["options"][0]["parent"] = json!("enabled");
gated["options"].as_array_mut().unwrap().push(json!({"id":"enabled","type":"bool","default":false}));
let gated = from_value(&gated).unwrap();
let mut values = saved(OptionValue::Num(60.0));
assert_eq!(effective_values(&gated, &values).0["exposure"], OptionValue::Num(60.0));
assert_eq!(resolve_with(&gated, &values), resolve_base(&gated));
values.insert("enabled".into(), OptionValue::Bool(true));
assert_eq!(resolve_with(&gated, &values).viewport_top, Color32::from_gray(60));
}
#[test]
fn surface_brightness_preserves_alpha_and_semantics_and_scales_authored_layers() {
let mut source = brightness_fixture();
source["colors"]["grid"] = json!("#64646480");
source["colors"]["border"] = json!("#64646480");
source["borders"] = json!({"primary":{"width":1,"color":"#64646480"}});
let theme = from_value(&source).unwrap();
let original = resolve(&theme);
for percent in [60.0, 120.0] {
let mut before = original;
let mut after = resolve_with(&theme, &BTreeMap::from([("exposure".into(), OptionValue::Num(percent))]));
assert_ne!(after.grid, before.grid);
assert_ne!(after.border, before.border);
assert_ne!(after.borders.primary, before.borders.primary);
assert_eq!(after.borders.primary.unwrap().color.a(), before.borders.primary.unwrap().color.a());
assert_eq!(after.borders.primary.unwrap().width, before.borders.primary.unwrap().width);
assert_eq!(after.icon_palette, before.icon_palette);
for key in keys() {
let alphas = |colors: &[Color32]| colors.iter().map(|color| color.a()).collect::<Vec<_>>();
assert_eq!(alphas(slot(&mut after, key).unwrap()), alphas(slot(&mut before, key).unwrap()), "{percent} {key}: alpha");
}
for key in [
"text",
"text_dim",
"form_ink",
"form_dim",
"dialog_label",
"accent",
"selected_bg",
"selected_ink",
"hover",
"error",
"warning",
"agent_busy",
"agent_ok",
"axis_x",
"axis_y",
"axis_z",
"sketch",
"sketch_fixed",
"sketch_conflict",
] {
assert_eq!(slot(&mut after, key), slot(&mut before, key), "{percent} {key}: semantic colour");
}
}
source["overlays"] = json!([{"when":{"opt":"exposure","gte":60},"colors":{"panel":"#323232","viewport_top":"#969696"}}]);
source["tabs"] = json!({"SKETCH":{"panel":"#3c3c3c","viewport_top":"#c8c8c8"}});
source["options"]
.as_array_mut()
.unwrap()
.extend([json!({"id":"tab_colours","type":"bool","default":true}), json!({"id":"opacity","type":"range","default":60})]);
let theme = from_value(&source).unwrap();
for (percent, overlay_channel, tab_channel) in [(60.0, 90, 120), (120.0, 180, 240)] {
let values = BTreeMap::from([("exposure".into(), OptionValue::Num(percent))]);
let overlay = resolve_with(&theme, &values);
let tab = resolve_for_tab(&theme, &values, Some("SKETCH"));
assert_eq!(overlay.viewport_top, Color32::from_gray(overlay_channel));
assert_eq!(tab.viewport_top, Color32::from_gray(tab_channel));
assert_eq!(tab.axis_x, original.axis_x);
for tokens in [overlay, tab] {
for background in [Color32::BLACK, Color32::WHITE] {
let ground = crate::theme::composite(tokens.panel, background);
assert!(crate::theme::contrast_ratio(crate::theme::composite(tokens.text, ground), ground) >= 4.5);
}
}
}
}
const ORIGINAL_BUILTIN_IDS: &[&str] = &[
"gruvbox-dark",
"gruvbox-light",
"solidcraft-95",
"1-bit",
"1-bit-dark",
"platinum-9",
"cupertino",
"green-phosphor",
"amber-phosphor",
"blueprint",
"one-dark",
"tokyo-night",
"solarized-light",
"nord",
"high-contrast",
"graphite",
"nosferatu",
"catppuccin-mocha",
"okibi",
"akari",
"clay",
"clay-dark",
"cga",
];
#[test]
fn builtin_order_preserves_the_existing_picker_sequence() {
let original: &[&str] = &[
"gruvbox-dark",
"gruvbox-light",
"solidcraft-95",
"1-bit",
"1-bit-dark",
"platinum-9",
"cupertino",
"green-phosphor",
"amber-phosphor",
"blueprint",
"one-dark",
"tokyo-night",
"solarized-light",
"nord",
"high-contrast",
"graphite",
"nosferatu",
"catppuccin-mocha",
"okibi",
"akari",
"clay",
"clay-dark",
"cga",
];
let themes = read_builtin(BUILTIN).unwrap();
let retained: Vec<_> = themes.iter().map(|t| t.id.as_str()).filter(|id| original.contains(id)).collect();
assert_eq!(retained, original);
let retained: Vec<_> = themes.iter().map(|t| t.id.as_str()).filter(|id| ORIGINAL_BUILTIN_IDS.contains(id)).collect();
assert_eq!(retained, ORIGINAL_BUILTIN_IDS);
assert!(BUILTIN.windows(2).all(|pair| pair[0].0 < pair[1].0), "source entries are sorted by id");
}
@@ -2409,18 +2896,25 @@ mod tests {
assert_eq!((theme.name.as_str(), theme.colors.len()), ("CGA", keys().len()), "CGA sets every colour");
}
/// #4868: a theme that sets none of the File view's colours (all the built-in ones) gets them
/// #4868: a theme that sets none of the File view's colours gets them
/// from its own palette, readable: the rail and primary action from its accent, the page from
/// its panel, not Dark's or Light's blue and grey. One it does set stays as set.
#[test]
fn the_file_view_follows_a_theme_that_does_not_set_its_colours() {
for theme in builtin() {
let t = resolve(theme);
// File colours derive from the authored material before floating-surface opacity.
let mut opaque = theme.clone();
opaque.options.retain(|option| option.id != "opacity");
let material = resolve(&opaque);
if !theme.colors.contains_key("file_rail") {
assert_eq!((t.file_background, t.file_primary), (t.panel, t.accent_soft), "{}", theme.id);
assert_eq!((t.file_background, t.file_primary), (material.panel, material.accent_soft), "{}", theme.id);
let base = if theme.base.is_dark() { Tokens::dark() } else { Tokens::light() };
assert!(t.accent == base.accent || t.file_rail != base.file_rail, "{}: the rail is the base's", theme.id);
}
if !theme.colors.contains_key("file_surface") {
assert_eq!(t.file_surface, material.dialog_bg, "{}", theme.id);
}
// 4.5:1, or the best any of the theme's own inks reaches on that fill when that is less
// (Solarized Light's text on its soft accent is 4.29:1): a derived colour can't do
// better than the theme itself.
@@ -92,7 +92,7 @@
{"expect":{"hidden_handles":["prefs:theme_notice_dismiss:blur"],"prefs":{"theme_options":{"scenario-blur":{"blur":18.25}},"theme_option_notices":[{"id":"blur","kind":"unknown_option"}]}}},
{"call":"ui.prefs","params":{"theme_import":{"format":"solidcraft-theme/1","name":"Scenario Blur","base":"dark","options":[{"id":"blur","type":"range","default":0}]}}},
{"expect":{"prefs":{"theme_option_values":{"blur":18.25},"theme_option_notices":[]}}},
{"call":"ui.prefs","params":{"theme_import":{"format":"solidcraft-theme/1","name":"Scenario Future Blur","base":"dark","requires":2,"options":[{"id":"blur","type":"range","default":18}]}}},
{"call":"ui.prefs","params":{"theme_import":{"format":"solidcraft-theme/1","name":"Scenario Future Blur","base":"dark","requires":3,"options":[{"id":"blur","type":"range","default":18}]}}},
{"frames":2},
{"expect":{"shown_handles":["prefs:theme_notice_dismiss:newer"],"prefs":{"theme_option_defs":[],"theme_option_values":{}}}},
{"click":{"handle":"prefs:theme_notice_dismiss:newer"}},
+2 -2
View File
@@ -511,11 +511,11 @@ manager's isolated app when a fresh session policy is needed.
For raw/newer warnings, the extended existing `prefs_theme_files` scenario saves
blur through `ui.prefs`, reimports that theme without the declaration, dismisses
the now-unknown-entry notice, checks that raw JSON and API notices persist, and
reintroduces the definition to restore the saved value. It also imports `requires:2`
reintroduces the definition to restore the saved value. It also imports `requires:3`
and dismisses its warning without adding a controls header. The unit test additionally
loads malformed raw JSON, changes it to ensure reappearance, and bounds dismissal
storage. For a manager screenshot, import a copy of the fixture with name `Future
blur review`, `requires:2`, then open Interface; retain the original file for reimport
blur review`, `requires:3`, then open Interface; retain the original file for reimport
after upgrading, as its unsupported tables are discarded.
All nine new unit tests match the **`blur_`** filter, in the existing area modules:
+27 -4
View File
@@ -385,7 +385,7 @@ Four ids are reserved for definitions owned by the app:
| Id | Type | Bounds | Unit | Parent |
|---|---|---|---|---|
| `opacity` | range | 40–100 | `%` | — |
| `blur` | range | 0–40 | `px` | — |
| `blur` | range | 0–60 | `px` | — |
| `tab_colours` | bool | — | — | — |
| `tab_fade_ms` | range | 0–600 | `ms` | `tab_colours` |
@@ -399,8 +399,8 @@ absent from the list until its behaviour is registered.
The resolver exposes declared standard options in the order `opacity`, `blur`, `tab_colours`,
each immediately followed by its child, then custom options in declared order.
Opacity scales only these floating surface fill tokens: `panel`, `panel_header`, `dialog_bg`,
`dialog_body`, `dialog_footer`, `overlay` and `nav_tip`. Scaling is applied after matching
Opacity scales only these floating surface fill tokens: `panel`, `panel_header`, `toolbar`,
`timeline`, `dialog_bg`, `dialog_body`, `dialog_footer`, `overlay` and `nav_tip`. Scaling is applied after matching
overlays. All other tokens, including text, icons, borders, controls, viewport and document
colours, retain their resolved values. At 100%, with the implicit 100% default, or without
offered opacity, every token is exactly the original resolved value. The supported range is
@@ -453,6 +453,21 @@ Saved selections and settings profiles using the former `-soft` and `-hard` ids
select the corresponding family and contrast. Selecting a family again restores its
last contrast; Reset returns it to Standard.
**Onyx** is an original dark stone-grey theme with quiet surface layers, fine etched edges
and a restrained blue-grey accent. Its defaults pair **80% Opacity** (adjustable from 60–100%) with a broad **60 px Blur**:
Opacity sets how much of the scene shows through; Blur sets how widely its detail softens.
Its custom controls are **Accent** (Slate blue, Silver or Sage), **Contrast** (Soft or Defined),
**Brightness** (60–120%, default 100%), then **Foreground colours** (Vibrant or Muted),
with optional **Tab colours** and a Fade control in the standard controls above.
Edges always use the fine Etched finish. Brightness smoothly adjusts stone surfaces,
viewport backgrounds and grid tones while preserving text and semantic foreground colours.
Vibrant is the default, giving the axes, icons and modelling guides stronger colour.
Muted preserves the original quiet palette. Accent independently controls selection and highlights; foreground modes
keep the stone-grey backgrounds and red/green/blue axis meanings.
Tab colours gently tint panels and the viewport while preserving the selected accent.
Accent changes selection and control accents; Foreground colours sets semantic glyphs,
workspace colours and status colours while retaining their meaning. As with other themes, the existing blur availability and readability limits apply.
Up to 32 overlays apply in file order, after the base palette and the theme's own value tables.
Later matching entries win by key. Every overlay has one `when`: `{"opt":"id","eq":value}`
for an exact, correctly typed comparison, or `gte`, `lte`, or both for inclusive range bounds.
@@ -461,7 +476,7 @@ malformed values are refused with the overlay's one-based position. An overlay m
`colors`, `radii`, `borders` and `icon_palette`; all their existing key and value checks apply.
Overlays only select literal tables: there are no scripts, expressions or interpolation.
The optional integer `requires` defaults to 1, this app's supported level. A larger level
The optional integer `requires` defaults to 1; this app supports levels through 2. A larger level
loads the authored base value tables, disables options and overlays, and exposes a
`needs_newer` flag through the theme API for future UI. The theme-definition API can export
options and overlays intact; the existing token snapshot export keeps its previous shape.
@@ -469,6 +484,14 @@ The value resolver reports notices for unknown saved ids, wrong types, unknown c
non-finite numbers and clamped values. Invalid saved values for declared options fall back to defaults;
unknown saved ids are omitted because they have no definition or default.
A custom percent range may declare `"effect":"surface_brightness"` with `requires: 2`.
Its bounds must stay within 60–120%, include 100%, and use default 100 and step 1.
This continuously scales neutral surface, viewport, grid and border RGB after overlays
and tab tables, before opacity readability protection. Alpha, text, semantic colours
and selection colours stay authored. At 100% the transform is exactly unchanged.
Only one such effect is allowed per theme; other effects and non-range bindings are rejected.
The custom option keeps its authored label and order, after the standard controls.
#### Follow a theme file
In the desktop app, **Follow a theme file** (under the themes) takes a theme file that another