Retune the iOS 27 Liquid Glass materials, and make the theme generation reach the device - #5876
shai-almog wants to merge 3 commits into
Conversation
…on 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) <noreply@anthropic.com>
|
Developer Guide build artifacts are available for download from this workflow run:
Developer Guide quality checks: |
|
Compared 166 screenshots: 166 matched. Benchmark ResultsDetailed Performance Metrics
|
|
Compared 151 screenshots: 151 matched. Native Android coverage
✅ Native Android screenshot tests passed. Native Android coverage
Benchmark ResultsDetailed Performance Metrics
|
✅ Continuous Quality ReportTest & Coverage
Static Analysis
Generated automatically by the PR CI workflow. |
Cloudflare Preview
|
|
Compared 166 screenshots: 166 matched. |
|
Compared 166 screenshots: 166 matched. |
|
@codex review |
|
Compared 166 screenshots: 166 matched. Benchmark ResultsDetailed Performance Metrics
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8b0680ec26
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # - 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. |
There was a problem hiding this comment.
Invalidate the generated Xcode project when changing generations
When the same checkout is built for iOS 27 and then iOS 26 (or vice versa) without touching the fidelity app's sources, this wrapper leaves scripts/fidelity-app/ios/target/*-ios-source intact. CN1BuildMojo.doIOSLocalBuild() skips Xcode-project generation based only on source timestamps, so command-line build-hint changes do not regenerate the stub and its setIosThemeGeneration call retains the previous value. The newly changed runner then trusts that stale value and installs the wrong .res, silently scoring one generation against the other generation's goldens; remove the generated project or include the selected generation in the cache key before invoking build-ios-app.sh.
Useful? React with 👍 / 👎.
| # - 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. |
There was a problem hiding this comment.
Forward the workspace Maven repository to the inner build
When workspace setup installs the current 8.0-SNAPSHOT artifacts into a custom repository such as /tmp/cn1-local-repo, this wrapper neither preserves that repository setting nor forwards its positional Maven arguments to the inner mvnw; IOS_DEPENDENCY_ARGS receives only the theme hints. The inner build can therefore resolve an identically versioned plugin from the default repository or another checkout, and a stale plugin omits setIosThemeGeneration, leaving the port at generation 26 even while this script announces an iOS 27 build. Propagate the same local-repository setting into IOS_DEPENDENCY_ARGS or otherwise ensure the inner build resolves the artifacts installed by workspace setup.
Useful? React with 👍 / 👎.
| for (String name : KNOWN) { | ||
| for (int i = 0; i < 2; i++) { | ||
| boolean dark = i == 1; | ||
| assertNotNull(GlassRecipe.named(name, dark), name + " did not resolve"); |
There was a problem hiding this comment.
Assert each name resolves to its intended recipe
GlassRecipe.named() is documented to return a non-null panel fallback for every unknown string, so this assertion succeeds even if any or all of the newly added chrome27, pill27, and panel27 lookup branches are removed or misspelled. The shipped-theme test only compares values with the separate KNOWN set, while the material tests call the factory methods directly, so that exact lookup regression leaves the entire new suite green. Compare each named result's kind and parameters with its intended factory instead of testing only for non-null.
Useful? React with 👍 / 👎.
| if(key.equalsIgnoreCase("cn1.nativeThemeResource")) { | ||
| return "/" + modernThemeResourceName() + ".res"; |
There was a problem hiding this comment.
Preserve the native-theme fallback in the reported resource
When generation 27 is requested from a framework or development bundle that has not generated iOSModern27Theme.res, installNativeTheme() deliberately detects the missing file and falls back to iOSModernTheme.res, but this property unconditionally reports the missing generation-27 path. FidelityDeviceRunner treats every nonempty answer as authoritative and returns without installing any theme when openTheme() fails, so this new path bypasses the port's fallback and can leave the fidelity run on the previously installed theme. Report the actually available resource or make the runner apply the same generation-26 fallback.
Useful? React with 👍 / 👎.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Compared 181 screenshots: 181 matched. |
|
Compared 148 screenshots: 148 matched. Benchmark Results
Detailed Performance Metrics
|
|
Compared 160 screenshots: 160 matched. Benchmark Results
Detailed Performance Metrics
|
|
Compared 144 screenshots: 144 matched. |
|
Compared 149 screenshots: 149 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
|
Compared 217 screenshots: 217 matched. |
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/<theme>/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) <noreply@anthropic.com>
Native fidelity (Android, Material 3)54 pairs compared -- median 95.6%, worst 91.3% ( Distribution --
Geometry vs native (bbox offset / size ratio / center offset / corner radius) -- gated separately from the visual score
Side-by-side comparisons (worst first)
|
Native fidelity (iOS Modern, Metal)68 pairs compared -- median 95.0%, worst 83.5% ( Distribution --
Geometry vs native (bbox offset / size ratio / center offset / corner radius) -- gated separately from the visual score
Side-by-side comparisons (worst first)
|
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) <noreply@anthropic.com>
















































































































































































































































gen27.csswas empty, soios.themeGeneration=27selected 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 committedgoldens/ios-26-metaltile inverts back to the backdrop that produced it, and the matchinggoldens/ios-27-metalpixel 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.9against1.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 rather than 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, not noise. No choice of sat/scale/offset can express it. 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.liquidPanel27records that rather than leaving it to be rediscovered.Measured effect
iPhone 16 / iOS 27.0 simulator, scored against
goldens/ios-27-metal, 68 tiles:15 tiles improved, 51 unchanged, 2 regressed by 0.3 (TextField dark, within the documented non-affine dark error). The six dark glass panels gained 21–24 points each.
Why none of that was visible at first
Three runs returned byte-identical scores before anything could be measured. Four independent things each make the hint inert while every build stays green, and all four look identical from outside — "the theme change did nothing" rather than "the theme was never loaded":
FidelityDeviceRunner.resolveThemeResource) instead of going throughinstallNativeTheme, and hardcoded generation 26;IPhoneBuilderwrites into the generated stub — a stale plugin omits it;mvnwresolves that plugin from whateverlocalRepositoryis configured, and a shared one hands over another checkout's jar;The port now answers
ios.themeGenerationandcn1.nativeThemeResourcefromgetProperty, and the runner asks it.build-fidelity-app.shrecords the other three and replaces its earlier claim that reading the constant pool verified the hint — that only proves the string was interned.Test
GlassRecipeTestis 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.Not in this change
ios-26-metal-frameshas 24 frames and-animhas 7;ios-27-metalhas the 68 static tiles and nothing else, so animation validation fails on missing goldens. Capturing them needsrecord-ios-native-anim.shon the iOS 27 runtime and is tap-driven for tabs.ios-27-metalbaseline is therefore not recorded. Separately,FidelityGaterefuses the update because geometry cannot be extracted forGlassPanelGrey_normal_darkandGlassPanelRed_normal_dark— the iOS 27 dark panel over mid-grey computes to ≈128 against a 128 backdrop, so the widget is genuinely edgeless, and the native golden agrees (#818181panel over#808080). That is a harness limitation a correct theme exposes, not a bad constant.gen26.cssis untouched;verify-native-theme-split.shstill passes byte-for-byte.🤖 Generated with Claude Code