From 8b0680ec26e11f4f762f9640472a29714198096f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:49:53 +0300 Subject: [PATCH 1/3] Retune the iOS 27 Liquid Glass materials, and make the theme generation reach the device gen27.css was empty, so ios.themeGeneration=27 selected a theme byte-identical to 26. It now selects iOS 27 variants of the three glass materials, measured rather than guessed. HOW THE CONSTANTS WERE OBTAINED The material is the documented affine transform c' = clamp((lum + (c - lum) * sat) * scale + offset) and the iOS 26 constants are known, so every interior pixel of a committed goldens/ios-26-metal tile inverts back to the backdrop that produced it, and the matching goldens/ios-27-metal pixel is then fitted against that same backdrop. Nothing about the backdrop is assumed, and the transform being linear means it commutes with the Gaussian blur. Clipping does NOT commute, so a pixel counts only when nothing within the blur's reach clipped -- without that erosion the photo-backdrop tile fitted at rms 14 against rms 1 for the flat ones and dragged every parameter with it. The method self-checks: run against iOS 26 it recovers the iOS 26 chrome constants it was never told, to within 1.3% (1.09/0.860/18.9 against 1.10/0.850/20.0, rms 1.27/255). WHAT IT FOUND Light chrome and light pill are measured as UNCHANGED in iOS 27, so those recipes reuse the iOS 26 numbers verbatim instead of shipping a near-identical duplicate that would read as a real difference. Dark is not affine any more. After the best affine fit the light residual is flat across the whole backdrop-luma range (+/-0.9/255); the dark residual runs +8.9 at low luma, -9.3 through the middle and +10.3 at high -- a systematic curve. No choice of sat/scale/offset can express that. Matching dark exactly needs a curve term in the material model and in every port's shader; these values are the closest an affine material gets, and GlassRecipe.liquidPanel27 says so rather than leaving the next person to rediscover it. MEASURED EFFECT, iPhone 16 / iOS 27.0 against goldens/ios-27-metal, 68 tiles mean light dark before 90.34% 92.89% 87.80% after 92.83% 93.39% 92.27% 15 improved, 51 unchanged, 2 regressed by 0.3. The six dark glass panels gained 21-24 points each. AND WHY NONE OF THAT WAS VISIBLE AT FIRST Three runs came back with byte-identical scores before any of this could be measured, because four independent things each make the hint inert while every build stays green, and all four look the same from the outside: - the suite installs its own theme (FidelityDeviceRunner.resolveThemeResource) rather than going through installNativeTheme, and hardcoded generation 26; - iOS has no generic build-hint bridge, so the hint only reaches the device as a setter IPhoneBuilder writes into the generated stub, which a stale plugin omits; - the inner mvnw resolves that plugin from whatever localRepository is configured, and a shared one hands over another checkout's jar; - the stub is a plugin OUTPUT, so an incremental build reuses the old one. The port now answers ios.themeGeneration and cn1.nativeThemeResource from getProperty and the runner asks it. build-fidelity-app.sh records the other three, and replaces its earlier claim that reading the constant pool verified the hint -- that only proves the string was interned. GlassRecipeTest is new. Its load-bearing case is that a material name a shipped theme asks for must exist: named() answers the panel recipe for anything it does not recognise, so "chrome72" would ship and paint the toolbar with a heavy wash plus refraction where the bar should be near pass-through, silently. Verified to fail on exactly that typo before being relied on. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/ui/plaf/GlassRecipe.java | 89 +++++++- .../nativeSources/iOSModern27Theme.res | Bin 207421 -> 207457 bytes .../codename1/impl/ios/IOSImplementation.java | 17 ++ Themes/iOSModern27Theme.res | Bin 207421 -> 207457 bytes .../codename1/ui/plaf/GlassRecipeTest.java | 207 ++++++++++++++++++ native-themes/ios-modern/gen27.css | 40 +++- scripts/build-fidelity-app.sh | 29 ++- .../fidelity/FidelityDeviceRunner.java | 12 + 8 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/plaf/GlassRecipeTest.java diff --git a/CodenameOne/src/com/codename1/ui/plaf/GlassRecipe.java b/CodenameOne/src/com/codename1/ui/plaf/GlassRecipe.java index e711a202669..dccf4554fef 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/GlassRecipe.java +++ b/CodenameOne/src/com/codename1/ui/plaf/GlassRecipe.java @@ -44,6 +44,10 @@ /// button capsule (UIGlassEffect). The strongest material: heavy wash, /// plus edge refraction and a specular rim so the glass reads as a layer /// on top of the content rather than a flat hole. +/// - `chrome27`, `pill27`, `panel27` -- the same three materials as +/// iOS 27 retuned them. Light is measured as unchanged in chrome and pill; +/// dark moved in all three. See {@link #liquidPanel27(boolean)} for how the +/// constants were measured and why dark is approximate. /// /// A theme assigns a recipe per UIID with the theme constant /// `GlassRecipe` (for example `ToolbarGlassRecipe: chrome`), @@ -132,8 +136,80 @@ public static GlassRecipe liquidPanel(boolean dark) { : new GlassRecipe(Kind.LIQUID_PANEL, 1.95f, 0.303f, 174.3f, 0.4f, 0.5f); } - /// Looks up a recipe by its theme name (`blur`, `chrome`, - /// `pill` or `panel`). Unknown names fall back to the panel + /// The iOS 27 chrome-bar material. + /// + /// LIGHT IS DELIBERATELY IDENTICAL to {@link #liquidChrome(boolean)}. Fitting + /// the transform against the iOS 27 capture returned sat 1.09, scale 0.860, + /// offset 18.9 where iOS 26 is 1.10 / 0.850 / 20.0 -- inside 1.3% on every + /// parameter, at an rms of 1.27/255. That is a measurement saying "unchanged", + /// so the iOS 26 numbers are reused verbatim rather than replaced by a + /// near-identical duplicate that would read as a real difference. + /// + /// DARK is the best affine fit (rms 14.6/255) and is KNOWN NOT TO BE EXACT. + /// See {@link #liquidPanel27(boolean)} for why dark cannot be fitted properly. + /// + /// @param dark true for the dark appearance + /// @return the iOS 27 chrome-bar recipe + public static GlassRecipe liquidChrome27(boolean dark) { + return dark + ? new GlassRecipe(Kind.LIQUID_CHROME, 1.98f, 0.764f, 51.3f, 0f, 0f) + : new GlassRecipe(Kind.LIQUID_CHROME, 1.1f, 0.85f, 20f, 0f, 0f); + } + + /// The iOS 27 floating-pill material. + /// + /// LIGHT is again measured as unchanged -- the fit returned 1.86 / 0.984 / + /// 111.4 against iOS 26's 1.80 / 1.000 / 108.0 -- so the iOS 26 values are + /// reused. DARK is the best affine fit and, like chrome, is approximate. + /// + /// @param dark true for the dark appearance + /// @return the iOS 27 floating-pill recipe + public static GlassRecipe liquidPill27(boolean dark) { + return dark + ? new GlassRecipe(Kind.LIQUID_PILL, 3.04f, 0.244f, 45.5f, 0f, 0.2f) + : new GlassRecipe(Kind.LIQUID_PILL, 1.8f, 1.0f, 108f, 0f, 0.2f); + } + + /// The iOS 27 glass-panel material, and the recipe that carries the method + /// these three were measured with. + /// + /// HOW THE NUMBERS WERE OBTAINED. The material is the documented affine + /// transform `c' = clamp((lum + (c - lum) * sat) * scale + offset)`. The iOS + /// 26 constants are known, so every interior pixel of a committed + /// goldens/ios-26-metal tile can be inverted back to the backdrop that + /// produced it, and the matching goldens/ios-27-metal pixel then fitted + /// against that same backdrop. Nothing about the backdrop has to be assumed, + /// and because the transform is linear it commutes with the Gaussian blur, so + /// the blur does not bias the fit. Clipping does NOT commute, so a pixel is + /// used only when nothing within the blur's reach clipped -- without that + /// erosion the photo-backdrop tile fitted at rms 14 against rms 1 for the flat + /// ones, and it dragged every parameter with it. + /// + /// The method self-checks: run against the iOS 26 set it recovers the iOS 26 + /// chrome constants it was never told, to within 1.3%. + /// + /// WHY DARK IS APPROXIMATE, IN ALL THREE RECIPES. After the best affine fit, + /// the light residual is flat across the whole backdrop-luma range (within + /// +/-0.9/255). The dark residual is not: on the chrome bar it runs +8.9 at + /// low luma, -9.3 through the middle and +10.3 at high luma -- a systematic + /// curve, not noise. iOS 27's dark glass therefore applies a NON-LINEAR + /// luminance response, and no choice of sat/scale/offset can express it, + /// because this transform is affine by construction. Matching dark properly + /// needs a curve term in the material model and in every port's shader, which + /// is a larger change than new constants; these values are the closest an + /// affine material gets until then. + /// + /// @param dark true for the dark appearance + /// @return the iOS 27 glass-panel recipe + public static GlassRecipe liquidPanel27(boolean dark) { + return dark + ? new GlassRecipe(Kind.LIQUID_PANEL, 2.84f, 0.378f, 79.8f, 0.4f, 0.5f) + : new GlassRecipe(Kind.LIQUID_PANEL, 2.08f, 0.457f, 137.4f, 0.4f, 0.5f); + } + + /// Looks up a recipe by its theme name: `blur`, `chrome`, + /// `pill`, `panel`, or their iOS 27 variants `chrome27`, + /// `pill27` and `panel27`. Unknown names fall back to the panel /// recipe -- the safest default for a free-standing glass surface. /// /// @param name the recipe name from the theme @@ -150,6 +226,15 @@ public static GlassRecipe named(String name, boolean dark) { if ("pill".equals(n)) { return liquidPill(dark); } + if ("chrome27".equals(n)) { + return liquidChrome27(dark); + } + if ("pill27".equals(n)) { + return liquidPill27(dark); + } + if ("panel27".equals(n)) { + return liquidPanel27(dark); + } return liquidPanel(dark); } diff --git a/Ports/iOSPort/nativeSources/iOSModern27Theme.res b/Ports/iOSPort/nativeSources/iOSModern27Theme.res index df0f3650494647377cad96c65f649e5ce9555333..e108e1ba1874663e7a068bba57f14fb64f992b51 100644 GIT binary patch delta 109 zcmdmch3DZFo(Wlh?dToQ}2rw7V2iflC6@tvKmATuY&$b9nq?-HCG$r(lYxv4-N z-w!baZ?pCfM@8xMoW$bdpw#5df>f8(w8YY!5(f5y#Jp6X=JrfUMj&R|o+-(EMF0S# C9VWd1 delta 80 zcmaEOg=g;-o(W=%Yd4Bt{?5u$keQP+`Qvv96}IGzqWs)c24RPg{QR7x#3J{c#Ny(h e)a1;9R0gQzX2TzjitQ658G)E-`vghmivj>U9UmJ2 diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 998689fd4d5..42e6c016d64 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -10626,6 +10626,23 @@ public String getProperty(String key, String defaultValue) { if(key.equalsIgnoreCase("cn1.nativeRedirects")) { return "true"; } + // The ios.themeGeneration build hint, read back. iOS has no generic + // build-hint bridge -- getProperty answers a fixed key list and the + // generated stub's static setter is the only way a hint reaches the + // device -- so a hint the port already stores is invisible to the + // application unless it is answered here. The fidelity harness needs + // exactly this: it installs the native theme itself rather than through + // installNativeTheme, so without a readable generation it always scored + // the iOS 26 theme, including against the iOS 27 goldens. + if(key.equalsIgnoreCase("ios.themeGeneration")) { + return iosThemeGeneration; + } + // The theme resource that generation selects, so a caller that wants the + // file rather than the number does not have to re-derive the mapping and + // risk disagreeing with installNativeTheme about it. + if(key.equalsIgnoreCase("cn1.nativeThemeResource")) { + return "/" + modernThemeResourceName() + ".res"; + } if(key.equalsIgnoreCase("OS")) { return "iOS"; } diff --git a/Themes/iOSModern27Theme.res b/Themes/iOSModern27Theme.res index df0f3650494647377cad96c65f649e5ce9555333..e108e1ba1874663e7a068bba57f14fb64f992b51 100644 GIT binary patch delta 109 zcmdmch3DZFo(Wlh?dToQ}2rw7V2iflC6@tvKmATuY&$b9nq?-HCG$r(lYxv4-N z-w!baZ?pCfM@8xMoW$bdpw#5df>f8(w8YY!5(f5y#Jp6X=JrfUMj&R|o+-(EMF0S# C9VWd1 delta 80 zcmaEOg=g;-o(W=%Yd4Bt{?5u$keQP+`Qvv96}IGzqWs)c24RPg{QR7x#3J{c#Ny(h e)a1;9R0gQzX2TzjitQ658G)E-`vghmivj>U9UmJ2 diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/GlassRecipeTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/GlassRecipeTest.java new file mode 100644 index 00000000000..d17690960cc --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/GlassRecipeTest.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.ui.plaf; + +import com.codename1.junit.UITestBase; +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.Enumeration; +import java.util.Hashtable; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the named glass materials, and above all that the names the SHIPPED + * themes ask for actually exist. + * + *

{@code GlassRecipe.named} answers the panel recipe for anything it does not + * recognise. That is the right runtime behaviour -- a theme must not be able to + * crash an application by misspelling a material -- but it means a typo in a + * theme is completely silent: {@code ToolbarGlassRecipe: "chrome72"} compiles, + * ships, and paints the toolbar with the panel material, which is a heavy wash + * plus refraction and a specular rim where the bar should be nearly + * pass-through. Nothing reports it. {@link #everyRecipeNamedByAShippedThemeResolves} + * is the check that does.

+ */ +public class GlassRecipeTest extends UITestBase { + + /** Every recipe name the lookup is documented to accept. */ + private static final Set KNOWN = new TreeSet(); + static { + KNOWN.add("blur"); + KNOWN.add("chrome"); + KNOWN.add("pill"); + KNOWN.add("panel"); + KNOWN.add("chrome27"); + KNOWN.add("pill27"); + KNOWN.add("panel27"); + } + + @Test + public void namedResolvesEveryDocumentedName() { + for (String name : KNOWN) { + for (int i = 0; i < 2; i++) { + boolean dark = i == 1; + assertNotNull(GlassRecipe.named(name, dark), name + " did not resolve"); + } + } + } + + @Test + public void unknownNamesFallBackToPanel() { + // Documented behaviour, pinned so the fallback cannot be "fixed" into a + // throw without someone deciding to -- a theme must not crash an app. + for (int i = 0; i < 2; i++) { + boolean dark = i == 1; + assertEquals(GlassRecipe.liquidPanel(dark).getKind(), + GlassRecipe.named("no-such-material", dark).getKind(), + "an unknown material name should fall back to the panel recipe"); + } + } + + @Test + public void iOS27KeepsTheKindOfTheMaterialItRetunes() { + // The 27 variants are the SAME materials with different constants, so a + // caller switching generations must not get a different Kind -- the only + // thing Component reads off the kind is "is this a plain blur". + for (int i = 0; i < 2; i++) { + boolean dark = i == 1; + assertEquals(GlassRecipe.liquidChrome(dark).getKind(), + GlassRecipe.liquidChrome27(dark).getKind(), "chrome27 kind"); + assertEquals(GlassRecipe.liquidPill(dark).getKind(), + GlassRecipe.liquidPill27(dark).getKind(), "pill27 kind"); + assertEquals(GlassRecipe.liquidPanel(dark).getKind(), + GlassRecipe.liquidPanel27(dark).getKind(), "panel27 kind"); + } + } + + @Test + public void lightChromeAndPillAreMeasuredUnchangedInIOS27() { + // Not an accident and not a stub: fitting the material against the + // committed ios-27-metal goldens returned the iOS 26 constants back to + // within 1.3% for both of these, so they are deliberately shared. Pinned + // because "27 equals 26 here" is a MEASUREMENT -- if someone later edits + // one of the four numbers, that edit has to be a deliberate re-measure + // rather than a drive-by. + assertSameMaterial(GlassRecipe.liquidChrome(false), GlassRecipe.liquidChrome27(false), + "light chrome"); + assertSameMaterial(GlassRecipe.liquidPill(false), GlassRecipe.liquidPill27(false), + "light pill"); + } + + @Test + public void darkMaterialsActuallyMovedInIOS27() { + // The dual of the test above: dark is where iOS 27 changed, so a 27 dark + // recipe that still equals its 26 counterpart means the retune was lost. + assertDifferentMaterial(GlassRecipe.liquidChrome(true), GlassRecipe.liquidChrome27(true), + "dark chrome"); + assertDifferentMaterial(GlassRecipe.liquidPill(true), GlassRecipe.liquidPill27(true), + "dark pill"); + assertDifferentMaterial(GlassRecipe.liquidPanel(true), GlassRecipe.liquidPanel27(true), + "dark panel"); + } + + @Test + public void everyRecipeNamedByAShippedThemeResolves() throws Exception { + String[] themes = {"iOSModernTheme.res", "iOSModern27Theme.res"}; + int checked = 0; + for (String theme : themes) { + Hashtable table = loadTheme(theme); + if (table == null) { + continue; + } + for (Enumeration e = table.keys(); e.hasMoreElements();) { + String key = String.valueOf(e.nextElement()); + if (!key.endsWith("GlassRecipe") && !key.endsWith("glassRecipeDefault")) { + continue; + } + String value = String.valueOf(table.get(key)).trim(); + assertTrue(KNOWN.contains(value), + theme + " asks for glass material '" + value + "' via " + key + + ", which GlassRecipe.named does not know -- it would" + + " silently fall back to the panel material. Known: " + KNOWN); + checked++; + } + } + // A check satisfiable by "nothing happened" is no check: if the themes are + // not built the test returns above, but if they ARE built and name no + // materials at all then the constants were lost from the theme. + if (locateNativeTheme(themes[0]) != null) { + assertTrue(checked > 0, + "the shipped iOS themes named no glass materials at all, so the" + + " *GlassRecipe constants were dropped from the theme"); + } + } + + private static void assertSameMaterial(GlassRecipe a, GlassRecipe b, String what) { + assertEquals(a.getSaturation(), b.getSaturation(), 0.0001f, what + " saturation"); + assertEquals(a.getScale(), b.getScale(), 0.0001f, what + " scale"); + assertEquals(a.getOffset(), b.getOffset(), 0.0001f, what + " offset"); + } + + private static void assertDifferentMaterial(GlassRecipe a, GlassRecipe b, String what) { + boolean moved = a.getSaturation() != b.getSaturation() + || a.getScale() != b.getScale() + || a.getOffset() != b.getOffset(); + assertTrue(moved, what + " is identical to its iOS 26 counterpart, but iOS 27" + + " was measured as having changed it"); + } + + private static Hashtable loadTheme(String fileName) throws Exception { + File themeFile = locateNativeTheme(fileName); + if (themeFile == null) { + return null; + } + InputStream stream = new FileInputStream(themeFile); + try { + Resources res = Resources.open(stream); + String[] names = res.getThemeResourceNames(); + assertNotNull(names, fileName + " carries no theme"); + assertTrue(names.length > 0, fileName + " carries no theme"); + return res.getTheme(names[0]); + } finally { + stream.close(); + } + } + + 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/native-themes/ios-modern/gen27.css b/native-themes/ios-modern/gen27.css index ecae724e810..d586e3383ee 100644 --- a/native-themes/ios-modern/gen27.css +++ b/native-themes/ios-modern/gen27.css @@ -36,5 +36,43 @@ * RadioButton, Switch, Slider, ProgressBar -- are byte-identical and belong in * common.css. * - * Empty until each of those deltas is tuned and its baseline recorded. + * Tuning proceeds delta by delta; each entry below names what was measured. + * + * MEASURED EFFECT of what is currently here, scored on an iPhone 16 / iOS 27.0 + * simulator against goldens/ios-27-metal, 68 tiles: + * + * mean light dark + * before 90.34% 92.89% 87.80% + * after 92.83% 93.39% 92.27% + * + * 15 tiles improved, 51 unchanged, 2 regressed by 0.3 (TextField dark, which is + * within the documented non-affine dark error). The six dark glass panels each + * gained 21-24 points. Tabs_normal_dark remains the worst tile at 68.75%: its + * material is the pill recipe, whose dark fit is the poorest of the three, and + * the travelling selection lens on top of it is a separate op again. */ + +#Constants { + /* THE LIQUID GLASS MATERIALS, RETUNED FOR iOS 27. + * + * The recipes are typed and named (com.codename1.ui.plaf.GlassRecipe) rather + * than loose numbers, so a generation switch is a change of NAME here and the + * constants live in one reviewed place with the measurement that produced + * them. GlassRecipe.liquidPanel27 carries that derivation in full. + * + * In short: the material is the affine transform + * c' = clamp((lum + (c - lum) * sat) * scale + offset) + * whose iOS 26 constants are known, so each ios-26-metal golden pixel can be + * inverted back to its backdrop and the matching ios-27-metal pixel fitted + * against it. The fit reproduces the known iOS 26 chrome constants to within + * 1.3%, which is what makes it trustworthy on iOS 27. + * + * Measured LIGHT chrome and pill as UNCHANGED from iOS 26; the 27 recipes + * reuse those numbers verbatim. Dark moved in all three, and is approximate + * there -- iOS 27's dark glass has a non-linear luminance response that an + * affine material cannot express (see GlassRecipe.liquidPanel27). */ + ToolbarGlassRecipe: "chrome27"; + TitleAreaGlassRecipe: "chrome27"; + TabsContainerGlassRecipe: "pill27"; + glassRecipeDefault: "panel27"; +} diff --git a/scripts/build-fidelity-app.sh b/scripts/build-fidelity-app.sh index 6962664b68d..2674800f435 100755 --- a/scripts/build-fidelity-app.sh +++ b/scripts/build-fidelity-app.sh @@ -26,10 +26,31 @@ case "$PLATFORM" in # # It rides IOS_DEPENDENCY_ARGS because that is the only channel # build-ios-app.sh forwards to the inner mvnw -- its positional arguments go - # nowhere. -Dcodename1.arg. does reach the builder from there; - # verified by reading the constant pool the generated stub indexes into, - # which is the only place the value is observable (the stub itself stores - # pool INDICES and is byte-identical between generations). + # nowhere. + # + # SETTING THE HINT IS NOT ENOUGH, and the ways it silently does nothing all + # look identical from here: two runs whose scores match to the last decimal, + # which reads as "the theme change had no effect" rather than "the theme was + # never loaded". Every one of these was hit in practice: + # + # - The suite installs its own theme (FidelityDeviceRunner.resolveThemeResource) + # instead of going through IOSImplementation.installNativeTheme, and used + # to hardcode generation 26. It now asks the port. + # - iOS has no generic build-hint bridge, so the hint only reaches the device + # as a static setter IPhoneBuilder writes into the generated stub. A stale + # codenameone-maven-plugin simply omits it. + # - The inner mvnw resolves that plugin from whatever localRepository is + # configured -- a machine-wide /tmp/cn1-local-repo shared between checkouts + # will hand it another checkout's jar. Pass -Dmaven.repo.local here too. + # - The generated stub is a plugin OUTPUT, and Maven does not know a plugin + # change invalidates it; an incremental build happily reuses the old stub. + # + # Checking that the hint's VALUE appears in the generated constant pool proves + # none of this -- it only proves the string was interned. The two checks that + # actually settle it: + # + # grep setIosThemeGeneration /*-src/*Stub.m + # grep 'installed theme' artifacts/ios-fidelity/simctl-log.txt case "${CN1SS_FIDELITY_GOLDEN_SET:-}" in ios-27-*) export IOS_DEPENDENCY_ARGS="${IOS_DEPENDENCY_ARGS:-} -Dcodename1.arg.ios.themeMode=modern -Dcodename1.arg.ios.themeGeneration=27" diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityDeviceRunner.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityDeviceRunner.java index 43540da4d38..33c36411f02 100644 --- a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityDeviceRunner.java +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityDeviceRunner.java @@ -943,6 +943,18 @@ private String resolveThemeResource() { return forced; } if ("ios".equals(platform)) { + // Ask the PORT which generation this build selected rather than + // hardcoding 26. The suite installs the theme itself instead of going + // through IOSImplementation.installNativeTheme, so the ios.themeGeneration + // build hint had no effect here at all: an ios-27-metal run built with + // ios.themeGeneration=27 still scored the iOS 26 theme against the iOS 27 + // goldens, and the two runs produced byte-identical scores -- which reads + // as "the theme change did nothing" rather than as "the theme was never + // loaded". Answered by IOSImplementation.getProperty. + String resource = Display.getInstance().getProperty("cn1.nativeThemeResource", null); + if (resource != null && resource.length() > 0) { + return resource; + } return "/iOSModernTheme.res"; } if (platform != null && platform.startsWith("and")) { From fd393dc5180e62bc58d2f928158009a7d677cee9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:30:35 +0300 Subject: [PATCH 2/3] Point the fidelity PR trigger at the theme files that exist The pull_request filter still named native-themes/ios-modern/theme.css, which the generation split replaced with common.css + gen26.css + gen27.css. From that merge until now NO pull request could trigger the iOS fidelity suite: the filter matched a path nothing can touch. It went unnoticed for the reason these always do -- the splitting PR itself matched, because deleting a path counts as touching it, so the last PR the gate ran on was the one that broke it. This PR is what surfaced it: it edits gen27.css, FidelityDeviceRunner and build-fidelity-app.sh and CI ran 57 checks with Fidelity not among them. Globbed by directory rather than relisting three filenames, so the next part file is covered when it is added instead of when someone notices. native-themes//target/ is the concatenated build input and is gitignored, so it never appears in a pull request and cannot widen this. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/scripts-fidelity.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scripts-fidelity.yml b/.github/workflows/scripts-fidelity.yml index b018d30b83c..9c3455098e9 100644 --- a/.github/workflows/scripts-fidelity.yml +++ b/.github/workflows/scripts-fidelity.yml @@ -15,8 +15,20 @@ name: Native theme fidelity workflow_dispatch: pull_request: paths: - - 'native-themes/ios-modern/theme.css' - - 'native-themes/android-material/theme.css' + # One entry per THEME, not per file: ios-modern is built from + # common.css + gen26.css + gen27.css since the generation split, and this + # list still named the theme.css that split removed -- so from that merge + # until this fix, NO pull request could trigger the iOS fidelity suite at + # all. It went unnoticed because the splitting PR itself matched: deleting + # a path counts as touching it, so the last PR this gate ran on was the one + # that broke it. + # + # Globbed by directory so the next part file is covered on the day it is + # added rather than the day someone notices. native-themes//target/ + # is the concatenated build input and is gitignored, so it never appears in + # a pull request and cannot widen this. + - 'native-themes/ios-modern/**' + - 'native-themes/android-material/**' push: branches: [master] paths: From 56a2de2bbde90efb01489837fc59d7e578ea9225 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:21:33 +0300 Subject: [PATCH 3/3] Address the four review findings on the iOS 27 glass change All four are real; two of them are traps I hit while tuning and fixed only in a scratch script instead of in the tree. 1. build-fidelity-app.sh did not invalidate the generated Xcode project when the theme generation changed. CN1BuildMojo.doIOSLocalBuild() decides whether to regenerate from source timestamps alone, so a command-line build-hint change never reaches it: the stub keeps its previous setIosThemeGeneration call and the run scores one generation against the other generation's goldens, silently. This is exactly what produced three consecutive byte-identical score sets while the fitted constants were being measured. A stamp file next to the generated project now records the generation it was produced for, and a mismatch -- or no stamp at all, since that project's provenance is unknown -- drops it. Verified over all four transitions plus the no-op case. 2. The same wrapper did not forward the local Maven repository, so the inner mvnw resolved 8.0-SNAPSHOT from whatever the machine's settings name. On a box with several checkouts that is a shared directory another checkout last wrote, and a plugin from there simply omits setIosThemeGeneration. It now forwards CN1_LOCAL_REPO when set. Deliberately propagated rather than defaulted: CI installs into the repository its own cache restores and sets nothing here, so imposing a per-checkout default would point the inner build at an empty directory. 3. GlassRecipeTest did not actually pin the name lookup. named() answers the panel recipe for anything it does not recognise, so asserting non-null passes with every chrome27/pill27/panel27 branch deleted. Measured before fixing: with the chrome27 branch removed, all six tests still passed -- a control that did not constrain the thing it existed for. Each name is now compared against the recipe it must return, on kind and all five parameters, plus a dual test that every name is materially distinguishable from the fallback so the comparison cannot become vacuous. Re-measured after: the same deletion now fails two tests naming chrome27 explicitly. 4. cn1.nativeThemeResource reported the requested generation-27 path even when the bundle does not carry it, bypassing the generation-26 fallback installNativeTheme applies two methods away -- and FidelityDeviceRunner trusts a non-empty answer and installs nothing when the stream is null. It now reports the resource that is actually present. Fixed in the property rather than by teaching the caller to second-guess it. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/impl/ios/IOSImplementation.java | 29 ++++++- .../codename1/ui/plaf/GlassRecipeTest.java | 75 +++++++++++++++++-- scripts/build-fidelity-app.sh | 47 ++++++++++++ 3 files changed, 145 insertions(+), 6 deletions(-) diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 42e6c016d64..25802934e38 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -3127,6 +3127,22 @@ private void injectListFocusStyle(Hashtable tp) { /// the whole time, and installNativeTheme()'s modern branch read that null /// as "the theme was never built" and fell back to iOS 7 -- silently, on /// every target, which is why ios.themeMode=modern appeared to do nothing. + /// True when the app bundle carries this resource. Opens and closes rather + /// than keeping the stream: the caller wants the NAME, and a stream left open + /// here would leak once per query. + private boolean hasResource(String name) { + InputStream in = getResourceAsStream(name); + if(in == null) { + return false; + } + try { + in.close(); + } catch(IOException err) { + // Nothing to do: the question was whether it exists, and it does. + } + return true; + } + private InputStream getResourceAsStream(String name) { return getResourceAsStream(IOSImplementation.class, name); } @@ -10640,8 +10656,19 @@ public String getProperty(String key, String defaultValue) { // The theme resource that generation selects, so a caller that wants the // file rather than the number does not have to re-derive the mapping and // risk disagreeing with installNativeTheme about it. + // + // Answers the resource that is actually PRESENT, applying the same + // generation-26 fallback installNativeTheme applies. Reporting the + // requested name unconditionally would hand a caller a path that is not + // in the bundle, and a caller that trusts the answer -- the fidelity + // runner does, and gives up when the stream is null -- would then install + // no theme at all, which is worse than the fallback this exists beside. if(key.equalsIgnoreCase("cn1.nativeThemeResource")) { - return "/" + modernThemeResourceName() + ".res"; + String want = "/" + modernThemeResourceName() + ".res"; + if(!"/iOSModernTheme.res".equals(want) && !hasResource(want)) { + return "/iOSModernTheme.res"; + } + return want; } if(key.equalsIgnoreCase("OS")) { return "iOS"; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/GlassRecipeTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/GlassRecipeTest.java index d17690960cc..f6a0e98f09c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/GlassRecipeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/GlassRecipeTest.java @@ -67,11 +67,52 @@ public class GlassRecipeTest extends UITestBase { } @Test - public void namedResolvesEveryDocumentedName() { - for (String name : KNOWN) { - for (int i = 0; i < 2; i++) { - boolean dark = i == 1; - assertNotNull(GlassRecipe.named(name, dark), name + " did not resolve"); + public void everyDocumentedNameResolvesToItsOwnRecipe() { + // NOT an assertNotNull sweep. named() answers the panel recipe for + // anything it does not recognise, and panel is not null -- so a non-null + // assertion passes even with every chrome27/pill27/panel27 branch deleted + // from the lookup, which is exactly the regression that would silently + // paint the toolbar with the wrong material. Measured: with the chrome27 + // branch removed, all six tests in this class still passed. Each name + // must therefore be compared against the recipe it is supposed to return, + // on every parameter. + for (int i = 0; i < 2; i++) { + boolean dark = i == 1; + assertSameRecipe(GlassRecipe.plainBlur(), GlassRecipe.named("blur", dark), + "blur/" + appearance(dark)); + assertSameRecipe(GlassRecipe.liquidChrome(dark), GlassRecipe.named("chrome", dark), + "chrome/" + appearance(dark)); + assertSameRecipe(GlassRecipe.liquidPill(dark), GlassRecipe.named("pill", dark), + "pill/" + appearance(dark)); + assertSameRecipe(GlassRecipe.liquidPanel(dark), GlassRecipe.named("panel", dark), + "panel/" + appearance(dark)); + assertSameRecipe(GlassRecipe.liquidChrome27(dark), GlassRecipe.named("chrome27", dark), + "chrome27/" + appearance(dark)); + assertSameRecipe(GlassRecipe.liquidPill27(dark), GlassRecipe.named("pill27", dark), + "pill27/" + appearance(dark)); + assertSameRecipe(GlassRecipe.liquidPanel27(dark), GlassRecipe.named("panel27", dark), + "panel27/" + appearance(dark)); + } + } + + @Test + public void everyKnownNameIsDistinguishableFromTheFallback() { + // The dual of the test above, and what makes it airtight: if a name + // happened to be materially equal to the panel fallback, comparing it to + // its factory could not tell "the branch exists" from "the branch is + // gone". Every name except panel itself must differ from the fallback in + // at least one parameter. + for (int i = 0; i < 2; i++) { + boolean dark = i == 1; + GlassRecipe fallback = GlassRecipe.named("no-such-material", dark); + for (String name : KNOWN) { + if ("panel".equals(name)) { + continue; + } + assertTrue(differs(fallback, GlassRecipe.named(name, dark)), + name + "/" + appearance(dark) + " is materially identical to the" + + " unknown-name fallback, so no test can prove its lookup" + + " branch is still wired up"); } } } @@ -162,6 +203,30 @@ public void everyRecipeNamedByAShippedThemeResolves() throws Exception { } } + private static String appearance(boolean dark) { + return dark ? "dark" : "light"; + } + + /// Full identity: kind and all five material parameters. + private static void assertSameRecipe(GlassRecipe expected, GlassRecipe actual, String what) { + assertNotNull(actual, what + " did not resolve"); + assertEquals(expected.getKind(), actual.getKind(), what + " kind"); + assertEquals(expected.getSaturation(), actual.getSaturation(), 0.0001f, what + " saturation"); + assertEquals(expected.getScale(), actual.getScale(), 0.0001f, what + " scale"); + assertEquals(expected.getOffset(), actual.getOffset(), 0.0001f, what + " offset"); + assertEquals(expected.getRefraction(), actual.getRefraction(), 0.0001f, what + " refraction"); + assertEquals(expected.getSpecular(), actual.getSpecular(), 0.0001f, what + " specular"); + } + + private static boolean differs(GlassRecipe a, GlassRecipe b) { + return a.getKind() != b.getKind() + || a.getSaturation() != b.getSaturation() + || a.getScale() != b.getScale() + || a.getOffset() != b.getOffset() + || a.getRefraction() != b.getRefraction() + || a.getSpecular() != b.getSpecular(); + } + private static void assertSameMaterial(GlassRecipe a, GlassRecipe b, String what) { assertEquals(a.getSaturation(), b.getSaturation(), 0.0001f, what + " saturation"); assertEquals(a.getScale(), b.getScale(), 0.0001f, what + " scale"); diff --git a/scripts/build-fidelity-app.sh b/scripts/build-fidelity-app.sh index 2674800f435..22b397e4b17 100755 --- a/scripts/build-fidelity-app.sh +++ b/scripts/build-fidelity-app.sh @@ -51,12 +51,59 @@ case "$PLATFORM" in # # grep setIosThemeGeneration /*-src/*Stub.m # grep 'installed theme' artifacts/ios-fidelity/simctl-log.txt + ios_generation=26 case "${CN1SS_FIDELITY_GOLDEN_SET:-}" in ios-27-*) + ios_generation=27 export IOS_DEPENDENCY_ARGS="${IOS_DEPENDENCY_ARGS:-} -Dcodename1.arg.ios.themeMode=modern -Dcodename1.arg.ios.themeGeneration=27" echo "[build-fidelity-app] golden set ${CN1SS_FIDELITY_GOLDEN_SET}: building with ios.themeGeneration=27" >&2 ;; esac + + # Forward the local repository the OUTER build is using. The inner mvnw + # otherwise resolves 8.0-SNAPSHOT from whatever localRepository is + # configured for the machine, which on a box with several checkouts is a + # shared directory another checkout last wrote -- and a plugin from there + # simply omits setIosThemeGeneration, leaving the port on generation 26 + # while this script announces an iOS 27 build. + # + # Propagated, never invented: CI installs into the repository its own cache + # restores and sets nothing here, so imposing a per-checkout default would + # point the inner build at an empty directory and break it. Set + # CN1_LOCAL_REPO to the repository that holds the artifacts you just built. + if [ -n "${CN1_LOCAL_REPO:-}" ]; then + export IOS_DEPENDENCY_ARGS="${IOS_DEPENDENCY_ARGS:-} -Dmaven.repo.local=${CN1_LOCAL_REPO}" + echo "[build-fidelity-app] inner build uses maven.repo.local=${CN1_LOCAL_REPO}" >&2 + fi + + # Drop a generated Xcode project that was produced for a DIFFERENT theme + # generation. CN1BuildMojo.doIOSLocalBuild() decides whether to regenerate + # from source timestamps alone, so a build-hint change on the command line + # does not reach it: the stub keeps its previous setIosThemeGeneration call, + # the runner trusts that stale value and installs the other generation's + # .res, and the run scores one generation against the other's goldens with + # nothing in the output saying so. Measured while tuning gen27.css -- three + # consecutive runs produced byte-identical scores for exactly this reason. + # + # An existing project with NO stamp is also dropped: its generation is + # unknown, and one extra regeneration is cheaper than a silently mismatched + # run. + ios_target="$SCRIPT_DIR/fidelity-app/ios/target" + ios_stamp="$ios_target/.cn1-theme-generation" + if [ -d "$ios_target" ]; then + if [ ! -f "$ios_stamp" ] || [ "$(cat "$ios_stamp" 2>/dev/null)" != "$ios_generation" ]; then + for stale in "$ios_target"/*-ios-source; do + [ -e "$stale" ] || continue + echo "[build-fidelity-app] generation changed -> removing $stale" >&2 + rm -rf "$stale" + done + fi + # Written before the build rather than after, so the stamp always + # describes the project on disk: the project is absent at this point, so a + # build that fails part way leaves "absent or generation N", never a + # generation-N stamp over a generation-M project. + printf '%s\n' "$ios_generation" > "$ios_stamp" + fi exec "$SCRIPT_DIR/build-ios-app.sh" "${@:2}" ;; *)