diff --git a/Data/colours/color.dtd b/Data/colours/color.dtd
index c39c5aae..d333bae6 100644
--- a/Data/colours/color.dtd
+++ b/Data/colours/color.dtd
@@ -30,6 +30,8 @@
gameGuideColor CDATA #IMPLIED
oneButtonDynamicOuterGuidesColor CDATA #IMPLIED
conversionNodeColor CDATA #IMPLIED
+ appearance (light|dark) #IMPLIED
+ companion CDATA #IMPLIED
>
diff --git a/Data/colours/colour.default.dark.xml b/Data/colours/colour.default.dark.xml
new file mode 100644
index 00000000..a4480ee7
--- /dev/null
+++ b/Data/colours/colour.default.dark.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
diff --git a/Data/colours/colour.euroasian.dark.xml b/Data/colours/colour.euroasian.dark.xml
new file mode 100644
index 00000000..85d93a72
--- /dev/null
+++ b/Data/colours/colour.euroasian.dark.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/Data/colours/colour.euroasian2.dark.xml b/Data/colours/colour.euroasian2.dark.xml
new file mode 100644
index 00000000..92fa77ef
--- /dev/null
+++ b/Data/colours/colour.euroasian2.dark.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/Data/colours/colour.rainbow.dark.xml b/Data/colours/colour.rainbow.dark.xml
new file mode 100644
index 00000000..e3ba19af
--- /dev/null
+++ b/Data/colours/colour.rainbow.dark.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
diff --git a/Data/colours/colour.thai.dark.xml b/Data/colours/colour.thai.dark.xml
new file mode 100644
index 00000000..b86784b7
--- /dev/null
+++ b/Data/colours/colour.thai.dark.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/Data/colours/colour.turbo.dark.xml b/Data/colours/colour.turbo.dark.xml
new file mode 100644
index 00000000..db919b97
--- /dev/null
+++ b/Data/colours/colour.turbo.dark.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/Data/colours/colour.vowels.dark.xml b/Data/colours/colour.vowels.dark.xml
new file mode 100644
index 00000000..f5f4acc2
--- /dev/null
+++ b/Data/colours/colour.vowels.dark.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/Data/colours/colour.vowels2.dark.xml b/Data/colours/colour.vowels2.dark.xml
new file mode 100644
index 00000000..efdf16a3
--- /dev/null
+++ b/Data/colours/colour.vowels2.dark.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/docs/C_API.md b/docs/C_API.md
index 0003436f..10e41430 100644
--- a/docs/C_API.md
+++ b/docs/C_API.md
@@ -438,6 +438,24 @@ void dasher_set_palette(dasher_ctx* ctx, const char* palette_name);
`dasher_get_palette_preview_colors` writes 4 ARGB preview colours into `out_colors` (must have room for 4 ints). Returns 0 on success.
+### Appearance / dark mode (RFC 0007)
+
+Palettes may declare an `appearance` (`light`/`dark`) and a `companion` (their opposite-appearance partner) in their XML. Use these to follow the OS light/dark preference without hardcoding name pairs per frontend.
+
+```c
+int dasher_get_palette_appearance(dasher_ctx* ctx, int index); // 0=unspecified, 1=light, 2=dark, -1=oor
+const char* dasher_find_companion_palette(dasher_ctx* ctx, const char* palette_name); // NULL if none
+int dasher_set_appearance(dasher_ctx* ctx, int appearance); // 1=light, 2=dark; 0 on success, -1 if no companion
+```
+
+`dasher_set_appearance` switches the current palette to its companion that matches the requested appearance; if the current palette already matches it is a no-op. If no companion is available it returns `-1` and leaves the palette unchanged (the user's explicit choice is respected). Companion lookup is bidirectional, so legacy palettes without metadata are still paired with a dark companion that names them.
+
+Typical frontend usage on an OS appearance change:
+
+```c
+dasher_set_appearance(ctx, is_dark ? 2 : 1);
+```
+
## Game Mode
```c
diff --git a/src/CAPI.cpp b/src/CAPI.cpp
index 8b24c5d1..26ba06a7 100644
--- a/src/CAPI.cpp
+++ b/src/CAPI.cpp
@@ -9,6 +9,7 @@
#include "DasherCore/XmlSettingsStore.h"
#include "DasherCore/FileUtils.h"
#include "DasherCore/ColorPalette.h"
+#include "DasherCore/ColorIO.h"
#include "DasherCore/GameModule.h"
#include "DasherCore/Alphabet/AlphInfo.h"
#include "DasherCore/Alphabet/AlphIO.h"
@@ -988,6 +989,88 @@ DASHER_API void dasher_set_palette(dasher_ctx* ctx, const char* palette_name) {
ctx->intf->SetStringParameter(Dasher::SP_COLOUR_ID, std::string(palette_name));
}
+// ── Appearance / dark mode (RFC 0007) ──────────────────────────────────────
+
+namespace {
+// Bidirectional companion lookup (RFC 0007). Returns the opposite-appearance
+// partner palette, or nullptr if none. If the palette declares an explicit
+// `companion`, that is used; otherwise we scan for a palette that declares this
+// one as its companion (so legacy palettes without metadata are still paired).
+const Dasher::ColorPalette* companionLookup(Dasher::CColorIO* colorIO, const std::string& name) {
+ if (!colorIO) return nullptr;
+ const Dasher::ColorPalette* p = colorIO->FindPalette(name);
+ if (!p || p->PaletteName != name) return nullptr; // FindPalette falls back to default
+
+ // Forward: explicit companion declared and resolvable.
+ if (!p->CompanionName.empty()) {
+ const Dasher::ColorPalette* q = colorIO->FindPalette(p->CompanionName);
+ if (q && q->PaletteName == p->CompanionName && q != p) return q;
+ }
+ // Reverse: some other palette declares this one as its companion.
+ const auto* all = colorIO->GetKnownPalettes();
+ for (const auto& [n, q] : *all) {
+ if (q == p) continue;
+ if (q->CompanionName == name) return q;
+ }
+ return nullptr;
+}
+
+// Effective appearance, treating an unspecified palette whose companion is dark
+// as effectively light (the common legacy-palette case).
+Dasher::ColorPalette::Appearance effectiveAppearance(Dasher::CColorIO* colorIO, const Dasher::ColorPalette* p) {
+ using App = Dasher::ColorPalette::Appearance;
+ if (!p) return App::Unspecified;
+ if (p->AppearanceValue != App::Unspecified) return p->AppearanceValue;
+ const Dasher::ColorPalette* comp = companionLookup(colorIO, p->PaletteName);
+ if (comp && comp->AppearanceValue == App::Dark) return App::Light;
+ return App::Unspecified;
+}
+} // namespace
+
+DASHER_API int dasher_get_palette_appearance(dasher_ctx* ctx, int index) {
+ if (!ctx || !ctx->intf) return -1;
+ auto colorIO = ctx->intf->GetColorIO();
+ if (!colorIO) return -1;
+ auto names = ctx->intf->GetPermittedValues(Dasher::SP_COLOUR_ID);
+ if (index < 0 || index >= static_cast(names.size())) return -1;
+ const Dasher::ColorPalette* p = colorIO->FindPalette(names[index]);
+ if (!p || p->PaletteName != names[index]) return 0; // not found -> unspecified
+ return static_cast(p->AppearanceValue);
+}
+
+DASHER_API const char* dasher_find_companion_palette(dasher_ctx* ctx, const char* palette_name) {
+ if (!ctx || !ctx->intf || !palette_name) return nullptr;
+ auto colorIO = ctx->intf->GetColorIO();
+ if (!colorIO) return nullptr;
+ const Dasher::ColorPalette* comp = companionLookup(colorIO, palette_name);
+ if (!comp) return nullptr;
+ ctx->tlString = comp->PaletteName;
+ return ctx->tlString.c_str();
+}
+
+DASHER_API int dasher_set_appearance(dasher_ctx* ctx, int appearance) {
+ if (!ctx || !ctx->intf) return -1;
+ if (appearance != 1 && appearance != 2) return -1;
+ using App = Dasher::ColorPalette::Appearance;
+ App target = (appearance == 1) ? App::Light : App::Dark;
+
+ auto colorIO = ctx->intf->GetColorIO();
+ if (!colorIO) return -1;
+ std::string currentName = ctx->intf->GetStringParameter(Dasher::SP_COLOUR_ID);
+ const Dasher::ColorPalette* current = colorIO->FindPalette(currentName);
+
+ // Candidate 1: the current palette already matches.
+ if (current && effectiveAppearance(colorIO, current) == target) return 0;
+
+ // Candidate 2: the current palette's companion matches.
+ const Dasher::ColorPalette* comp = companionLookup(colorIO, currentName);
+ if (comp && effectiveAppearance(colorIO, comp) == target) {
+ dasher_set_palette(ctx, comp->PaletteName.c_str());
+ return 0;
+ }
+ return -1; // no suitable companion; leave the current palette unchanged
+}
+
// ── Alphabets ──────────────────────────────────────────────────────────────
DASHER_API int dasher_get_alphabet_count(dasher_ctx* ctx) {
diff --git a/src/DasherCore/ColorIO.cpp b/src/DasherCore/ColorIO.cpp
index 61a64771..4b87f21c 100644
--- a/src/DasherCore/ColorIO.cpp
+++ b/src/DasherCore/ColorIO.cpp
@@ -174,8 +174,19 @@ bool CColorIO::Parse(pugi::xml_document& document, const std::string, bool bUser
std::string parentName = outer.attribute("parentName").as_string(HardcodedDefaultPalette->PaletteName.c_str());
std::string colorSchemeName = outer.attribute("name").as_string(); // definitely exists, we checked above
+ // RFC 0007: appearance classification + companion link (not colour values).
+ std::string appearanceStr = outer.attribute("appearance").as_string("");
+ std::string companionName = outer.attribute("companion").as_string("");
+ ColorPalette::Appearance appearance = ColorPalette::Appearance::Unspecified;
+ if (appearanceStr == "light")
+ appearance = ColorPalette::Appearance::Light;
+ else if (appearanceStr == "dark")
+ appearance = ColorPalette::Appearance::Dark;
+
for (pugi::xml_attribute attribute : outer.attributes()) {
- if (strcmp(attribute.name(), "parentName") == 0 || strcmp(attribute.name(), "name") == 0) continue;
+ if (strcmp(attribute.name(), "parentName") == 0 || strcmp(attribute.name(), "name") == 0 ||
+ strcmp(attribute.name(), "appearance") == 0 || strcmp(attribute.name(), "companion") == 0)
+ continue;
if (strcmp(attribute.name(), "uiPreviewColors") == 0) {
UIPreviewColors = GetAttributeAsColorList(attribute);
@@ -231,8 +242,8 @@ bool CColorIO::Parse(pugi::xml_document& document, const std::string, bool bUser
//"HardcodedDefault" is the parent for now, later on the parents get relinked by looking up the parentNames
auto it2 = KnownPalettes.find(colorSchemeName);
if (it2 != KnownPalettes.end()) delete it2->second;
- KnownPalettes[colorSchemeName] =
- new ColorPalette(HardcodedDefaultPalette, parentName, NamedColors, GroupColors, uiColorsArray, colorSchemeName);
+ KnownPalettes[colorSchemeName] = new ColorPalette(HardcodedDefaultPalette, parentName, NamedColors, GroupColors,
+ uiColorsArray, colorSchemeName, appearance, companionName);
return true;
}
diff --git a/src/DasherCore/ColorPalette.cpp b/src/DasherCore/ColorPalette.cpp
index b2ed5cc9..76c46a0a 100644
--- a/src/DasherCore/ColorPalette.cpp
+++ b/src/DasherCore/ColorPalette.cpp
@@ -60,9 +60,11 @@ ColorPalette::Color ColorPalette::Color::lerp(const Color& ColorA, const Color&
ColorPalette::ColorPalette(ColorPalette* ParentPalette, std::string ParentPaletteName,
const std::unordered_map& NamedColors,
const std::unordered_map& GroupColors,
- std::array UIPreviewColors, std::string PaletteName)
+ std::array UIPreviewColors, std::string PaletteName, Appearance appearance,
+ std::string companionName)
: ParentPalette(ParentPalette), ParentPaletteName(std::move(ParentPaletteName)), PaletteName(PaletteName),
- NamedColors(NamedColors), GroupColors(GroupColors), UIPreviewColors(UIPreviewColors) {}
+ AppearanceValue(appearance), CompanionName(std::move(companionName)), NamedColors(NamedColors),
+ GroupColors(GroupColors), UIPreviewColors(UIPreviewColors) {}
const ColorPalette::Color& ColorPalette::GetAltColor(const std::vector& NormalColors,
const std::vector& AltColors, bool useAlt,
@@ -88,6 +90,20 @@ const ColorPalette::Color& ColorPalette::GetNamedColor(const NamedColor::knownCo
}
const std::array& ColorPalette::GetUIPreviewColors() const {
+ // A palette parsed without explicit preview colours and with no suitable group
+ // is assigned the error sentinel (black/magenta). For inheritance-based palettes
+ // (e.g. dark companions with no groups of their own), fall back to the parent's
+ // preview so the picker shows the parent's representative colours. (RFC 0007)
+ static const std::array sentinel = {Color(0, 0, 0, 255), Color(255, 0, 255, 255), Color(0, 0, 0, 255),
+ Color(255, 0, 255, 255)};
+ bool isSentinel = true;
+ for (int i = 0; i < 4; i++) {
+ if (!(UIPreviewColors[i] == sentinel[i])) {
+ isSentinel = false;
+ break;
+ }
+ }
+ if (isSentinel && ParentPalette) return ParentPalette->GetUIPreviewColors();
return UIPreviewColors;
}
diff --git a/src/DasherCore/ColorPalette.h b/src/DasherCore/ColorPalette.h
index a80af037..db618792 100644
--- a/src/DasherCore/ColorPalette.h
+++ b/src/DasherCore/ColorPalette.h
@@ -44,6 +44,11 @@ static const knownColorName gameGuide = "gameGuideColor";
class ColorPalette {
public:
+ // Visual classification of a palette for system-appearance matching (RFC 0007).
+ // Unspecified palettes (e.g. legacy-format files without metadata) are still
+ // pairable via a bidirectional companion lookup.
+ enum class Appearance : int { Unspecified = 0, Light = 1, Dark = 2 };
+
typedef struct Color {
int Red = 0;
int Green = 0;
@@ -96,7 +101,8 @@ class ColorPalette {
ColorPalette(ColorPalette* ParentPalette, std::string ParentPaletteName,
const std::unordered_map& NamedColors,
const std::unordered_map& GroupColors,
- std::array UIPreviewColors, std::string PaletteName);
+ std::array UIPreviewColors, std::string PaletteName,
+ Appearance appearance = Appearance::Unspecified, std::string companionName = "");
const Color& GetAltColor(const std::vector& NormalColors, const std::vector& AltColors, bool useAlt,
int Index) const;
const Color& GetAltColor(const Color& NormalColor, const Color& AltColor, bool useAlt) const;
@@ -106,6 +112,9 @@ class ColorPalette {
std::string ParentPaletteName;
std::string PaletteName;
+ Appearance AppearanceValue = Appearance::Unspecified; // RFC 0007: light/dark classification
+ std::string CompanionName; // RFC 0007: opposite-appearance partner name
+
const Color& GetNamedColor(const NamedColor::knownColorName& NamedColor, bool AskParent = true) const;
const std::array& GetUIPreviewColors() const;
diff --git a/src/dasher.h b/src/dasher.h
index 2624ed9c..c834cd34 100644
--- a/src/dasher.h
+++ b/src/dasher.h
@@ -221,6 +221,33 @@ DASHER_API int dasher_get_palette_preview_colors(dasher_ctx* ctx, int index, int
// Set the active colour palette by name.
DASHER_API void dasher_set_palette(dasher_ctx* ctx, const char* palette_name);
+// ── Appearance / dark mode (RFC 0007) ─────────────────────────────────────
+//
+// Palettes may declare an `appearance` ("light" or "dark") and a `companion`
+// (the name of their opposite-appearance partner) in their XML. Frontends can
+// use these to follow the OS light/dark preference by switching to the current
+// palette's companion, without each frontend hardcoding name pairs.
+//
+// Legacy palettes without metadata report appearance 0 (unspecified) but are
+// still paired via a bidirectional companion lookup: if palette A declares
+// companion="B", then B's effective companion is A.
+
+// Get the appearance classification of a palette.
+// Returns: 0 = unspecified, 1 = light, 2 = dark, -1 = index out of range.
+DASHER_API int dasher_get_palette_appearance(dasher_ctx* ctx, int index);
+
+// Find the companion (opposite-appearance) palette for the given palette name.
+// Lookup is bidirectional (see above). Returns the companion name (valid until
+// the next API call), or NULL if the palette has no companion.
+DASHER_API const char* dasher_find_companion_palette(dasher_ctx* ctx, const char* palette_name);
+
+// Switch to the companion of the current palette that matches the requested
+// appearance. appearance: 1 = light, 2 = dark.
+// If the current palette already matches, this is a no-op and returns 0.
+// Returns 0 on success, -1 if no suitable companion is available (in which case
+// the current palette is left unchanged — the user's explicit choice is respected).
+DASHER_API int dasher_set_appearance(dasher_ctx* ctx, int appearance);
+
// ── Alphabets ─────────────────────────────────────────────────────────────
// Get the number of available alphabets.
diff --git a/tests/test_color_math.cpp b/tests/test_color_math.cpp
index 5d915d62..a981e191 100644
--- a/tests/test_color_math.cpp
+++ b/tests/test_color_math.cpp
@@ -111,6 +111,90 @@ TEST(color_palette_switch) {
printf("v color_palette_switch passed\n");
}
+TEST(color_palette_appearance_classification) {
+ dasher_ctx* ctx = create_isolated_context();
+ ASSERT(ctx);
+ dasher_set_screen_size(ctx, 800, 600);
+
+ int palette_count = dasher_get_palette_count(ctx);
+ ASSERT(palette_count > 0);
+
+ // Locate "Rainbow" (legacy light, no metadata) and "Rainbow Dark" (modern, dark).
+ int rainbow_idx = -1, rainbow_dark_idx = -1;
+ for (int i = 0; i < palette_count; i++) {
+ const char* name = dasher_get_palette_name(ctx, i);
+ if (strcmp(name, "Rainbow") == 0) rainbow_idx = i;
+ if (strcmp(name, "Rainbow Dark") == 0) rainbow_dark_idx = i;
+ }
+ ASSERT_NEQ(rainbow_idx, -1);
+ ASSERT_NEQ(rainbow_dark_idx, -1);
+
+ // Legacy Rainbow carries no metadata -> unspecified (0).
+ ASSERT_EQ(dasher_get_palette_appearance(ctx, rainbow_idx), 0);
+ // Rainbow Dark declares appearance="dark" -> 2.
+ ASSERT_EQ(dasher_get_palette_appearance(ctx, rainbow_dark_idx), 2);
+ // Out-of-range index -> -1.
+ ASSERT_EQ(dasher_get_palette_appearance(ctx, palette_count), -1);
+
+ dasher_destroy(ctx);
+ printf("v color_palette_appearance_classification passed\n");
+}
+
+TEST(color_palette_companion_lookup) {
+ dasher_ctx* ctx = create_isolated_context();
+ ASSERT(ctx);
+ dasher_set_screen_size(ctx, 800, 600);
+
+ // Bidirectional: Rainbow (legacy, no metadata) resolves to Rainbow Dark via the
+ // reverse scan; Rainbow Dark resolves to Rainbow via its explicit companion.
+ const char* comp_of_rainbow = dasher_find_companion_palette(ctx, "Rainbow");
+ ASSERT(comp_of_rainbow);
+ ASSERT_STR_EQ(comp_of_rainbow, "Rainbow Dark");
+
+ const char* comp_of_dark = dasher_find_companion_palette(ctx, "Rainbow Dark");
+ ASSERT(comp_of_dark);
+ ASSERT_STR_EQ(comp_of_dark, "Rainbow");
+
+ // A palette with no companion (Yellow on Blue is inherently dark) returns NULL.
+ const char* none = dasher_find_companion_palette(ctx, "Yellow on Blue");
+ ASSERT(none == nullptr);
+
+ dasher_destroy(ctx);
+ printf("v color_palette_companion_lookup passed\n");
+}
+
+TEST(color_palette_set_appearance) {
+ dasher_ctx* ctx = create_isolated_context();
+ ASSERT(ctx);
+ dasher_set_screen_size(ctx, 800, 600);
+
+ dasher_set_palette(ctx, "Rainbow");
+ ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Rainbow");
+
+ // Request dark -> switch to Rainbow Dark.
+ ASSERT_EQ(dasher_set_appearance(ctx, 2), 0);
+ ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Rainbow Dark");
+
+ // Requesting dark again is a no-op (already on the dark variant).
+ ASSERT_EQ(dasher_set_appearance(ctx, 2), 0);
+ ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Rainbow Dark");
+
+ // Request light -> switch back to Rainbow.
+ ASSERT_EQ(dasher_set_appearance(ctx, 1), 0);
+ ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Rainbow");
+
+ // A palette with no companion cannot switch -> -1, and is left unchanged.
+ dasher_set_palette(ctx, "Yellow on Blue");
+ ASSERT_EQ(dasher_set_appearance(ctx, 1), -1);
+ ASSERT_STR_EQ(dasher_get_current_palette(ctx), "Yellow on Blue");
+
+ // Invalid appearance value -> -1.
+ ASSERT_EQ(dasher_set_appearance(ctx, 9), -1);
+
+ dasher_destroy(ctx);
+ printf("v color_palette_set_appearance passed\n");
+}
+
int main() {
printf("Running color math tests...\n\n");
@@ -122,6 +206,9 @@ int main() {
test_color_round_trip_many();
test_color_palette_preview_nonempty();
test_color_palette_switch();
+ test_color_palette_appearance_classification();
+ test_color_palette_companion_lookup();
+ test_color_palette_set_appearance();
printf("\nAll color math tests passed!\n");
return 0;