diff --git a/.agents/notes/implemented/architecture/2026-10-16-community-skin-shared-bases.md b/.agents/notes/implemented/architecture/2026-10-16-community-skin-shared-bases.md new file mode 100644 index 00000000..1174234f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-community-skin-shared-bases.md @@ -0,0 +1,31 @@ +# Agent Note: Windows 社区皮肤共享基底适配 + +Status: implemented + +## Problem + +独立 Windows 客户端把清单 `base` 限制为六个本地外观,社区服务发来的 `system`、`shuishan`、`light`、`paper`、`night`、`ink` 因而在加载时被拒绝。手改为 `fluent` 可以加载,但会丢掉基底配色。 + +## Decision + +在目录加载层接受六个共享基底,将内部渲染基底统一适配为 `fluent`,并补齐固定基底缺省的候选、菜单与工具栏配色。清单显式配色优先。`system` 跟随宿主,不注入固定配色。固定明暗基底只保留对应的有效 `themes`,沿用现有布局与主题兼容判定。六个旧内置外观及其默认清单保持原有行为。 + +配色依据为 msime 提交 `1502128159` 的 `crates/client-core/src/theme.rs`,只取已提交来源的颜色定义,不复制相邻工作区的未提交实现。 + +## Alternatives considered + +- **仅扩大白名单**:改动最少,但 WebView2 按 `base` 查找 CSS 文件,共享基底没有本地资源;D2D 与设置预览也只理解旧外观,不能保证配色一致。 +- **未知基底全部回退 Fluent**:任何新包都能显示,但拼写错误和不受支持的基底被静默接受,而且原本的配色丢失;未知值仍应拒绝。 +- **每个渲染器分别实现共享基底**:各渲染器可按需调整,但候选窗、浮层和两种设置预览需要重复维护同一张表;加载层的现有配色槽位已能表达这次兼容。 + +## Consequences + +社区包不再需要手改 `base`;D2D、WebView2、工具栏、光标指示器和设置预览都读取加载器适配后的同一份数据。固定基底与清单声明的明暗取交集,空交集的包仍可列出,但不会在任何模式套用;适配后的 `base` 是内部渲染结构名,设置页也会显示为 `fluent`。 + +配色表由 Windows 保存副本,上游调整时需要同步。共享规范中超出现有 Windows 清单读取与渲染能力的扩展字段不因此获得支持。缺省 `selected` 与 `hover` 按上游 `resolve` 的规则,取清单覆盖后的 `accent` / `text` 加半透明,清单可显式覆盖这些槽位;独立的菜单和工具栏字段优先于由候选配色派生的缺省值。 + +## Verification + +`server/tests/src/test_candidate_skin_catalog_bases.cpp` 注册进 Windows 的 `MetasequoiaImeServerTests`。同一测试源在 macOS 上用 `server/tests/portable/main.cpp`、真实 `candidate_skin_catalog.cpp` 和 toml++ 3.4.0 直接编译,以 AddressSanitizer、UndefinedBehaviorSanitizer 执行。覆盖共享基底、v1/v2、显式配色、旧外观、未知值、有效明暗交集,以及默认清单和包内开关的优先级。默认分支的加载器运行这些回归测试失败,修复后通过。 + +本地另执行现有 portable CTest、产品级 Python 单测、全量 clang-format 18.1.8 检查与构建输入路径检查。macOS 无法验证 Windows 原生宿主、安装或不同 DPI 下的实际绘制;PR 保留这一验证缺口,不把加载器测试当作 Windows 装机验收。 diff --git a/examples/skin-examples/schema/README.md b/examples/skin-examples/schema/README.md index dc56490a..b4f21d45 100644 --- a/examples/skin-examples/schema/README.md +++ b/examples/skin-examples/schema/README.md @@ -6,7 +6,7 @@ ## 顶层字段 -- 必填:`schema_version`(`1` 或 `2`)、`id`、`name`、`version`、`base`(继承的内置皮肤:`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`) +- 必填:`schema_version`(`1` 或 `2`)、`id`、`name`、`version`、`base`(继承的内置皮肤:`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`;或与 msime 共享的基底:`system`、`shuishan`、`light`、`paper`、`night`、`ink`,按 `fluent` 的结构绘制并补上基底配色,固定明暗的基底只在对应的 `themes` 下生效) - 可选:`author`、`description` - `[supports]`:`layouts`(`horizontal` / `vertical`)、`themes`(`dark` / `light`) - `[license]`:`code`、`assets` diff --git a/examples/skin-examples/skins/niya-demo/skin.toml b/examples/skin-examples/skins/niya-demo/skin.toml index d5ff7c0e..9ad5f2c7 100644 --- a/examples/skin-examples/skins/niya-demo/skin.toml +++ b/examples/skin-examples/skins/niya-demo/skin.toml @@ -20,7 +20,8 @@ name = "Niya Demo" # 必填,设置页里显示的名字,最 version = "0.3.0" # 必填,皮肤自身的版本号,最长 32 字节 author = "Metasequoia IME contributors" # 可选,作者,最长 120 字节 description = "天元突破·妮娅:卡片上方右对齐的人物装饰、带星点的背景图、12dip 圆角,以及配套的悬浮工具栏配色。" # 可选,最长 500 字节 -# 必填,继承的内置皮肤:fluent | wechat | graphite | willow_green | autumn_osmanthus | microsoft。 +# 必填,继承的内置皮肤:fluent | wechat | graphite | willow_green | autumn_osmanthus | microsoft, +# 或与 msime 共享的基底:system | shuishan | light | paper | night | ink(按 fluent 结构绘制)。 # 下面没写的键、以及皮肤无法描述的排版(字号、间距、内边距)都取自 base。 base = "fluent" diff --git a/examples/skin-examples/tests/test_manifests.py b/examples/skin-examples/tests/test_manifests.py index bfb72283..d413bdf0 100644 --- a/examples/skin-examples/tests/test_manifests.py +++ b/examples/skin-examples/tests/test_manifests.py @@ -9,6 +9,8 @@ ROOT = Path(__file__).resolve().parents[1] # MSIME_SKINS_DIR points the same checks at another skin collection, e.g. the repository's skins/. SKINS_DIR = Path(os.environ.get("MSIME_SKINS_DIR", ROOT / "skins")).resolve() BUILTIN_SKINS = ("fluent", "wechat", "graphite", "willow_green", "autumn_osmanthus", "microsoft") +# Mirrors the Server catalog: community bases shared with msime, adapted onto fluent at load time. +SHARED_BASES = ("system", "shuishan", "light", "paper", "night", "ink") class ManifestTests(unittest.TestCase): @@ -24,7 +26,7 @@ class ManifestTests(unittest.TestCase): # Mirrors the Server catalog: name, version and base are required, author is not. for key in ("name", "version", "base"): self.assertTrue(data[key].strip(), key) - self.assertIn(data["base"], BUILTIN_SKINS) + self.assertIn(data["base"], BUILTIN_SKINS + SHARED_BASES) window = data.get("candidate_window", {}) assets = {key: data.get(key) for key in ("toolbar_stylesheet", "preview")} for table in ("decoration", "background"): diff --git a/server/src/skin/candidate_skin_catalog.cpp b/server/src/skin/candidate_skin_catalog.cpp index 1113e933..a9a96c80 100644 --- a/server/src/skin/candidate_skin_catalog.cpp +++ b/server/src/skin/candidate_skin_catalog.cpp @@ -14,6 +14,104 @@ namespace CandidateSkinCatalog { namespace { +// 共享基底来自 msime 的固定主题(crates/client-core/src/theme.rs,上游调整时需同步); +// Windows 只复用 Fluent 的结构,配色在加载时补进现有槽位。system 跟随宿主,没有调色板。 +struct SharedBase +{ + const char *id; + const char *theme; + const char *surface; + const char *text; + const char *accent; + const char *number; +}; + +const SharedBase *FindSharedBase(const std::string &id) +{ + static constexpr SharedBase bases[] = { + {"system", nullptr, nullptr, nullptr, nullptr, nullptr}, + {"shuishan", "dark", "#2A2B27", "#FFFFFF", "#7FE08E", "#9FB5A3"}, + {"light", "light", "#FFFFFF", "#1A1A1A", "#005FB8", "#6A6F76"}, + {"paper", "light", "#F7F5F0", "#1A1E1B", "#2C7A4B", "#6E6A5E"}, + {"night", "dark", "#16262F", "#E6F1F4", "#4FD1C5", "#86A6B0"}, + {"ink", "dark", "#1A1A1A", "#9A9A9A", "#FFFFFF", "#9A9A9A"}, + }; + for (const auto &base : bases) + { + if (id == base.id) + { + return &base; + } + } + return nullptr; +} + +void FillColor(std::string &color, const std::string &fallback) +{ + if (color.empty()) + { + color = fallback; + } +} + +// 给十六进制颜色换上指定透明度;清单里的 rgba()、颜色名等无法直接拼接的写法退回基底原色。 +std::string WithAlpha(const std::string &color, const char *fallback, const char *alpha) +{ + const bool hex = color.size() > 1 && color.front() == '#' && + std::all_of(color.begin() + 1, color.end(), [](unsigned char ch) { return std::isxdigit(ch); }); + if (hex && (color.size() == 7 || color.size() == 9)) + { + return color.substr(0, 7) + alpha; + } + if (hex && (color.size() == 4 || color.size() == 5)) + { + return std::string{'#', color[1], color[1], color[2], color[2], color[3], color[3]} + alpha; + } + return std::string(fallback) + alpha; +} + +void AdaptSharedBase(Package &package) +{ + const SharedBase *base = FindSharedBase(package.base); + if (!base) + { + return; + } + // 所有使用方(含设置预览)仍按本地 CSS / D2D 的 Fluent 结构处理,不能去查不存在的共享 CSS。 + package.base = "fluent"; + if (!base->theme) + { + return; + } + package.themes.erase(std::remove_if(package.themes.begin(), package.themes.end(), + [base](const std::string &theme) { return theme != base->theme; }), + package.themes.end()); + CandidateColors &colors = std::string_view(base->theme) == "light" ? package.light : package.dark; + FillColor(colors.surface, base->surface); + FillColor(colors.border, "#0000001F"); + FillColor(colors.text, base->text); + FillColor(colors.number, base->number); + FillColor(colors.accent, base->accent); + // 与上游一致,派生槽位跟随清单覆盖后的 accent / text,而不是基底原色。 + FillColor(colors.selected, WithAlpha(colors.accent, base->accent, "24")); + FillColor(colors.hover, WithAlpha(colors.text, base->text, "0F")); + FillColor(colors.selectedText, colors.accent); + FillColor(colors.selectedNumber, colors.number); + FillColor(colors.translation, colors.number); + // 菜单与工具栏默认跟随最终候选配色,各自显式设置的颜色始终优先。 + FillColor(colors.menuBackground, colors.surface); + FillColor(colors.menuBorder, colors.border); + FillColor(colors.menuText, colors.text); + FillColor(colors.menuHover, colors.hover); + ToolbarColors &toolbar = std::string_view(base->theme) == "light" ? package.toolbarLight : package.toolbarDark; + FillColor(toolbar.background, colors.surface); + FillColor(toolbar.border, colors.border); + FillColor(toolbar.handle, colors.accent); + FillColor(toolbar.divider, colors.border); + FillColor(toolbar.icon, colors.text); + FillColor(toolbar.hover, colors.hover); +} + void SetError(std::string *error, const std::string &message) { if (error) @@ -354,7 +452,8 @@ std::optional Load(const std::filesystem::path &skinsRoot, const std::s !ReadString(root, "version", package.version, 32, true) || !ReadString(root, "author", package.author, 120, false) || !ReadString(root, "description", package.description, 500, false) || - !ReadString(root, "base", package.base, 32, true) || !IsBuiltIn(package.base)) + !ReadString(root, "base", package.base, 32, true) || + (!IsBuiltIn(package.base) && !FindSharedBase(package.base))) { SetError(error, "manifest 的基本信息无效"); return std::nullopt; @@ -670,6 +769,7 @@ std::optional Load(const std::filesystem::path &skinsRoot, const std::s return std::nullopt; } } + AdaptSharedBase(package); return package; } catch (const toml::parse_error &) diff --git a/server/src/skin/candidate_skin_catalog.h b/server/src/skin/candidate_skin_catalog.h index f3275e40..c3338466 100644 --- a/server/src/skin/candidate_skin_catalog.h +++ b/server/src/skin/candidate_skin_catalog.h @@ -100,6 +100,7 @@ struct Package std::string version; std::string author; std::string description; + // 渲染用的本地基底;共享基底在 Load 中转为 Fluent,并补齐缺省配色与有效 themes。 std::string base = "fluent"; std::vector layouts; std::vector themes; @@ -175,7 +176,7 @@ inline constexpr bool kDefaultPageArrows = false; // 默认皮肤清单缺失或没写 fit_content 时的取值:卡片保留出厂最小宽度。 inline constexpr bool kDefaultFitContent = false; -// 五个内置皮肤的 ID,按设置页的展示顺序。 +// 六个内置皮肤的 ID,按设置页的展示顺序。 const std::vector &BuiltInIds(); bool IsBuiltIn(const std::string &id); bool IsSafeId(const std::string &id); diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index add7f1fc..c245c64e 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -86,6 +86,7 @@ set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/test_dictionary_replay_non_ascii_path.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_non_ascii_path.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_colors.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_bases.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_images.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_page_arrows.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_supports.cpp" diff --git a/server/tests/src/test_candidate_skin_catalog_bases.cpp b/server/tests/src/test_candidate_skin_catalog_bases.cpp new file mode 100644 index 00000000..ff7b8a12 --- /dev/null +++ b/server/tests/src/test_candidate_skin_catalog_bases.cpp @@ -0,0 +1,255 @@ +#include "tests/includes/test_framework.h" + +#include "skin/candidate_skin_catalog.h" + +#include +#include +#include +#include + +namespace +{ +struct SkinFixture +{ + std::filesystem::path root = + std::filesystem::temp_directory_path() / + ("msime-shared-bases-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + + ~SkinFixture() + { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + + void Write(const std::string &id, const std::string &base, const std::string &tables = "", int schema = 1, + const std::string &themes = "['dark', 'light']") const + { + const auto directory = root / id; + std::filesystem::create_directories(directory); + std::ofstream manifest(directory / "skin.toml"); + REQUIRE(manifest.is_open()); + manifest << "schema_version = " << schema << "\nid = '" << id << "'\nname = 'Sample'\nversion = '1.0'\nbase = '" + << base << "'\n[supports]\nlayouts = ['horizontal', 'vertical']\nthemes = " << themes + << "\n[candidate_window]\n" + << tables; + REQUIRE(manifest.good()); + } +}; +} // namespace + +TEST_CASE(candidate_skin_shared_system_uses_native_palette_and_preserves_overrides) +{ + SkinFixture fixture; + fixture.Write("sample", "system", "[candidate.dark]\ntext = '#123456'\n[toolbar.light]\nicon = '#654321'\n"); + const auto package = CandidateSkinCatalog::Load(fixture.root, "sample"); + REQUIRE(package.has_value()); + REQUIRE_EQ(package->base, std::string("fluent")); + REQUIRE_EQ(package->dark.text, std::string("#123456")); + REQUIRE_EQ(package->toolbarLight.icon, std::string("#654321")); + REQUIRE(package->dark.surface.empty()); + REQUIRE(package->light.surface.empty()); + REQUIRE(package->toolbarDark.background.empty()); + REQUIRE(!package->cornerRadiusDip.has_value()); + for (const char *layout : {"horizontal", "vertical"}) + { + for (const char *theme : {"dark", "light"}) + { + REQUIRE(CandidateSkinCatalog::LoadSupported(fixture.root, "sample", layout, theme).has_value()); + } + } + REQUIRE(!CandidateSkinCatalog::LoadSupported(fixture.root, "sample", "unknown", "dark").has_value()); +} + +TEST_CASE(candidate_skin_shared_fixed_bases_inherit_candidate_menu_and_toolbar_colors) +{ + struct Expected + { + const char *id; + const char *theme; + const char *surface; + const char *text; + const char *accent; + const char *number; + }; + constexpr Expected expected[] = { + {"shuishan", "dark", "#2A2B27", "#FFFFFF", "#7FE08E", "#9FB5A3"}, + {"light", "light", "#FFFFFF", "#1A1A1A", "#005FB8", "#6A6F76"}, + {"paper", "light", "#F7F5F0", "#1A1E1B", "#2C7A4B", "#6E6A5E"}, + {"night", "dark", "#16262F", "#E6F1F4", "#4FD1C5", "#86A6B0"}, + {"ink", "dark", "#1A1A1A", "#9A9A9A", "#FFFFFF", "#9A9A9A"}, + }; + SkinFixture fixture; + for (int schema : {1, 2}) + { + for (const auto &entry : expected) + { + fixture.Write("sample", entry.id, "", schema); + const auto package = CandidateSkinCatalog::Load(fixture.root, "sample"); + REQUIRE(package.has_value()); + REQUIRE_EQ(package->base, std::string("fluent")); + REQUIRE_EQ(package->schemaVersion, schema); + REQUIRE_EQ(package->themes, std::vector({entry.theme})); + const bool light = std::string(entry.theme) == "light"; + const auto &colors = light ? package->light : package->dark; + const auto &toolbar = light ? package->toolbarLight : package->toolbarDark; + REQUIRE_EQ(colors.surface, std::string(entry.surface)); + REQUIRE_EQ(colors.border, std::string("#0000001F")); + REQUIRE_EQ(colors.text, std::string(entry.text)); + REQUIRE_EQ(colors.accent, std::string(entry.accent)); + REQUIRE_EQ(colors.number, std::string(entry.number)); + REQUIRE_EQ(colors.translation, std::string(entry.number)); + REQUIRE_EQ(colors.selected, std::string(entry.accent) + "24"); + REQUIRE_EQ(colors.hover, std::string(entry.text) + "0F"); + REQUIRE_EQ(colors.selectedText, std::string(entry.accent)); + REQUIRE_EQ(colors.selectedNumber, std::string(entry.number)); + REQUIRE_EQ(colors.menuBackground, colors.surface); + REQUIRE_EQ(colors.menuBorder, colors.border); + REQUIRE_EQ(colors.menuText, colors.text); + REQUIRE_EQ(colors.menuHover, colors.hover); + REQUIRE_EQ(toolbar.background, colors.surface); + REQUIRE_EQ(toolbar.border, colors.border); + REQUIRE_EQ(toolbar.divider, colors.border); + REQUIRE_EQ(toolbar.handle, colors.accent); + REQUIRE_EQ(toolbar.icon, colors.text); + REQUIRE_EQ(toolbar.hover, colors.hover); + for (const char *layout : {"horizontal", "vertical"}) + { + REQUIRE(CandidateSkinCatalog::LoadSupported(fixture.root, "sample", layout, entry.theme).has_value()); + REQUIRE(!CandidateSkinCatalog::LoadSupported(fixture.root, "sample", layout, light ? "dark" : "light") + .has_value()); + } + } + } +} + +TEST_CASE(candidate_skin_shared_base_keeps_explicit_colors_and_derives_auxiliary_defaults) +{ + SkinFixture fixture; + fixture.Write("sample", "paper", R"toml( +[candidate.light] +surface = "#123456" +text = "#234567" +number = "#345678" +accent = "#456789" +selected = "#56789a" +hover = "#6789ab" +selected_text = "#789abc" +selected_number = "#89abcd" +translation = "#9abcde" +show_selected_bar = false +[candidate.light.menu] +text = "#abcdef" +[toolbar.light] +background = "#fedcba" +icon = "#edcba9" +)toml"); + const auto package = CandidateSkinCatalog::Load(fixture.root, "sample"); + REQUIRE(package.has_value()); + REQUIRE_EQ(package->light.surface, std::string("#123456")); + REQUIRE_EQ(package->light.text, std::string("#234567")); + REQUIRE_EQ(package->light.number, std::string("#345678")); + REQUIRE_EQ(package->light.accent, std::string("#456789")); + REQUIRE_EQ(package->light.selected, std::string("#56789a")); + REQUIRE_EQ(package->light.hover, std::string("#6789ab")); + REQUIRE_EQ(package->light.selectedText, std::string("#789abc")); + REQUIRE_EQ(package->light.selectedNumber, std::string("#89abcd")); + REQUIRE_EQ(package->light.translation, std::string("#9abcde")); + REQUIRE_EQ(package->light.showSelectedBar, std::optional(false)); + REQUIRE_EQ(package->light.menuBackground, package->light.surface); + REQUIRE_EQ(package->light.menuText, std::string("#abcdef")); + REQUIRE_EQ(package->toolbarLight.background, std::string("#fedcba")); + REQUIRE_EQ(package->toolbarLight.icon, std::string("#edcba9")); + REQUIRE_EQ(package->toolbarLight.handle, package->light.accent); + REQUIRE_EQ(package->toolbarLight.hover, package->light.hover); +} + +TEST_CASE(candidate_skin_shared_base_derives_selection_and_hover_from_overridden_colors) +{ + SkinFixture fixture; + fixture.Write("sample", "paper", "[candidate.light]\naccent = '#C0392B'\ntext = '#202020'\n"); + const auto package = CandidateSkinCatalog::Load(fixture.root, "sample"); + REQUIRE(package.has_value()); + REQUIRE_EQ(package->light.selected, std::string("#C0392B24")); + REQUIRE_EQ(package->light.hover, std::string("#2020200F")); + REQUIRE_EQ(package->light.selectedText, std::string("#C0392B")); + REQUIRE_EQ(package->light.menuHover, package->light.hover); + REQUIRE_EQ(package->toolbarLight.hover, package->light.hover); + + fixture.Write("sample", "paper", "[candidate.light]\naccent = '#C0392B80'\ntext = '#abc'\n"); + auto normalized = CandidateSkinCatalog::Load(fixture.root, "sample"); + REQUIRE(normalized.has_value()); + REQUIRE_EQ(normalized->light.selected, std::string("#C0392B24")); + REQUIRE_EQ(normalized->light.hover, std::string("#aabbcc0F")); + + fixture.Write("sample", "paper", "[candidate.light]\naccent = 'rgba(192, 57, 43, 1)'\ntext = 'black'\n"); + normalized = CandidateSkinCatalog::Load(fixture.root, "sample"); + REQUIRE(normalized.has_value()); + REQUIRE_EQ(normalized->light.selected, std::string("#2C7A4B24")); + REQUIRE_EQ(normalized->light.hover, std::string("#1A1E1B0F")); +} + +TEST_CASE(candidate_skin_shared_base_scan_rejects_unknown_base_and_keeps_legacy_defaults) +{ + SkinFixture fixture; + for (const auto &base : CandidateSkinCatalog::BuiltInIds()) + { + const auto id = "sample-" + base; + fixture.Write(id, base); + const auto package = CandidateSkinCatalog::Load(fixture.root, id); + REQUIRE(package.has_value()); + REQUIRE_EQ(package->base, base); + REQUIRE_EQ(package->themes.size(), size_t{2}); + REQUIRE(package->light.surface.empty()); + REQUIRE(package->dark.surface.empty()); + REQUIRE(package->toolbarLight.background.empty()); + } + fixture.Write("sample-system", "system"); + fixture.Write("sample-paper", "paper"); + for (const char *base : {"unsupported-base", "custom", "native"}) + { + fixture.Write("unknown", base); + std::string error; + REQUIRE(!CandidateSkinCatalog::Load(fixture.root, "unknown", &error).has_value()); + REQUIRE(!error.empty()); + } + const auto catalog = CandidateSkinCatalog::Scan(fixture.root); + REQUIRE_EQ(catalog.packages.size(), size_t{8}); + REQUIRE_EQ(catalog.issues.size(), size_t{1}); + REQUIRE_EQ(catalog.issues.front().folder, std::string("unknown")); +} + +TEST_CASE(candidate_skin_shared_base_does_not_expand_declared_theme_support) +{ + SkinFixture fixture; + fixture.Write("sample", "paper", "", 1, "['dark']"); + const auto package = CandidateSkinCatalog::Load(fixture.root, "sample"); + REQUIRE(package.has_value()); + REQUIRE(package->themes.empty()); + REQUIRE(!CandidateSkinCatalog::LoadSupported(fixture.root, "sample", "horizontal", "dark").has_value()); + REQUIRE(!CandidateSkinCatalog::LoadSupported(fixture.root, "sample", "horizontal", "light").has_value()); +} + +TEST_CASE(candidate_skin_shared_base_preserves_default_manifest_and_package_switch_precedence) +{ + SkinFixture fixture; + const auto defaults = fixture.root / "default" / "fluent"; + std::filesystem::create_directories(defaults); + { + std::ofstream manifest(defaults / "skin.toml"); + manifest << "schema_version = 1\nid = 'fluent'\n[candidate_window]\npage_arrows = true\nfit_content = true\n"; + REQUIRE(manifest.good()); + } + for (const char *base : {"system", "paper"}) + { + fixture.Write("sample", base); + auto package = CandidateSkinCatalog::Load(fixture.root, "sample"); + REQUIRE(package.has_value()); + REQUIRE(CandidateSkinCatalog::ResolvePageArrows(fixture.root, "sample", &*package)); + REQUIRE(CandidateSkinCatalog::ResolveFitContent(fixture.root, "sample", &*package)); + fixture.Write("sample", base, "page_arrows = false\nfit_content = false\n"); + package = CandidateSkinCatalog::Load(fixture.root, "sample"); + REQUIRE(package.has_value()); + REQUIRE(!CandidateSkinCatalog::ResolvePageArrows(fixture.root, "sample", &*package)); + REQUIRE(!CandidateSkinCatalog::ResolveFitContent(fixture.root, "sample", &*package)); + } +}