diff --git a/.github/workflows/_build-ios-port.yml b/.github/workflows/_build-ios-port.yml index 0558bd8cf5..ac40f4521d 100644 --- a/.github/workflows/_build-ios-port.yml +++ b/.github/workflows/_build-ios-port.yml @@ -68,7 +68,7 @@ jobs: id: src_hash run: | set -euo pipefail - SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes \ + SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/ios-packaging.yml b/.github/workflows/ios-packaging.yml index 4b35730fc3..1db554a0e3 100644 --- a/.github/workflows/ios-packaging.yml +++ b/.github/workflows/ios-packaging.yml @@ -85,7 +85,7 @@ jobs: id: src_hash run: | set -euo pipefail - SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes \ + SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index abac1b2717..95f198b8ff 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -22,6 +22,8 @@ name: Test Android build scripts - '!CodenameOne/src/**/*.md' - 'Ports/Android/**' - '!Ports/Android/**/*.md' + - 'native-themes/android-material/**' + - '!native-themes/android-material/**/*.md' - 'maven/**' - '!maven/core-unittests/**' - 'tests/**' @@ -49,6 +51,8 @@ name: Test Android build scripts - '!CodenameOne/src/**/*.md' - 'Ports/Android/**' - '!Ports/Android/**/*.md' + - 'native-themes/android-material/**' + - '!native-themes/android-material/**/*.md' - 'maven/**' - '!maven/core-unittests/**' - 'tests/**' diff --git a/.github/workflows/scripts-ios-native.yml b/.github/workflows/scripts-ios-native.yml index 5fc0b56aac..0c1ec5ddb5 100644 --- a/.github/workflows/scripts-ios-native.yml +++ b/.github/workflows/scripts-ios-native.yml @@ -20,6 +20,8 @@ on: - '!CodenameOne/src/**/*.md' - 'Ports/iOSPort/**' - '!Ports/iOSPort/**/*.md' + - 'native-themes/ios-modern/**' + - '!native-themes/ios-modern/**/*.md' - 'vm/**' - '!vm/**/*.md' - 'tests/**' @@ -47,6 +49,8 @@ on: - '!CodenameOne/src/**/*.md' - 'Ports/iOSPort/**' - '!Ports/iOSPort/**/*.md' + - 'native-themes/ios-modern/**' + - '!native-themes/ios-modern/**/*.md' - 'vm/**' - '!vm/**/*.md' - 'tests/**' @@ -108,7 +112,7 @@ jobs: id: src_hash run: | set -euo pipefail - SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes \ + SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index eaba9af79a..6a3697f9a7 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -23,6 +23,8 @@ on: - '!CodenameOne/src/**/*.md' - 'Ports/iOSPort/**' - '!Ports/iOSPort/**/*.md' + - 'native-themes/ios-modern/**' + - '!native-themes/ios-modern/**/*.md' - 'vm/**' - '!vm/**/*.md' - 'tests/**' @@ -53,6 +55,8 @@ on: - '!CodenameOne/src/**/*.md' - 'Ports/iOSPort/**' - '!Ports/iOSPort/**/*.md' + - 'native-themes/ios-modern/**' + - '!native-themes/ios-modern/**/*.md' - 'vm/**' - '!vm/**/*.md' - 'tests/**' @@ -120,7 +124,7 @@ jobs: id: src_hash run: | set -euo pipefail - SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes \ + SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ @@ -269,7 +273,7 @@ jobs: id: src_hash run: | set -euo pipefail - SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes \ + SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index bf15bd88da..1680a61713 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -1802,6 +1802,8 @@ private void buildTheme(Hashtable themeProps) { this.themeProps.put(key, themeProps.get(key)); } + applyThemeBindings(); + updateLargerTextScaleSettingFromTheme(); if (!this.themeProps.containsKey("PickerButtonBar.derive")) { @@ -1863,6 +1865,99 @@ private void buildTheme(Hashtable themeProps) { } + /// Theme entries can be bound to a named theme constant via a + /// `@cn1-bind:<themeKey>=<varName>` pseudo-constant emitted by the CSS + /// compiler when it expands a `var(--name, fallback)` reference. The + /// compiler still inlines `fallback` as the baked-in default (so themes + /// load correctly with no override), but additionally records that the + /// resolved style property tracks `--name`. + /// + /// At runtime, callers tune the palette by injecting an `@<varName>` + /// constant via [#addThemeProps]. This method walks the binding entries + /// and overlays the override value onto every bound style key, so a + /// single `addThemeProps({"@accent-color": "ff2d95"})` call retunes + /// every UIID whose CSS rule referenced `var(--accent-color, ...)`. + /// Bindings without a matching override are left at their baked-in + /// default (whatever was already in themeProps from the initial load). + private void applyThemeBindings() { + if (themeConstants == null || themeConstants.isEmpty() || themeProps == null) { + return; + } + final String prefix = "cn1-bind:"; + for (Map.Entry entry : themeConstants.entrySet()) { + String constantKey = entry.getKey(); + if (constantKey == null || !constantKey.startsWith(prefix)) { + continue; + } + Object varNameObj = entry.getValue(); + if (!(varNameObj instanceof String)) { + continue; + } + String varName = ((String) varNameObj).trim(); + if (varName.length() == 0) { + continue; + } + Object override = themeConstants.get(varName); + if (!(override instanceof String)) { + continue; + } + String themeKey = constantKey.substring(prefix.length()); + if (themeKey.length() == 0) { + continue; + } + // Only retune keys that are already present in themeProps so a + // stale binding entry (left over after the bound rule was + // dropped from the source CSS) can't materialize a phantom + // style key from the user's override value. + if (!themeProps.containsKey(themeKey)) { + continue; + } + String overrideValue = (String) override; + if (themeKey.endsWith("Color")) { + overrideValue = normalizeBoundColorValue(overrideValue); + if (overrideValue == null) { + continue; + } + } + themeProps.put(themeKey, overrideValue); + } + } + + /// `loadTheme` stores color theme entries as plain hex strings (no `#`, + /// lowercase). User-supplied overrides may use either form, so trim a + /// leading `#` and lowercase the value before assigning it to a bound + /// color key. Returns null when the value can't be parsed as a 3- or + /// 6-digit hex color so the binding falls through to its default. + private static String normalizeBoundColorValue(String raw) { + if (raw == null) { + return null; + } + String value = raw.trim(); + if (value.length() == 0) { + return null; + } + if (value.charAt(0) == '#') { + value = value.substring(1); + } + if (value.length() == 3) { + char r = value.charAt(0); + char g = value.charAt(1); + char b = value.charAt(2); + value = "" + r + r + g + g + b + b; + } + if (value.length() != 6) { + return null; + } + for (int i = 0; i < 6; i++) { + char c = value.charAt(i); + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + return null; + } + } + return value.toLowerCase(); + } + private Map parseCache() { if (parseCache == null) { parseCache = new HashMap(); diff --git a/docs/developer-guide/Native-Themes.asciidoc b/docs/developer-guide/Native-Themes.asciidoc index 56bd7ffa9d..af4a5786e9 100644 --- a/docs/developer-guide/Native-Themes.asciidoc +++ b/docs/developer-guide/Native-Themes.asciidoc @@ -149,8 +149,13 @@ inherits from it. |=== To rebrand the app, override the colour at the role level rather -than touching every UIID. For example, to flip the primary -container palette to teal: +than touching every UIID. The example below layers a teal palette +on top from your app's own `theme.css`; if you want the same +rebrand to apply at *runtime* (for in-app accent toggles, branded +flavours, A/B tests) without recompiling the theme, see +`Runtime accent palette override` further down - a single +`addThemeProps({"@accent-color": ...})` call retunes every +accent-bearing UIID at once. [source,css] ---- @@ -242,25 +247,167 @@ accent-driven UIIDs the same way as Android above. The colour names match Apple's `UIColor.systemBlue` etc. so you can mirror the SF Symbols semantics if you want. -=== Runtime palette override +=== Accent palette override + +Both shipped native themes expose their accent palette as named +constants you can retune from your app's own `theme.css` (or, for +dynamic theming, at runtime). The CSS source uses +`var(--accent-color, fallback)` references so the fallback ships as +the baked-in default (the .res file loads fine with no override) +and the compiler additionally emits a +`@cn1-bind:.=accent-color` constant alongside each +affected style key. When the resolved theme constants pick up a new +`@accent-color` value (whether from your CSS or via runtime +`UIManager.addThemeProps`), the framework fans the override out to +every bound UIID at once - no per-UIID rule duplication, no theme +recompile. + +.iOS modern (`native-themes/ios-modern/theme.css`) +[cols="2,1,1,3", options="header"] +|=== +|Constant |Light default |Dark default |Drives + +|`@accent-color` +|`#007aff` +|(see `@accent-color-dark`) +|`Button.fgColor`, `RaisedButton.bgColor`, +`CheckBox.selected`, `RadioButton.selected`, `OnOffSwitch.fgColor`, +`BackCommand`, `TitleCommand`, `FloatingActionButton.bgColor`. -Push a Hashtable of theme props through `UIManager.addThemeProps` -to flip the palette live, without recompiling the theme: +|`@accent-color-dark` +|n/a +|`#0a84ff` +|`$Dark` counterparts of the above. + +|`@accent-pressed-color` / `@accent-pressed-color-dark` +|`#0064d1` +|`#64b1ff` +|All `.press#fg/bgColor` accent overrides. + +|`@accent-disabled-color` / `@accent-disabled-color-dark` +|`#b3d4ff` +|`#004a99` +|All `.dis#fg/bgColor` accent overrides. + +|`@accent-on-color` +|`#ffffff` +|(same) +|Text colour painted on top of the accent fill. +|=== + +.Android Material 3 (`native-themes/android-material/theme.css`) +[cols="2,1,1,2", options="header"] +|=== +|Constant |Light default |Dark default |Material 3 token + +|`@accent-color` / `@accent-color-dark` +|`#6750a4` +|`#d0bcff` +|`primary` + +|`@accent-on-color` / `@accent-on-color-dark` +|`#ffffff` +|`#381e72` +|`on-primary` + +|`@accent-container-color` / `@accent-container-color-dark` +|`#eaddff` +|`#4f378b` +|`primary-container` + +|`@accent-on-container-color` / `@accent-on-container-color-dark` +|`#21005d` +|`#eaddff` +|`on-primary-container` + +|`@accent-pressed-color` / `@accent-pressed-color-dark` +|`#d0bcff` +|`#4f378b` +|`state-pressed` +|=== + +==== Override from your app's `theme.css` (recommended) + +Redeclare the accent variable inside the `#Constants` block of your +app's `theme.css`. The framework's CSS compiler exports any +`--name` declaration in `#Constants` as a `@name` theme constant, +so the value flows through the same binding pass that handles +runtime overrides: + +[source,css] +---- +#Constants { + includeNativeBool: true; + darkModeBool: true; + + /* Override the native theme's accent palette. The compiler picks + up these declarations from #Constants and exports them as + @accent-color / @accent-color-dark theme constants so every + UIID bound to var(--accent-color) in the parent native theme + picks them up at app launch - no per-UIID rule edit needed. */ + --accent-color: #ff2d95; + --accent-color-dark: #ff2d95; + --accent-pressed-color: #c71a75; + --accent-pressed-color-dark: #c71a75; + --accent-on-color: #ffffff; + + /* Material 3 RaisedButton uses a separate "container" tonal + pair; iOS ignores these (no bindings reference them) so it's + safe to set them unconditionally. */ + --accent-container-color: #ff2d95; + --accent-container-color-dark: #ff2d95; + --accent-on-container-color: #ffffff; + --accent-on-container-color-dark: #ffffff; +} +---- + +Bindings that reference a constant you did not override stay at +their baked-in default, so a partial override (e.g. just +`--accent-color`) is fine. + +==== Runtime override + +For dynamic theming - in-app accent toggles, branded flavours, +A/B tests - push the same constants through +`UIManager.addThemeProps` after the theme has been installed: [source,java] ---- Hashtable override = new Hashtable(); -override.put("RaisedButton.bgColor", "d81b60"); -override.put("RaisedButton.sel#bgColor", "b71c5c"); -override.put("RaisedButton.press#bgColor", "ad1457"); -override.put("BackCommand.fgColor", "d81b60"); +override.put("@accent-color", "ff2d95"); +override.put("@accent-color-dark", "ff2d95"); +override.put("@accent-pressed-color", "c71a75"); +override.put("@accent-pressed-color-dark", "c71a75"); +override.put("@accent-on-color", "ffffff"); +override.put("@accent-container-color", "ff2d95"); +override.put("@accent-container-color-dark", "ff2d95"); +override.put("@accent-on-container-color", "ffffff"); +override.put("@accent-on-container-color-dark", "ffffff"); UIManager.getInstance().addThemeProps(override); Form.getCurrentForm().refreshTheme(); ---- -Common cases are demonstrated by the `PaletteOverrideThemeScreenshotTest` -in the hellocodenameone test suite, which flips the primary accent -to magenta at runtime and re-renders the same form. +Values can be passed with or without the leading `#` and in any +case; the runtime accepts `"ff2d95"`, `"#FF2D95"`, and the 3-digit +shorthand `"#f0a"` interchangeably. + +`PaletteOverrideThemeScreenshotTest` in the hellocodenameone test +suite exercises this path against both native themes, light + dark. + +==== When the override path doesn't apply + +The binding mechanism handles every accent UIID the shipped themes +expose. For other widgets - or when you want to override a colour +the native CSS hard-codes (e.g. the iOS `success` green on +`Switch.selected`) - either: + +* Layer a per-UIID redeclaration in your app's `theme.css` (see + `Customising in your own theme` below). Right when you want to + tweak a UIID the binding vocabulary doesn't already cover. +* Or pass the specific UIID/state key directly into + `UIManager.addThemeProps` (`Switch.sel#bgColor` etc.). Runtime, + bypasses the binding system. Right when you need a one-off colour + tweak you can't anchor in CSS. === Platform-specific UIIDs @@ -384,6 +531,15 @@ RaisedButton.disabled { background-color: #ffd6e2; color: #ffffff; } The user's CSS is layered on top of the native theme at app launch, so refresh / restart picks the override up. +For a wholesale accent rebrand prefer redeclaring the +`--accent-color` (etc.) variables in your `#Constants` block - see +`Override from your app's theme.css` above. That single declaration +fans out through every UIID bound to the variable in the native +theme, no per-UIID rules required. Per-UIID redeclaration here +remains the right choice for tweaking non-accent properties +(typography, spacing, surface fills) the binding vocabulary doesn't +cover. + === Inheriting from a native UIID `cn1-derive` lets a custom UIID start from one of the native diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/NativeThemeBindingsTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/NativeThemeBindingsTest.java new file mode 100644 index 0000000000..10185459b3 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/NativeThemeBindingsTest.java @@ -0,0 +1,142 @@ +package com.codename1.ui.plaf; + +import com.codename1.junit.UITestBase; +import com.codename1.ui.Button; +import com.codename1.ui.util.Resources; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.Hashtable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * End-to-end check: load the shipped iOS Modern native theme `.res`, + * verify the var() bindings the CSS compiler emitted survived the + * round-trip, and that pushing an `@accent-color` override through + * {@link UIManager#addThemeProps(Hashtable)} retunes a Button's + * fgColor without touching `Button.fgColor` directly. + * + * Loads the `.res` straight from the repo's `Themes/` build output + * (next to where `scripts/build-native-themes.sh` writes it). When + * that file is absent the test is silently skipped rather than failed + * - the runtime-side binding logic is already covered by + * {@link UIManagerThemeBindingsTest}, so this test only fires when a + * fresh native theme is sitting on disk. + */ +public class NativeThemeBindingsTest extends UITestBase { + + @Test + public void iosModernThemeBindingRetunesButton() throws Exception { + File themeFile = locateNativeTheme("iOSModernTheme.res"); + if (themeFile == null) { + return; + } + Resources res; + InputStream stream = new FileInputStream(themeFile); + try { + res = Resources.open(stream); + } finally { + stream.close(); + } + String[] themeNames = res.getThemeResourceNames(); + assertNotNull(themeNames); + Hashtable theme = res.getTheme(themeNames[0]); + assertNotNull(theme); + + // Compiler should have emitted both the baked-in default AND + // the binding entry. Resources.loadTheme stores colors as the + // unpadded hex of their int value (Integer.toHexString), so the + // expected default is "7aff" rather than "007aff". + assertEquals("7aff", theme.get("Button.fgColor")); + assertEquals("accent-color", theme.get("@cn1-bind:Button.fgColor")); + // `#Constants { --accent-color: #007aff; }` in the native + // theme.css is exported as a `@accent-color` theme constant so + // a user app's theme.css can override it via the same syntax. + assertEquals("007AFF", theme.get("@accent-color")); + + UIManager.getInstance().setThemeProps(theme); + + Button defaultBtn = new Button("default"); + defaultBtn.setUIID("Button"); + assertEquals(0x007aff, defaultBtn.getUnselectedStyle().getFgColor(), + "Native theme button should pick up the inlined fallback when no override is supplied"); + + Hashtable override = new Hashtable(); + override.put("@accent-color", "ff2d95"); + UIManager.getInstance().addThemeProps(override); + + Button retuned = new Button("magenta"); + retuned.setUIID("Button"); + assertEquals(0xff2d95, retuned.getUnselectedStyle().getFgColor(), + "@accent-color override should retune every UIID bound to --accent-color"); + } + + @Test + public void androidMaterialThemeBindingRetunesButton() throws Exception { + File themeFile = locateNativeTheme("AndroidMaterialTheme.res"); + if (themeFile == null) { + return; + } + Resources res; + InputStream stream = new FileInputStream(themeFile); + try { + res = Resources.open(stream); + } finally { + stream.close(); + } + String[] themeNames = res.getThemeResourceNames(); + assertNotNull(themeNames); + Hashtable theme = res.getTheme(themeNames[0]); + assertNotNull(theme); + + // M3 baseline primary is #6750a4 → "6750a4" when stored via + // Integer.toHexString. + assertEquals("6750a4", theme.get("Button.bgColor")); + assertEquals("accent-color", theme.get("@cn1-bind:Button.bgColor")); + // Native theme.css declares `#Constants { --accent-color: #6750a4; }` + // and the Flute compiler now exports that as a `@accent-color` + // theme constant in addition to the parser-internal var() lookup. + // This is what lets a user app's theme.css redeclare + // `#Constants { --accent-color: #ff2d95; }` and have it propagate + // through the runtime binding pass to every UIID bound to + // --accent-color in this parent theme. + assertEquals("6750A4", theme.get("@accent-color")); + + UIManager.getInstance().setThemeProps(theme); + + Button defaultBtn = new Button("default"); + defaultBtn.setUIID("Button"); + assertEquals(0x6750a4, defaultBtn.getUnselectedStyle().getBgColor(), + "Android M3 button bg should pick up the inlined fallback when no override is supplied"); + + Hashtable override = new Hashtable(); + override.put("@accent-color", "ff2d95"); + UIManager.getInstance().addThemeProps(override); + + Button retuned = new Button("magenta"); + retuned.setUIID("Button"); + assertEquals(0xff2d95, retuned.getUnselectedStyle().getBgColor(), + "@accent-color override should retune Button.bgColor on the Android Material 3 theme"); + } + + /// Searches a few well-known relative locations for a freshly-built + /// native theme `.res`. We surfboard up a few directory levels + /// because the surefire `user.dir` lands inside the unittests + /// module while the build output lives at the repo root's + /// `Themes/`. + private static File locateNativeTheme(String fileName) { + File cwd = new File(".").getAbsoluteFile(); + for (int i = 0; i < 6 && cwd != null; i++) { + File candidate = new File(cwd, "Themes/" + fileName); + if (candidate.isFile()) { + return candidate; + } + cwd = cwd.getParentFile(); + } + return null; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerThemeBindingsTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerThemeBindingsTest.java new file mode 100644 index 0000000000..0927cc0a1a --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerThemeBindingsTest.java @@ -0,0 +1,129 @@ +package com.codename1.ui.plaf; + +import com.codename1.junit.UITestBase; +import com.codename1.ui.Button; +import org.junit.jupiter.api.Test; + +import java.util.Hashtable; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies the runtime theme-binding pass installed in + * {@link UIManager#buildTheme} resolves `@cn1-bind:<themeKey>=<varname>` + * entries against the live theme constants and overlays the resulting + * value onto the bound theme key. + * + * The CSS compiler emits the binding entries when it expands a + * `var(--name, fallback)` reference - the fallback is inlined as the + * baked-in default and the binding survives in the .res file as a + * `@cn1-bind:Button.fgColor=name` constant. At app launch the user + * passes {@link UIManager#addThemeProps(Hashtable)} a single + * `@accent-color` override and every bound UIID picks it up without + * having to redeclare per-UIID rules. + */ +public class UIManagerThemeBindingsTest extends UITestBase { + + @Test + public void boundThemeKeyKeepsDefaultWithoutOverride() { + Hashtable theme = new Hashtable(); + theme.put("Button.fgColor", "007aff"); + theme.put("@cn1-bind:Button.fgColor", "accent-color"); + + UIManager.getInstance().setThemeProps(theme); + + Button b = new Button("default"); + b.setUIID("Button"); + assertEquals(0x007aff, b.getUnselectedStyle().getFgColor()); + } + + @Test + public void boundThemeKeyPicksUpAccentOverride() { + Hashtable theme = new Hashtable(); + theme.put("Button.fgColor", "007aff"); + theme.put("RaisedButton.bgColor", "007aff"); + theme.put("@cn1-bind:Button.fgColor", "accent-color"); + theme.put("@cn1-bind:RaisedButton.bgColor", "accent-color"); + UIManager.getInstance().setThemeProps(theme); + + Hashtable override = new Hashtable(); + override.put("@accent-color", "ff2d95"); + UIManager.getInstance().addThemeProps(override); + + Button b = new Button("themed"); + b.setUIID("Button"); + assertEquals(0xff2d95, b.getUnselectedStyle().getFgColor()); + + Button raised = new Button("raised"); + raised.setUIID("RaisedButton"); + assertEquals(0xff2d95, raised.getUnselectedStyle().getBgColor()); + } + + @Test + public void overrideAcceptsHashPrefixAndCaseVariants() { + Hashtable theme = new Hashtable(); + theme.put("Button.fgColor", "007aff"); + theme.put("@cn1-bind:Button.fgColor", "accent-color"); + UIManager.getInstance().setThemeProps(theme); + + Hashtable override = new Hashtable(); + override.put("@accent-color", "#FF2D95"); + UIManager.getInstance().addThemeProps(override); + + Button b = new Button(); + b.setUIID("Button"); + assertEquals(0xff2d95, b.getUnselectedStyle().getFgColor()); + } + + @Test + public void shorthand3DigitOverrideExpandsTo6Digits() { + Hashtable theme = new Hashtable(); + theme.put("Button.fgColor", "007aff"); + theme.put("@cn1-bind:Button.fgColor", "accent-color"); + UIManager.getInstance().setThemeProps(theme); + + Hashtable override = new Hashtable(); + override.put("@accent-color", "#f0a"); + UIManager.getInstance().addThemeProps(override); + + Button b = new Button(); + b.setUIID("Button"); + assertEquals(0xff00aa, b.getUnselectedStyle().getFgColor()); + } + + @Test + public void unboundThemeKeyIsNotMaterializedFromOverride() { + Hashtable theme = new Hashtable(); + theme.put("Button.fgColor", "007aff"); + // Stale binding referencing a key the theme does not actually + // emit - the runtime must NOT create RaisedButton.fgColor out of + // thin air when the user supplies an @accent-color override. + theme.put("@cn1-bind:RaisedButton.fgColor", "accent-color"); + UIManager.getInstance().setThemeProps(theme); + + Hashtable override = new Hashtable(); + override.put("@accent-color", "ff2d95"); + UIManager.getInstance().addThemeProps(override); + + // Button stays at the baked-in default (no binding). + Button b = new Button(); + b.setUIID("Button"); + assertEquals(0x007aff, b.getUnselectedStyle().getFgColor()); + } + + @Test + public void invalidColorOverrideLeavesDefaultIntact() { + Hashtable theme = new Hashtable(); + theme.put("Button.fgColor", "007aff"); + theme.put("@cn1-bind:Button.fgColor", "accent-color"); + UIManager.getInstance().setThemeProps(theme); + + Hashtable override = new Hashtable(); + override.put("@accent-color", "not-a-color"); + UIManager.getInstance().addThemeProps(override); + + Button b = new Button(); + b.setUIID("Button"); + assertEquals(0x007aff, b.getUnselectedStyle().getFgColor()); + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index 20c0b5d7b0..af7b4f5167 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -883,14 +883,21 @@ private static class ScaledUnit implements LexicalUnit { CN1Gradient gradient; LexicalUnit next, prev, param; - - + + /// Set to the originating CSS variable name (no `--` prefix) when this + /// unit is the result of expanding a `var(--name, fallback)` reference. + /// `evaluate()` carries it down so `apply()` can stash a runtime binding + /// on the owning Element, letting `updateResources()` emit a + /// `@cn1-bind:<themeKey>=<name>` constant alongside the resolved + /// default value. Null on units that did not originate from a var(). + String bindingVarName; + ScaledUnit(LexicalUnit src, double dpi, int screenWidth, int screenHeight) { this.src = src; this.dpi = dpi; this.screenWidth = screenWidth; this.screenHeight = screenHeight; - + } @@ -1690,6 +1697,28 @@ private boolean isOwnedBy(String key, String id) { return key.indexOf('.', id.length() + 1) == -1; } + /// Emits a `@cn1-bind:<themeKey>=<varName>` constant when the source + /// Element has a `var()` binding for the given CSS property. This lets + /// the runtime UIManager retune every UIID that referenced the same + /// `--name` constant via a single `addThemeProps({"@name": value})` + /// call without forcing the theme to be recompiled. + /// + /// `stateEl` is the per-state Element (Unselected / Selected / Pressed + /// / Disabled). Its `resolveBinding` walks up to the parent UIID + /// Element so a base `Button { color: var(--accent) }` rule still + /// emits a binding for the per-state `Button.press#fgColor` even when + /// `Button.pressed` did not redeclare `color`. + private void emitColorBinding(EditableResources res, String themeName, String themeKey, Element stateEl, String cssProperty) { + if (stateEl == null) { + return; + } + String varName = stateEl.resolveBinding(cssProperty); + if (varName == null || varName.length() == 0) { + return; + } + res.setThemeProperty(themeName, "@cn1-bind:" + themeKey, varName); + } + public void updateResources() { if (res != null) { Map themeData = res.getTheme(themeName); @@ -1840,12 +1869,16 @@ public void updateResources() { currToken = "fgColor"; res.setThemeProperty(themeName, unselId+".fgColor", el.getThemeFgColor(unselectedStyles)); + emitColorBinding(res, themeName, unselId+".fgColor", el.getUnselected(), "color"); currToken = "selected fgColor"; res.setThemeProperty(themeName, selId+"#fgColor", el.getThemeFgColor(selectedStyles)); + emitColorBinding(res, themeName, selId+"#fgColor", el.getSelected(), "color"); currToken = "pressed fgColor"; res.setThemeProperty(themeName, pressedId+"#fgColor", el.getThemeFgColor(pressedStyles)); + emitColorBinding(res, themeName, pressedId+"#fgColor", el.getPressed(), "color"); currToken = "disabled fgColor"; res.setThemeProperty(themeName, disabledId+"#fgColor", el.getThemeFgColor(disabledStyles)); + emitColorBinding(res, themeName, disabledId+"#fgColor", el.getDisabled(), "color"); currToken = "fgAlpha"; res.setThemeProperty(themeName, unselId+".fgAlpha", el.getThemeFgAlpha(unselectedStyles)); @@ -1858,12 +1891,16 @@ public void updateResources() { currToken = "bgColor"; res.setThemeProperty(themeName, unselId+".bgColor", el.getThemeBgColor(unselectedStyles)); + emitColorBinding(res, themeName, unselId+".bgColor", el.getUnselected(), "background-color"); currToken = "selected bgColor"; res.setThemeProperty(themeName, selId+"#bgColor", el.getThemeBgColor(selectedStyles)); + emitColorBinding(res, themeName, selId+"#bgColor", el.getSelected(), "background-color"); currToken = "pressed bgColor"; res.setThemeProperty(themeName, pressedId+"#bgColor", el.getThemeBgColor(pressedStyles)); + emitColorBinding(res, themeName, pressedId+"#bgColor", el.getPressed(), "background-color"); currToken = "disabled bgColor"; res.setThemeProperty(themeName, disabledId+"#bgColor", el.getThemeBgColor(disabledStyles)); + emitColorBinding(res, themeName, disabledId+"#bgColor", el.getDisabled(), "background-color"); currToken = "transparency"; res.setThemeProperty(themeName, unselId+".transparency", el.getThemeTransparency(unselectedStyles)); @@ -2017,6 +2054,22 @@ public void updateResources() { } } else if (lu.getLexicalUnitType() == LexicalUnit.SAC_INTEGER) { res.setThemeProperty(themeName, "@"+constantKey, String.valueOf(((ScaledUnit)lu).getIntegerValue())); + } else if (lu.getLexicalUnitType() == LexicalUnit.SAC_RGBCOLOR + || (lu.getLexicalUnitType() == LexicalUnit.SAC_FUNCTION + && ("rgb".equals(lu.getFunctionName()) + || "rgba".equals(lu.getFunctionName()) + || "cn1rgb".equals(lu.getFunctionName()) + || "cn1rgba".equals(lu.getFunctionName())))) { + // Hex / rgb() / rgba() color literals declared in + // #Constants. Stored as a plain hex string (no `#`, + // lowercase, 6 chars) which is the format + // UIManager's themeConstants and the runtime + // applyThemeBindings pass expect for color values. + // Lets a user theme.css declare e.g. + // `#Constants { --accent-color: #ff2d95; }` and have + // it propagate to every UIID bound to --accent-color + // in the parent native theme. + res.setThemeProperty(themeName, "@"+constantKey, getColorString(lu)); } } catch (RuntimeException t) { System.err.println("\nAn error occurred processing constant key "+constantKey); @@ -3224,7 +3277,18 @@ public static String generateSHA256(String message) { public class Element { Element parent = anyNodeStyle; Map properties = new LinkedHashMap(); - + + /// Records `(cssProperty -> varName)` for any property on this + /// Element whose value was set via a `var(--name, fallback)` + /// expansion. Populated by `apply(Element, String, LexicalUnit)` + /// from `ScaledUnit.bindingVarName` and consumed by + /// `updateResources()` to emit `@cn1-bind:<themeKey>=<name>` + /// constants. Each state sub-Element (unselected / selected / + /// pressed / disabled) keeps its own map so per-state var + /// references map cleanly to the corresponding `Button.press#fgColor` + /// shape. + Map bindings = new LinkedHashMap(); + Element unselected; Element selected; Element pressed; @@ -3552,7 +3616,49 @@ Element getDisabled() { } return disabled; } - + + /// Returns the var name a given CSS property is bound to, walking + /// the current Element first and falling back to its `parent` + /// chain. State sub-Elements have their parent set to the + /// owning UIID Element (see `getUnselected/Selected/Pressed/Disabled`) + /// so a `Button { color: var(--accent) }` binding visible on the + /// Button base also reaches `Button.press#fgColor` whenever + /// `Button.pressed` did not redeclare `color`. Likewise a + /// `cn1-derive: Button` chain inherits Button's bindings so + /// derived UIIDs retune in lockstep when the user overrides the + /// referenced theme constant. + String resolveBinding(String cssProperty) { + String b = bindings.get(cssProperty); + if (b != null) { + return b; + } + // If this Element set its own (non-bound) value for the + // CSS property, don't inherit a binding from the parent. + // Example: `Button { color: var(--accent) }` plus + // `Button.disabled { color: #a5a0ab }` - the disabled state + // EXPLICITLY overrode the colour with a literal, so reading + // the parent's accent binding for `Button.dis#fgColor` would + // wire the disabled state to the runtime accent override + // even though the disabled rule deliberately broke that + // link. Only fall through to the parent when this Element + // has no explicit value of its own (e.g. a derive-only + // child, or the implicit unselected state). + if (style.get(cssProperty) != null) { + return null; + } + // anyNodeStyle.parent points back to itself (default field + // initializer); stop the walk there to avoid infinite + // recursion. Any binding declared on anyNodeStyle is still + // returned by the bindings.get() check above. + if (this == anyNodeStyle) { + return null; + } + if (parent != null && parent != this) { + return parent.resolveBinding(cssProperty); + } + return null; + } + void put(String key, Object value) { style.put(key, value); } @@ -4928,6 +5034,21 @@ private com.codename1.ui.plaf.Border createRoundBorder(Map s } else { out.opacity(255); } + // When the source background-color came from a var() + // expansion, flip the RoundBorder into "uiid mode" so it + // paints via the Style's bgPainter (i.e. Style.bgColor) + // at render time instead of the static color baked into + // the border at compile time. Without this, a runtime + // `@accent-color` override updates themeProps[bgColor] + // correctly but the visible pill stays at the compile- + // time fallback because RoundBorder.fillShape uses its + // own field. Only flip when the binding is present so + // legacy themes that rely on the baked-color path keep + // their existing rendering. + if (backgroundColor instanceof ScaledUnit + && ((ScaledUnit) backgroundColor).bindingVarName != null) { + out.uiid(true); + } } else { out.opacity(0); } @@ -5559,7 +5680,19 @@ String renderAsCSSString(String property, Map styles) { public void apply(Element style, String property, LexicalUnit value) { - + // Capture any var() binding tagged onto the head ScaledUnit so the + // owning Element knows this property is bound to a runtime + // overridable theme constant. We record on every property so + // `updateResources()` only has to look in one place; emission to + // the .res is gated on whether the resulting theme key is one of + // the accent-bearing color outputs (fg/bg color today). + if (value instanceof ScaledUnit) { + String varName = ((ScaledUnit) value).bindingVarName; + if (varName != null && varName.length() > 0) { + style.bindings.put(property, varName); + } + } + switch (property) { case "refresh-images": refreshImages = true; @@ -7177,6 +7310,35 @@ private static int indexOfOutsideComments(String css, String needle, int fromPos return -1; } + /// Checks whether the supplied `currSelectors` currently parsing + /// declarations is the special `#Constants` pseudo-element CN1 + /// themes use to declare theme constants (rather than UIID styling + /// rules). Used by the `cn1--<name>` short-circuit in `property_` + /// to decide whether to ALSO emit the declaration as a `@<name>` + /// theme constant - so a user theme.css can override a native + /// theme's bound palette purely from CSS. + private static boolean isInsideConstantsBlock(SelectorList currSelectors) { + if (currSelectors == null) { + return false; + } + int len = currSelectors.getLength(); + for (int i = 0; i < len; i++) { + Selector sel = currSelectors.item(i); + if (sel.getSelectorType() != Selector.SAC_CONDITIONAL_SELECTOR) { + continue; + } + ConditionalSelector csel = (ConditionalSelector) sel; + if (csel.getCondition().getConditionType() != Condition.SAC_ID_CONDITION) { + continue; + } + AttributeCondition acond = (AttributeCondition) csel.getCondition(); + if ("Constants".equalsIgnoreCase(acond.getValue())) { + return true; + } + } + return false; + } + private static int findMatchingBrace(String css, int openPos) { int depth = 0; int len = css.length(); @@ -7372,6 +7534,22 @@ private ScaledUnit evaluate(LexicalUnit lu) throws CSSException { } else { su = evaluate(new ScaledUnit(varVal, theme.currentDpi, theme.getPreviewScreenWidth(), theme.getPreviewScreenHeight())); } + // Tag the resolved unit with the originating var name + // (sans the `cn1--` prefix the loader rewrites `--` to) + // so apply() can stash a runtime binding on the owning + // Element. We tag the head only - the chain after the + // var() expansion stays unbound. + if (su != null && varname != null) { + String bare = varname; + if (bare.startsWith("cn1--")) { + bare = bare.substring("cn1--".length()); + } else if (bare.startsWith("--")) { + bare = bare.substring(2); + } + if (bare.length() > 0) { + su.bindingVarName = bare; + } + } // Evaluate the variable value in case it also includes other variables that need to be evaluated. //ScaledUnit su = evaluate(new ScaledUnit(varVal, theme.currentDpi, theme.getPreviewScreenWidth(), theme.getPreviewScreenHeight())); LexicalUnit toAppend = lu.getNextLexicalUnit(); @@ -7408,8 +7586,41 @@ private ScaledUnit evaluate(LexicalUnit lu) throws CSSException { private void property_(String string, LexicalUnit _lu, boolean bln) throws CSSException { if (string.startsWith("cn1--")) { - + variables.put(string, _lu); + // When the `--name` declaration sits inside the + // `#Constants` pseudo-element, ALSO export it as a + // `@name` theme constant. This is the load-bearing + // hook that lets a user app's theme.css override a + // native-theme palette variable purely from CSS: + // the native theme declares `#Constants { + // --accent-color: #007aff; }` which becomes a + // theme constant, the user's theme.css redeclares + // the same `#Constants { --accent-color: #ff2d95; }` + // override which loads later (after the native + // theme is layered in via includeNativeBool=true) + // and overwrites the constant. UIManager's + // applyThemeBindings then retunes every UIID + // bound to that variable. Without this dual + // emission `--name` would only live in the + // parser-internal variables map and never reach + // the runtime. + if (currSelectors != null + && isInsideConstantsBlock(currSelectors)) { + String constantName = string.substring("cn1--".length()); + if (constantName.length() > 0) { + // Evaluate via the same path the rest of + // updateResources expects: wraps raw + // LexicalUnitImpls into ScaledUnits so + // downstream getColorString() and friends + // see the chain shape they were written + // against (red/green/blue ScaledUnits + // for SAC_RGBCOLOR), not the raw Flute + // LexicalUnitImpl which would crash on + // the ScaledUnit cast. + theme.constants.put(constantName, evaluate(_lu)); + } + } return; } diff --git a/native-themes/android-material/theme.css b/native-themes/android-material/theme.css index cbe99f8aec..aaf75f58f8 100644 --- a/native-themes/android-material/theme.css +++ b/native-themes/android-material/theme.css @@ -5,8 +5,7 @@ * rounded/pill borders, state selectors via .pressed / .selected / * .disabled. Light and dark palettes live at the bottom of this file * in a prefers-color-scheme block the compiler rewrites into $DarkUIID - * entries. Colors are inlined (not CSS variables) because the rewriter - * operates at string level and doesn't re-scope :root declarations. + * entries. * * Material 3 baseline palette reference: * primary #6750a4 dark #d0bcff @@ -24,12 +23,36 @@ * state-disabled #e0dce4 dark #2b2930 * on-disabled #a5a0ab dark #5c5967 * - * Overridable palette: user apps layer their own theme.css or runtime - * UIManager.addThemeProps on top of this theme - see - * PaletteOverrideThemeScreenshotTest for a working example. + * Accent palette: every var(--accent-...) reference below resolves to + * the inlined fallback at compile time AND emits a runtime binding so + * the same color can be retuned at app launch via + * UIManager.addThemeProps({"@accent-color": "ff2d95", ...}). No theme + * recompile needed - the .res ships with the variable bindings baked + * in. See PaletteOverrideThemeScreenshotTest for a worked example. + * Variable names mirror the M3 token names: --accent maps to "primary", + * --accent-on to "on-primary", --accent-container to + * "primary-container", --accent-on-container to "on-primary-container", + * --accent-pressed to "state-pressed". Keep light/dark variants in + * sync (-dark suffix) so an app override affecting only the light + * constant doesn't desynchronise dark mode. */ #Constants { + /* Material 3 primary accent palette overridable at runtime via + addThemeProps. Each var(--name, fallback) embeds the fallback as + the baked-in default and emits a @cn1-bind constant the runtime + UIManager consults when an @ override is supplied. */ + --accent-color: #6750a4; + --accent-color-dark: #d0bcff; + --accent-on-color: #ffffff; + --accent-on-color-dark: #381e72; + --accent-container-color: #eaddff; + --accent-container-color-dark: #4f378b; + --accent-on-container-color: #21005d; + --accent-on-container-color-dark: #eaddff; + --accent-pressed-color: #d0bcff; + --accent-pressed-color-dark: #4f378b; + includeNativeBool: false; darkModeBool: true; commandBehavior: Native; @@ -120,8 +143,8 @@ SpanLabelText { cn1-derive: Label; background-color: transparent; } only honours base-level cn1-derive), so explicit values are the reliable fix. */ Button { - color: #ffffff; - background-color: #6750a4; + color: var(--accent-on-color, #ffffff); + background-color: var(--accent-color, #6750a4); padding: 1.5mm 4mm 1.5mm 4mm; margin: 0.8mm; font-family: "native:MainRegular"; @@ -130,8 +153,8 @@ Button { text-align: center; } Button.pressed { - color: #21005d; - background-color: #d0bcff; + color: var(--accent-on-container-color, #21005d); + background-color: var(--accent-pressed-color, #d0bcff); padding: 1.5mm 4mm 1.5mm 4mm; margin: 0.8mm; font-family: "native:MainRegular"; @@ -155,8 +178,8 @@ Button.disabled { dark mode where Button's own container color otherwise matches. */ RaisedButton { cn1-derive: Button; - color: #21005d; - background-color: #eaddff; + color: var(--accent-on-container-color, #21005d); + background-color: var(--accent-container-color, #eaddff); padding: 1.5mm 4mm 1.5mm 4mm; margin: 0.8mm; font-family: "native:MainRegular"; @@ -165,8 +188,8 @@ RaisedButton { text-align: center; } RaisedButton.pressed { - color: #21005d; - background-color: #d0bcff; + color: var(--accent-on-container-color, #21005d); + background-color: var(--accent-pressed-color, #d0bcff); padding: 1.5mm 4mm 1.5mm 4mm; margin: 0.8mm; font-family: "native:MainRegular"; @@ -188,11 +211,11 @@ RaisedButton.disabled { FlatButton { cn1-derive: Button; background-color: transparent; - color: #6750a4; + color: var(--accent-color, #6750a4); cn1-background-type: cn1-pill-border; text-align: center; } -FlatButton.pressed { background-color: #eaddff; color: #21005d; cn1-background-type: cn1-pill-border; } +FlatButton.pressed { background-color: var(--accent-container-color, #eaddff); color: var(--accent-on-container-color, #21005d); cn1-background-type: cn1-pill-border; } TextField { color: #1d1b20; @@ -233,7 +256,7 @@ CheckBox { font-size: 3.5mm; icon-gap: 2mm; } -CheckBox.selected { color: #6750a4; } +CheckBox.selected { color: var(--accent-color, #6750a4); } CheckBox.disabled { color: #a5a0ab; background-color: #e0dce4; } RadioButton { @@ -245,7 +268,7 @@ RadioButton { font-size: 3.5mm; icon-gap: 2mm; } -RadioButton.selected { color: #6750a4; } +RadioButton.selected { color: var(--accent-color, #6750a4); } RadioButton.disabled { color: #a5a0ab; background-color: #e0dce4; } /* Switch's track left-edge aligns with Label's text left-edge @@ -257,17 +280,17 @@ Switch { margin: 1mm 1.5mm 1mm 1.5mm; text-align: left; } -Switch.selected { color: #ffffff; background-color: #6750a4; } +Switch.selected { color: var(--accent-on-color, #ffffff); background-color: var(--accent-color, #6750a4); } Switch.disabled { color: #a5a0ab; background-color: #e0dce4; } OnOffSwitch { cn1-derive: Label; - color: #ffffff; + color: var(--accent-on-color, #ffffff); background-color: #e7e0ec; padding: 1mm 2mm 1mm 2mm; cn1-background-type: cn1-pill-border; } -OnOffSwitch.selected { background-color: #6750a4; color: #ffffff; } +OnOffSwitch.selected { background-color: var(--accent-color, #6750a4); color: var(--accent-on-color, #ffffff); } Toolbar { background-color: #fef7ff; @@ -294,8 +317,8 @@ Title { } MainTitle { cn1-derive: Title; } -BackCommand { cn1-derive: Button; background-color: transparent; color: #6750a4; padding: 1mm 2mm 1mm 2mm; } -TitleCommand { cn1-derive: Button; background-color: transparent; color: #6750a4; padding: 1mm 2mm 1mm 2mm; } +BackCommand { cn1-derive: Button; background-color: transparent; color: var(--accent-color, #6750a4); padding: 1mm 2mm 1mm 2mm; } +TitleCommand { cn1-derive: Button; background-color: transparent; color: var(--accent-color, #6750a4); padding: 1mm 2mm 1mm 2mm; } /* Material 3 top tabs: flat surface with selected tab underlined by color rather than a pill fill. No border-radius here - @@ -313,10 +336,10 @@ Tab { font-size: 3.5mm; text-align: center; } -Tab.selected { color: #6750a4; background-color: #fef7ff; } -Tab.pressed { color: #21005d; background-color: #eaddff; } +Tab.selected { color: var(--accent-color, #6750a4); background-color: #fef7ff; } +Tab.pressed { color: var(--accent-on-container-color, #21005d); background-color: var(--accent-container-color, #eaddff); } -SelectedTab { cn1-derive: Tab; color: #6750a4; } +SelectedTab { cn1-derive: Tab; color: var(--accent-color, #6750a4); } UnselectedTab { cn1-derive: Tab; color: #49454f; } SideNavigationPanel { background-color: #fef7ff; padding: 0; margin: 0; } @@ -328,7 +351,7 @@ SideCommand { padding: 2mm 3mm 2mm 3mm; margin: 0; } -SideCommand.pressed { background-color: #eaddff; } +SideCommand.pressed { background-color: var(--accent-container-color, #eaddff); } List { background-color: #fef7ff; padding: 0; margin: 0; } @@ -348,7 +371,7 @@ MultiButton { font-family: "native:MainRegular"; font-size: 3.5mm; } -MultiButton.pressed { background-color: #eaddff; } +MultiButton.pressed { background-color: var(--accent-container-color, #eaddff); } MultiButton.disabled { color: #a5a0ab; background-color: #e0dce4; } MultiLine1 { cn1-derive: Label; color: #1d1b20; font-family: "native:MainBold"; } @@ -389,14 +412,14 @@ DialogContentPane { background-color: transparent; padding: 2mm; margin: 0; } DialogCommandArea { background-color: transparent; padding: 1mm; } FloatingActionButton { - color: #21005d; - background-color: #eaddff; + color: var(--accent-on-container-color, #21005d); + background-color: var(--accent-container-color, #eaddff); padding: 3mm; margin: 3mm; font-family: "native:MainBold"; cn1-background-type: cn1-pill-border; } -FloatingActionButton.pressed { background-color: #d0bcff; } +FloatingActionButton.pressed { background-color: var(--accent-pressed-color, #d0bcff); } Separator { background-color: #cac4d0; padding: 0; margin: 0; } PopupContent { @@ -421,16 +444,16 @@ PopupContent { /* Re-declare cn1-background-type on each dark Button override - the compiler doesn't carry the light declaration's pill border forward into the $Dark entries. */ - Button { color: #381e72; background-color: #d0bcff; cn1-background-type: cn1-pill-border; } - Button.pressed { background-color: #4f378b; color: #eaddff; cn1-background-type: cn1-pill-border; } + Button { color: var(--accent-on-color-dark, #381e72); background-color: var(--accent-color-dark, #d0bcff); cn1-background-type: cn1-pill-border; } + Button.pressed { background-color: var(--accent-pressed-color-dark, #4f378b); color: var(--accent-on-container-color-dark, #eaddff); cn1-background-type: cn1-pill-border; } Button.disabled { color: #5c5967; background-color: #2b2930; cn1-background-type: cn1-pill-border; } - RaisedButton { color: #eaddff; background-color: #4f378b; cn1-background-type: cn1-pill-border; } - RaisedButton.pressed { color: #eaddff; background-color: #6750a4; cn1-background-type: cn1-pill-border; } + RaisedButton { color: var(--accent-on-container-color-dark, #eaddff); background-color: var(--accent-container-color-dark, #4f378b); cn1-background-type: cn1-pill-border; } + RaisedButton.pressed { color: var(--accent-on-container-color-dark, #eaddff); background-color: var(--accent-color, #6750a4); cn1-background-type: cn1-pill-border; } RaisedButton.disabled { background-color: #2b2930; color: #5c5967; cn1-background-type: cn1-pill-border; } - FlatButton { color: #d0bcff; background-color: transparent; cn1-background-type: cn1-pill-border; } - FlatButton.pressed { background-color: #4f378b; color: #eaddff; cn1-background-type: cn1-pill-border; } + FlatButton { color: var(--accent-color-dark, #d0bcff); background-color: transparent; cn1-background-type: cn1-pill-border; } + FlatButton.pressed { background-color: var(--accent-container-color-dark, #4f378b); color: var(--accent-on-container-color-dark, #eaddff); cn1-background-type: cn1-pill-border; } TextField { color: #e6e0e9; background-color: #211f26; } TextField.pressed { background-color: #49454f; } @@ -441,42 +464,42 @@ PopupContent { TextHint { color: #938f99; } CheckBox { color: #e6e0e9; background-color: transparent; } - CheckBox.selected { color: #d0bcff; } + CheckBox.selected { color: var(--accent-color-dark, #d0bcff); } CheckBox.disabled { color: #5c5967; background-color: #2b2930; } RadioButton { color: #e6e0e9; background-color: transparent; } - RadioButton.selected { color: #d0bcff; } + RadioButton.selected { color: var(--accent-color-dark, #d0bcff); } RadioButton.disabled { color: #5c5967; background-color: #2b2930; } Switch { color: #938f99; background-color: #49454f; } - Switch.selected { color: #381e72; background-color: #d0bcff; } + Switch.selected { color: var(--accent-on-color-dark, #381e72); background-color: var(--accent-color-dark, #d0bcff); } Switch.disabled { color: #5c5967; background-color: #2b2930; } - OnOffSwitch { color: #381e72; background-color: #49454f; } - OnOffSwitch.selected { background-color: #d0bcff; color: #381e72; } + OnOffSwitch { color: var(--accent-on-color-dark, #381e72); background-color: #49454f; } + OnOffSwitch.selected { background-color: var(--accent-color-dark, #d0bcff); color: var(--accent-on-color-dark, #381e72); } Toolbar { background-color: #141218; color: #e6e0e9; } TitleArea { background-color: #141218; color: #e6e0e9; } Title { color: #e6e0e9; background-color: #141218; } MainTitle { color: #e6e0e9; background-color: #141218; } - BackCommand { color: #d0bcff; background-color: transparent; } - TitleCommand { color: #d0bcff; background-color: transparent; } + BackCommand { color: var(--accent-color-dark, #d0bcff); background-color: transparent; } + TitleCommand { color: var(--accent-color-dark, #d0bcff); background-color: transparent; } Tabs { background-color: #141218; } TabsContainer { background-color: #141218; color: #e6e0e9; } Tab { color: #cac4d0; background-color: #141218; } - Tab.selected { color: #d0bcff; background-color: #141218; } - Tab.pressed { color: #eaddff; background-color: #4f378b; } - SelectedTab { color: #d0bcff; } + Tab.selected { color: var(--accent-color-dark, #d0bcff); background-color: #141218; } + Tab.pressed { color: var(--accent-on-container-color-dark, #eaddff); background-color: var(--accent-pressed-color-dark, #4f378b); } + SelectedTab { color: var(--accent-color-dark, #d0bcff); } UnselectedTab { color: #cac4d0; } SideNavigationPanel { background-color: #141218; } SideCommand { color: #e6e0e9; background-color: transparent; } - SideCommand.pressed { background-color: #4f378b; } + SideCommand.pressed { background-color: var(--accent-pressed-color-dark, #4f378b); } List { background-color: #141218; } ListRenderer { color: #e6e0e9; background-color: transparent; } MultiButton { background-color: #141218; color: #e6e0e9; } - MultiButton.pressed { background-color: #4f378b; } + MultiButton.pressed { background-color: var(--accent-pressed-color-dark, #4f378b); } MultiLine1 { color: #e6e0e9; } MultiLine2 { color: #cac4d0; } MultiLine3 { color: #938f99; } @@ -489,8 +512,8 @@ PopupContent { DialogCommandArea { background-color: #211f26; } PopupContent { background-color: #211f26; color: #e6e0e9; } - FloatingActionButton { color: #eaddff; background-color: #4f378b; } - FloatingActionButton.pressed { background-color: #d0bcff; } + FloatingActionButton { color: var(--accent-on-container-color-dark, #eaddff); background-color: var(--accent-container-color-dark, #4f378b); } + FloatingActionButton.pressed { background-color: var(--accent-color-dark, #d0bcff); } Separator { background-color: #49454f; } } diff --git a/native-themes/ios-modern/theme.css b/native-themes/ios-modern/theme.css index d1e1d3de13..b53a55ea06 100644 --- a/native-themes/ios-modern/theme.css +++ b/native-themes/ios-modern/theme.css @@ -5,8 +5,7 @@ * rounded/pill borders, state selectors via .pressed / .selected / * .disabled. Light and dark palettes live at the bottom of this file * in a prefers-color-scheme block the compiler rewrites into $DarkUIID - * entries. Colors are inlined (not CSS variables) because the rewriter - * operates at string level and doesn't re-scope :root declarations. + * entries. * * Apple system palette reference (light / dark): * accent 007aff / 0a84ff @@ -22,13 +21,32 @@ * separator c6c6c8 / 38383a * success 34c759 / 30d158 * - * Overridable palette: user apps layer their own theme.css or runtime - * UIManager.addThemeProps on top of this theme - see - * PaletteOverrideThemeScreenshotTest for a working example that flips - * the accent to magenta at runtime. + * Accent palette: every var(--accent-...) reference below resolves to + * the inlined fallback at compile time AND emits a runtime binding so + * the same color can be retuned at app launch via + * UIManager.addThemeProps({"@accent-color": "ff2d95", ...}). No theme + * recompile needed - the .res ships with the variable bindings baked + * in. See PaletteOverrideThemeScreenshotTest for a worked example. + * Keep light/dark variants in sync (-dark suffix) so an app override + * affecting only the light constant doesn't desynchronise dark mode. */ #Constants { + /* Accent palette overridable at runtime via addThemeProps. The + light values feed the default rules; the -dark counterparts feed + the corresponding $Dark entries the dark @media block + compiles into. var(--name, fallback) embeds the fallback as the + baked-in default and emits a @cn1-bind:.= + constant the runtime UIManager consults when an @ + override is supplied. */ + --accent-color: #007aff; + --accent-color-dark: #0a84ff; + --accent-pressed-color: #0064d1; + --accent-pressed-color-dark: #64b1ff; + --accent-disabled-color: #b3d4ff; + --accent-disabled-color-dark: #004a99; + --accent-on-color: #ffffff; + includeNativeBool: false; darkModeBool: true; commandBehavior: Native; @@ -134,7 +152,7 @@ SpanLabelText { cn1-derive: Label; background-color: transparent; } degenerate to a circle when width<=height - RoundBorder paints a circle in that case, border-radius always keeps the corner radius. */ Button { - color: #007aff; + color: var(--accent-color, #007aff); padding: 2mm 4mm 2mm 4mm; margin: 1mm; font-family: "native:MainRegular"; @@ -142,18 +160,18 @@ Button { border-radius: 3mm; text-align: center; } -Button.pressed { color: #0064d1; background-color: #e5e5ea; } -Button.disabled { color: #b3d4ff; } +Button.pressed { color: var(--accent-pressed-color, #0064d1); background-color: #e5e5ea; } +Button.disabled { color: var(--accent-disabled-color, #b3d4ff); } RaisedButton { cn1-derive: Button; - color: #ffffff; - background-color: #007aff; + color: var(--accent-on-color, #ffffff); + background-color: var(--accent-color, #007aff); border-radius: 3mm; text-align: center; } -RaisedButton.pressed { color: #ffffff; background-color: #0064d1; } -RaisedButton.disabled { background-color: #b3d4ff; color: #ffffff; } +RaisedButton.pressed { color: var(--accent-on-color, #ffffff); background-color: var(--accent-pressed-color, #0064d1); } +RaisedButton.disabled { background-color: var(--accent-disabled-color, #b3d4ff); color: var(--accent-on-color, #ffffff); } FlatButton { cn1-derive: Button; } @@ -193,7 +211,7 @@ CheckBox { font-family: "native:MainRegular"; icon-gap: 2mm; } -CheckBox.selected { color: #007aff; } +CheckBox.selected { color: var(--accent-color, #007aff); } CheckBox.disabled { color: #c7c7cc; background-color: #e5e5ea; } RadioButton { @@ -204,7 +222,7 @@ RadioButton { font-family: "native:MainRegular"; icon-gap: 2mm; } -RadioButton.selected { color: #007aff; } +RadioButton.selected { color: var(--accent-color, #007aff); } RadioButton.disabled { color: #c7c7cc; background-color: #e5e5ea; } /* Switch's track-left needs to land at the same x as the Label @@ -224,7 +242,7 @@ Switch.disabled { color: #c7c7cc; background-color: #e5e5ea; } OnOffSwitch { cn1-derive: Label; - color: #007aff; + color: var(--accent-color, #007aff); background-color: #e5e5ea; padding: 1mm 2mm 1mm 2mm; cn1-background-type: cn1-pill-border; @@ -254,8 +272,8 @@ Title { } MainTitle { cn1-derive: Title; } -BackCommand { cn1-derive: Button; color: #007aff; padding: 1mm 2mm 1mm 2mm; } -TitleCommand { cn1-derive: Button; color: #007aff; padding: 1mm 2mm 1mm 2mm; } +BackCommand { cn1-derive: Button; color: var(--accent-color, #007aff); padding: 1mm 2mm 1mm 2mm; } +TitleCommand { cn1-derive: Button; color: var(--accent-color, #007aff); padding: 1mm 2mm 1mm 2mm; } /* iOS 26 tab-bar look: the TabsContainer is the visible pill group hugging the bottom edge, with individual tabs rendered as @@ -406,14 +424,14 @@ DialogContentPane { background-color: transparent; padding: 2mm; margin: 0; } DialogCommandArea { background-color: transparent; padding: 1mm; } FloatingActionButton { - color: #ffffff; - background-color: #007aff; + color: var(--accent-on-color, #ffffff); + background-color: var(--accent-color, #007aff); padding: 3mm; margin: 3mm; font-family: "native:MainBold"; cn1-background-type: cn1-pill-border; } -FloatingActionButton.pressed { background-color: #0064d1; } +FloatingActionButton.pressed { background-color: var(--accent-pressed-color, #0064d1); } Separator { background-color: #c6c6c8; padding: 0; margin: 0; } PopupContent { @@ -435,13 +453,13 @@ PopupContent { SpanLabel { color: #ffffff; background-color: transparent; } SpanLabelText { color: #ffffff; background-color: transparent; } - Button { color: #0a84ff; } - Button.pressed { color: #64b1ff; background-color: #3a3a3c; } - Button.disabled { color: #004a99; } + Button { color: var(--accent-color-dark, #0a84ff); } + Button.pressed { color: var(--accent-pressed-color-dark, #64b1ff); background-color: #3a3a3c; } + Button.disabled { color: var(--accent-disabled-color-dark, #004a99); } - RaisedButton { color: #ffffff; background-color: #0a84ff; } - RaisedButton.pressed { background-color: #64b1ff; } - RaisedButton.disabled { background-color: #004a99; } + RaisedButton { color: var(--accent-on-color, #ffffff); background-color: var(--accent-color-dark, #0a84ff); } + RaisedButton.pressed { background-color: var(--accent-pressed-color-dark, #64b1ff); } + RaisedButton.disabled { background-color: var(--accent-disabled-color-dark, #004a99); } TextField { color: #ffffff; background-color: #1c1c1e; } TextField.pressed { background-color: #2c2c2e; } @@ -452,25 +470,25 @@ PopupContent { TextHint { color: #8e8e93; } CheckBox { color: #ffffff; background-color: transparent; } - CheckBox.selected { color: #0a84ff; } + CheckBox.selected { color: var(--accent-color-dark, #0a84ff); } CheckBox.disabled { color: #48484a; background-color: #2c2c2e; } RadioButton { color: #ffffff; background-color: transparent; } - RadioButton.selected { color: #0a84ff; } + RadioButton.selected { color: var(--accent-color-dark, #0a84ff); } RadioButton.disabled { color: #48484a; background-color: #2c2c2e; } Switch { color: #ffffff; background-color: #2c2c2e; } Switch.selected { color: #ffffff; background-color: #30d158; } Switch.disabled { color: #48484a; background-color: #2c2c2e; } - OnOffSwitch { color: #0a84ff; background-color: #2c2c2e; } + OnOffSwitch { color: var(--accent-color-dark, #0a84ff); background-color: #2c2c2e; } OnOffSwitch.selected { background-color: #30d158; color: #ffffff; } Toolbar { background-color: #000000; color: #ffffff; } TitleArea { background-color: #000000; color: #ffffff; } Title { color: #ffffff; background-color: #000000; } MainTitle { color: #ffffff; background-color: #000000; } - BackCommand { color: #0a84ff; } - TitleCommand { color: #0a84ff; } + BackCommand { color: var(--accent-color-dark, #0a84ff); } + TitleCommand { color: var(--accent-color-dark, #0a84ff); } Tabs { background-color: transparent; } TabbedPane { background-color: #1c1c1e; } @@ -517,8 +535,8 @@ PopupContent { DialogCommandArea { background-color: transparent; } PopupContent { background-color: rgba(44,44,46,0.95); color: #ffffff; } - FloatingActionButton { color: #ffffff; background-color: #0a84ff; } - FloatingActionButton.pressed { background-color: #64b1ff; } + FloatingActionButton { color: var(--accent-on-color, #ffffff); background-color: var(--accent-color-dark, #0a84ff); } + FloatingActionButton.pressed { background-color: var(--accent-pressed-color-dark, #64b1ff); } Separator { background-color: #38383a; } } diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PaletteOverrideThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PaletteOverrideThemeScreenshotTest.java index af0555a085..a49012158e 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PaletteOverrideThemeScreenshotTest.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PaletteOverrideThemeScreenshotTest.java @@ -13,29 +13,65 @@ * Verifies that a sub-theme can re-skin the native palette without * touching the native theme's CSS source. * - * The native CSS declares a palette (--cn1-accent, --cn1-primary etc.) - * that's inlined into each UIID at compile time. At runtime a user app - * overrides specific colors by layering an additional {@link Hashtable} - * of theme props on top of the installed native theme via - * {@link UIManager#addThemeProps}. This test installs a magenta - * override - vivid enough that a visual diff against the native - * baseline is unmistakable - and verifies both the light and dark - * captures pick it up. + * The native CSS declares an accent palette via `var(--accent-color, + * fallback)` references. The compiler bakes the fallback into each + * UIID at compile time AND emits `@cn1-bind:<UIID>.<key>=accent-color` + * constants alongside, so the .res ships with every accent-bearing + * UIID quietly tracking the underlying palette variable. The + * recommended override path is from the user app's own `theme.css` + * (see Native-Themes docs - declaring `--accent-color: #ff2d95;` + * inside the app's `#Constants` block exports it as a + * `@accent-color` theme constant which the framework then fans out + * to every bound UIID at theme-install time). This screenshot test + * exercises the equivalent runtime path - + * {@link UIManager#addThemeProps(Hashtable)} with the same + * `@`-prefixed constants - because (a) screenshot tests cannot + * easily mutate the app's compiled theme.css, and (b) the runtime + * mechanism is what ships for dynamic theming use cases (in-app + * accent toggles, A/B tests, branded flavours). * - * The override is installed once when the suite reaches this test; the - * light capture exercises it with the light base styles, the dark - * capture exercises it with the base styles picking up the - * {@code $Dark} variants merged under the same override layer. - * Because {@link Style#setBgColor} on an override key blows away the - * {@code $Dark} variant for that specific key, the dark capture also - * ends up showing the override color - proving the override reaches - * every appearance. + * This test installs a magenta override on the primary accent and a + * vivid teal on the disabled accent. The teal is the load-bearing + * choice for cross-platform coverage: on iOS Modern the only visible + * widgets that rebind when accent-color changes are RaisedButton + + * Button (which the magenta already exercises), but the disabled + * RaisedButton stays at the default light-blue accent-disabled tone + * unless `@accent-disabled-color` is also retuned. Adding the teal + * therefore produces a visible iOS pixel diff against a baseline that + * predates the binding mechanism, confirming the binding fires on iOS + * and isn't merely a no-op coincidence with the magenta. Android + * Material 3 doesn't bind its disabled state to accent-disabled (its + * disabled colours are hard-coded in CSS), so the teal is iOS-only; + * Android's diff is driven by the magenta `@accent-container-color` + * retuning RaisedButton's tonal fill. + * + * The light capture exercises the light base styles; the dark capture + * exercises the {@code $Dark} variants which are bound to the + * matching `-dark` palette variables. + * + * Suite hygiene: this test installs the `@`-prefixed override + * constants once during the light populate(). DualAppearanceBaseTest's + * `finish()` runs after the dark capture and reloads `/theme` via + * {@link UIManager#initFirstTheme}, which routes through + * `setThemePropsImpl` and clears `themeConstants` before re-populating + * from the freshly-loaded theme - so the `@accent-color` (etc.) + * constants do NOT survive into the next test in the suite. The + * test's slot in `Cn1ssDeviceRunner` (after the theme-fidelity + * sub-suite, before OrientationLock) keeps it on the back end of the + * run regardless, so any future regression that drops + * `initFirstTheme` would still only affect tests that explicitly opt + * in to this run's tail. */ public class PaletteOverrideThemeScreenshotTest extends DualAppearanceBaseTest { private static final String OVERRIDE_ACCENT = "ff2d95"; private static final String OVERRIDE_ACCENT_PRESSED = "c71a75"; private static final String OVERRIDE_ACCENT_TEXT = "ffffff"; + /// Vivid teal for the disabled accent slot. Distinct from the iOS + /// Modern light/dark blue defaults (#b3d4ff / #004a99) and from the + /// magenta accent so the disabled RaisedButton on the form reads + /// as a third independent colour at a glance. + private static final String OVERRIDE_ACCENT_DISABLED = "00b894"; private boolean overrideInstalled; @Override @@ -77,30 +113,68 @@ protected void populate(Form form, String suffix) { /** * Adds a palette-override layer on top of the installed native - * theme. Uses {@link UIManager#addThemeProps} so the native theme - * stays resident underneath - the override table only has to - * redeclare the handful of keys it wants to change, plus the - * matching {@code $Dark} keys so the override applies in dark - * mode too. + * theme by declaring `@`-prefixed accent constants. The runtime + * binding pass in {@link UIManager} fans each constant out to every + * bound UIID/state/dark variant, so this short Hashtable replaces + * the 12+ explicit per-UIID keys the override used to require. + * + * Both Android and iOS native themes share the same variable + * vocabulary (see native-themes/<family>/theme.css `#Constants`). + * The Android theme additionally exposes M3-flavoured container + * tokens (`accent-container-color`, `accent-on-container-color`) + * which we override too so RaisedButton-style "tonal" surfaces also + * pick up the magenta - leaving them at the default would let + * Android-only RaisedButton.bgColor remain at the M3 baseline tone + * even though Button.fgColor and the matching iOS RaisedButton + * already shifted. */ private void installPaletteOverride() { + // Sanity check: log if a previous test in the suite leaked an + // accent constant into themeConstants. The test class that + // runs immediately before us (DarkLightShowcaseThemeScreenshot + // Test) does not touch the accent vocabulary, and the + // theme-fidelity tests that precede it install the modern + // theme via DualAppearanceBaseTest.installModernThemeIfRequest + // ed which routes through setThemeProps -> setThemePropsImpl + // -> themeConstants.clear(). So the expected pre-state here + // is "no @accent-color set". Any leaked value surfaces as a + // CN1SS:WARN line in the run log, making post-mortem + // investigation of suite-state cross-talk much cheaper. + String stale = UIManager.getInstance().getThemeConstant("accent-color", null); + if (stale != null) { + System.out.println("CN1SS:WARN:test=PaletteOverrideThemeScreenshotTest " + + "stale-accent-color=" + stale + + " (a previous test left an @accent-color constant in UIManager state)"); + } Hashtable override = new Hashtable(); - override.put("RaisedButton.bgColor", OVERRIDE_ACCENT); - override.put("RaisedButton.fgColor", OVERRIDE_ACCENT_TEXT); - override.put("RaisedButton.press#bgColor", OVERRIDE_ACCENT_PRESSED); - override.put("RaisedButton.press#fgColor", OVERRIDE_ACCENT_TEXT); - override.put("Button.fgColor", OVERRIDE_ACCENT); - override.put("Button.press#fgColor", OVERRIDE_ACCENT_PRESSED); - // Dark override mirrors the light override so the magenta - // applies across both appearances. A real user theme would - // probably choose two variants; this test keeps them identical - // for easy visual confirmation. - override.put("$DarkRaisedButton.bgColor", OVERRIDE_ACCENT); - override.put("$DarkRaisedButton.fgColor", OVERRIDE_ACCENT_TEXT); - override.put("$DarkRaisedButton.press#bgColor", OVERRIDE_ACCENT_PRESSED); - override.put("$DarkRaisedButton.press#fgColor", OVERRIDE_ACCENT_TEXT); - override.put("$DarkButton.fgColor", OVERRIDE_ACCENT); - override.put("$DarkButton.press#fgColor", OVERRIDE_ACCENT_PRESSED); + override.put("@accent-color", OVERRIDE_ACCENT); + override.put("@accent-color-dark", OVERRIDE_ACCENT); + override.put("@accent-pressed-color", OVERRIDE_ACCENT_PRESSED); + override.put("@accent-pressed-color-dark", OVERRIDE_ACCENT_PRESSED); + override.put("@accent-on-color", OVERRIDE_ACCENT_TEXT); + override.put("@accent-on-color-dark", OVERRIDE_ACCENT_TEXT); + // iOS-only: retunes the disabled RaisedButton's bg and the + // disabled Button.fgColor away from the platform accent- + // disabled blue. Without this slot, iOS captures resolve to + // byte-identical pixels as the pre-binding baseline (every + // visible widget on the form happens to bind to accent-color + // / accent-on-color, both of which still produce the same + // magenta the old per-UIID override forced). Including a + // unique colour here makes the iOS diff vs baseline + // unambiguous and proves the runtime binding fires on iOS too. + // Android Material 3 leaves disabled-state colours hard-coded + // in CSS, so this constant has no Android effect (and that's + // fine - Android's accent-container-color override below + // produces its own visible diff there). + override.put("@accent-disabled-color", OVERRIDE_ACCENT_DISABLED); + override.put("@accent-disabled-color-dark", OVERRIDE_ACCENT_DISABLED); + // Material 3 RaisedButton uses the "container" tonal pair; iOS + // ignores these vars (no bindings reference them) so it's safe + // to set them unconditionally for both platforms. + override.put("@accent-container-color", OVERRIDE_ACCENT); + override.put("@accent-container-color-dark", OVERRIDE_ACCENT); + override.put("@accent-on-container-color", OVERRIDE_ACCENT_TEXT); + override.put("@accent-on-container-color-dark", OVERRIDE_ACCENT_TEXT); UIManager.getInstance().addThemeProps(override); } } diff --git a/scripts/ios/screenshots-metal/PaletteOverrideTheme_dark.png b/scripts/ios/screenshots-metal/PaletteOverrideTheme_dark.png index da9a335b1b..d552b1933c 100644 Binary files a/scripts/ios/screenshots-metal/PaletteOverrideTheme_dark.png and b/scripts/ios/screenshots-metal/PaletteOverrideTheme_dark.png differ diff --git a/scripts/ios/screenshots-metal/PaletteOverrideTheme_light.png b/scripts/ios/screenshots-metal/PaletteOverrideTheme_light.png index f4d8d85df8..c8e4b87c04 100644 Binary files a/scripts/ios/screenshots-metal/PaletteOverrideTheme_light.png and b/scripts/ios/screenshots-metal/PaletteOverrideTheme_light.png differ diff --git a/scripts/ios/screenshots/PaletteOverrideTheme_dark.png b/scripts/ios/screenshots/PaletteOverrideTheme_dark.png index 694eb9550d..4aa4dfe427 100644 Binary files a/scripts/ios/screenshots/PaletteOverrideTheme_dark.png and b/scripts/ios/screenshots/PaletteOverrideTheme_dark.png differ diff --git a/scripts/ios/screenshots/PaletteOverrideTheme_light.png b/scripts/ios/screenshots/PaletteOverrideTheme_light.png index 336bf90dc5..e9b8a4ee39 100644 Binary files a/scripts/ios/screenshots/PaletteOverrideTheme_light.png and b/scripts/ios/screenshots/PaletteOverrideTheme_light.png differ