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:
1 parent
40b1b70dec
commit
e3f99de8af
9 files changed
+1342
-57
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 |
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user