diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml
index a7156e0ae83..22196585d8b 100644
--- a/.github/workflows/scripts-javascript.yml
+++ b/.github/workflows/scripts-javascript.yml
@@ -13,6 +13,7 @@ on:
- 'scripts/run-javascript-headless-browser.mjs'
- 'scripts/run-javascript-lifecycle-tests.mjs'
- 'scripts/run-javascript-lifecycle-tests.sh'
+ - 'scripts/verify-javascript-lens-parity.mjs'
- 'scripts/build-javascript-port-hellocodenameone.sh'
- 'scripts/javascript_browser_harness.py'
- 'scripts/javascript/screenshots/**'
@@ -35,6 +36,7 @@ on:
- 'scripts/run-javascript-headless-browser.mjs'
- 'scripts/run-javascript-lifecycle-tests.mjs'
- 'scripts/run-javascript-lifecycle-tests.sh'
+ - 'scripts/verify-javascript-lens-parity.mjs'
- 'scripts/build-javascript-port-hellocodenameone.sh'
- 'scripts/javascript_browser_harness.py'
- 'scripts/javascript/screenshots/**'
@@ -141,6 +143,9 @@ jobs:
with:
node-version: '20'
+ - name: Verify JavaScript glass-tab lens parity
+ run: node scripts/verify-javascript-lens-parity.mjs
+
- name: Cache npm modules for scripts/
uses: actions/cache@v5
with:
diff --git a/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java b/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java
index dd5272ee2e3..b1fab8a9227 100644
--- a/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java
+++ b/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java
@@ -688,18 +688,28 @@ private void fillShape(Graphics g, int color, int opacity, int width, int height
return;
}
if (stroke && this.stroke != null && strokeGradient && strokeAngle == 360 && g.isShapeClipSupported()) {
- // Gradient-stroked circle: fill the full circle with the stroke
- // gradient, then lay the background over an inset circle so only
- // a `sw`-wide gradient ring (the stroke) shows at the edge.
- int sw = (int) Math.ceil(this.stroke.getLineWidth());
+ // Gradient-stroked circle, in three passes that hold on every
+ // port: (1) the background fills the FULL circle, (2) the
+ // translucent gradient paints over it clipped to the same
+ // circle, (3) the interior is restored clipped to the inset
+ // circle. Passes 2 and 3 go through the same clip+paint
+ // rasterizer, so the leftover `sw`-wide gradient ring has
+ // registration-stable edges even at hairline widths (the old
+ // gradient-fill-then-plain-inner-fill pair mixed two different
+ // rasterizers and read as a broken, muddy stroke), and because
+ // the background lies beneath it the ring glints over the fill
+ // instead of the backdrop. A multi-contour winding ring is NOT
+ // portable: the iOS/Metal clip unions subpaths.
+ int sw = Math.max(1, (int) Math.ceil(this.stroke.getLineWidth()));
GeneralPath outer = new GeneralPath();
outer.arc(x, y, size, size, 0, 2 * Math.PI);
- GeneralPath innerBg = new GeneralPath();
- innerBg.arc(x + sw, y + sw, size - sw * 2, size - sw * 2, 0, 2 * Math.PI);
+ g.fillShape(outer);
g.setColor(makeStrokePaint(width, height));
g.setAlpha(strokeOpacity);
g.fillShape(outer);
- g.setColor(color);
+ GeneralPath innerBg = new GeneralPath();
+ innerBg.arc(x + sw, y + sw, size - sw * 2, size - sw * 2, 0, 2 * Math.PI);
+ g.setColor(new SolidPaint(color));
g.setAlpha(opacity);
g.fillShape(innerBg);
} else if (stroke && this.stroke != null) {
@@ -720,15 +730,17 @@ private void fillShape(Graphics g, int color, int opacity, int width, int height
} else {
float sw = (stroke && this.stroke != null) ? this.stroke.getLineWidth() : 0;
if (stroke && this.stroke != null && strokeGradient && g.isShapeClipSupported()) {
- // Gradient-stroked pill: fill the full pill with the stroke
- // gradient, then lay the background over an inset pill so only a
- // `sw`-wide gradient ring (the stroke) shows at the edge.
+ // Gradient-stroked pill; see the circle branch above for why
+ // this is three passes (background, gradient over it, interior
+ // restored) with the last two on the same clip+paint rasterizer
+ // rather than a multi-contour winding ring.
GeneralPath outer = createPillPath(width, height, 0);
- GeneralPath innerBg = createPillPath(width, height, sw);
+ g.fillShape(outer);
g.setColor(makeStrokePaint(width, height));
g.setAlpha(strokeOpacity);
g.fillShape(outer);
- g.setColor(color);
+ GeneralPath innerBg = createPillPath(width, height, Math.max(1f, sw));
+ g.setColor(new SolidPaint(color));
g.setAlpha(opacity);
g.fillShape(innerBg);
return;
@@ -784,6 +796,29 @@ private Paint makeStrokePaint(float width, float height) {
MultipleGradientPaint.ColorSpaceType.SRGB, null);
}
+ /// A solid-colour Paint. Filling through the clip+paint mechanism (rather
+ /// than a plain path fill) keeps the gradient stroke's inner edge on the
+ /// SAME rasterizer as the gradient pass, so the hairline ring between them
+ /// stays registration-stable on every port.
+ private static final class SolidPaint implements Paint {
+ private final int solidColor;
+
+ SolidPaint(int solidColor) {
+ this.solidColor = solidColor;
+ }
+
+ @Override
+ public void paint(Graphics g, com.codename1.ui.geom.Rectangle2D bounds) {
+ paint(g, bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight());
+ }
+
+ @Override
+ public void paint(Graphics g, double x, double y, double w, double h) {
+ g.setColor(solidColor);
+ g.fillRect((int) x, (int) y, (int) Math.ceil(w), (int) Math.ceil(h));
+ }
+ }
+
@Override
public boolean isBackgroundPainter() {
return true;
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 9a77893b38b..f64b2378e50 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -9128,6 +9128,25 @@ public void componentHidden(ComponentEvent e) {
Display.getInstance().setDefaultVirtualKeyboard(null);
}
+ // Deterministic visual probes sometimes need to render at the physical
+ // density of a reference device while keeping the Simulator window small
+ // enough to fit the host display. A skin normally supplies this value via
+ // ppi/pixelRatio; this opt-in override decouples density from window chrome
+ // and is intentionally applied after skin/desktop-mode initialization.
+ String forcedPixelMilliRatio = System.getProperty("cn1.javase.pixelMilliRatio");
+ if (forcedPixelMilliRatio != null) {
+ try {
+ double ratio = Double.parseDouble(forcedPixelMilliRatio.trim());
+ if (ratio > 0) {
+ pixelMilliRatio = Double.valueOf(ratio);
+ setDefaultPixelMilliRatio(pixelMilliRatio);
+ }
+ } catch (NumberFormatException err) {
+ System.err.println("Ignoring invalid cn1.javase.pixelMilliRatio="
+ + forcedPixelMilliRatio);
+ }
+ }
+
float factor = ((float) getDisplayHeight()) / 480.0f;
if (factor > 0 && autoAdjustFontSize && getSkin() != null) {
// set a reasonable default font size
@@ -17200,7 +17219,180 @@ public boolean blurRegion(Object graphics, int x, int y, int width, int height,
return false;
}
}
-
+
+ /// The full iOS 26 "Liquid Glass" material for backdrop-filter surfaces (the
+ /// floating tab-bar pill, glass buttons). Mirrors IOSImplementation.glassRegion's
+ /// offscreen branch step for step: read the backdrop PADDED and edge-replicated,
+ /// apply the affine colour material (saturation/scale/offset), Gaussian-blur,
+ /// then apply the rounded-shape OPTICS (edge refraction + specular rim + AA mask)
+ /// and composite the pill-shaped patch back. Without this the simulator fell
+ /// back to a plain blur, leaving the bar a flat grey where the device renders a
+ /// bright frost -- every glass-tab comparison read "murky" purely because of the
+ /// missing material under the lens.
+ @Override
+ public boolean glassRegion(Object graphics, int x, int y, int width, int height, float radius,
+ float cornerRadius, float sat, float scaleParam, float offset, float refract, float specular) {
+ if (radius <= 0f || width <= 0 || height <= 0) {
+ return true;
+ }
+ try {
+ BufferedImage dest;
+ double scale;
+ if (graphics instanceof NativeScreenGraphics) {
+ NativeScreenGraphics ng = (NativeScreenGraphics) graphics;
+ if (ng.sourceImage != null) {
+ dest = ng.sourceImage;
+ scale = 1.0;
+ } else if (canvas != null && canvas.edtBuffer != null) {
+ dest = canvas.edtBuffer;
+ scale = (double) dest.getWidth() / getDisplayWidthImpl();
+ } else {
+ return false;
+ }
+ } else if (mutableImageGraphics.containsKey(graphics)) {
+ dest = mutableImageGraphics.get(graphics);
+ scale = 1.0;
+ } else {
+ return false;
+ }
+ int rx = (int) Math.round(x * scale);
+ int ry = (int) Math.round(y * scale);
+ int rw = (int) Math.round(width * scale);
+ int rh = (int) Math.round(height * scale);
+ int dw = dest.getWidth(), dh = dest.getHeight();
+ if (rx < 0) { rw += rx; rx = 0; }
+ if (ry < 0) { rh += ry; ry = 0; }
+ if (rx + rw > dw) { rw = dw - rx; }
+ if (ry + rh > dh) { rh = dh - ry; }
+ if (rw <= 0 || rh <= 0) {
+ return true;
+ }
+ float scaledRadius = (float) (radius * scale);
+ float scaledCorner = cornerRadius < 0 ? -1f : (float) (cornerRadius * scale);
+ // Pad by 3*radius of edge-replicated backdrop so the blur's edge fade is
+ // fully contained outside the component (see the iOS port commentary).
+ int pad = (int) Math.ceil(scaledRadius) * 3 + 1;
+ int bw = rw + 2 * pad, bh = rh + 2 * pad;
+ int ax0 = Math.max(0, rx - pad), ay0 = Math.max(0, ry - pad);
+ int ax1 = Math.min(dw, rx + rw + pad), ay1 = Math.min(dh, ry + rh + pad);
+ int aw = ax1 - ax0, ah = ay1 - ay0;
+ int[] avail = dest.getRGB(ax0, ay0, aw, ah, null, 0, aw);
+ int[] prgb = new int[bw * bh];
+ for (int by = 0; by < bh; by++) {
+ int ay = (ry - pad + by) - ay0;
+ if (ay < 0) ay = 0; else if (ay >= ah) ay = ah - 1;
+ int arow = ay * aw, brow = by * bw;
+ for (int bx = 0; bx < bw; bx++) {
+ int ax = (rx - pad + bx) - ax0;
+ if (ax < 0) ax = 0; else if (ax >= aw) ax = aw - 1;
+ prgb[brow + bx] = avail[arow + ax];
+ }
+ }
+ glassMaterialInPlace(prgb, sat, scaleParam, offset);
+ BufferedImage padded = new BufferedImage(bw, bh, BufferedImage.TYPE_INT_ARGB);
+ padded.setRGB(0, 0, bw, bh, prgb, 0, bw);
+ BufferedImage blurredPadded = new GaussianFilter(scaledRadius).filter(padded, null);
+ int[] pbargb = blurredPadded.getRGB(0, 0, bw, bh, null, 0, bw);
+ int[] out = new int[rw * rh];
+ applyGlassOptics(pbargb, bw, bh, pad, out, rw, rh, scaledCorner, refract, specular);
+ BufferedImage patch = new BufferedImage(rw, rh, BufferedImage.TYPE_INT_ARGB);
+ patch.setRGB(0, 0, rw, rh, out, 0, rw);
+ Graphics2D dg = dest.createGraphics();
+ dg.drawImage(patch, rx, ry, null);
+ dg.dispose();
+ return true;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ /// The reverse-engineered iOS Liquid Glass colour material; mirrors
+ /// IOSImplementation.glassMaterialInPlace (validated <1 LSB against a real
+ /// UIVisualEffectView): c' = clamp((lum + (c - lum) * sat) * scale + offset).
+ private static void glassMaterialInPlace(int[] argb, float sat, float scale, float offset) {
+ for (int i = 0; i < argb.length; i++) {
+ int p = argb[i];
+ int a = p & 0xff000000;
+ float r = (p >> 16) & 0xff, g = (p >> 8) & 0xff, b = p & 0xff;
+ float lum = 0.2126f * r + 0.7152f * g + 0.0722f * b;
+ r = (lum + (r - lum) * sat) * scale + offset;
+ g = (lum + (g - lum) * sat) * scale + offset;
+ b = (lum + (b - lum) * sat) * scale + offset;
+ int ri = r < 0 ? 0 : (r > 255 ? 255 : (int) r);
+ int gi = g < 0 ? 0 : (g > 255 ? 255 : (int) g);
+ int bi = b < 0 ? 0 : (b > 255 ? 255 : (int) b);
+ argb[i] = a | (ri << 16) | (gi << 8) | bi;
+ }
+ }
+
+ /// Liquid Glass optics over the blurred material; mirrors
+ /// IOSImplementation.applyGlassOptics: rounded-rect SDF drives an edge
+ /// refraction (quarter-circle displacement toward the centre), a specular rim
+ /// glint (brightest at the top) and the anti-aliased shape mask.
+ private static void applyGlassOptics(int[] src, int bw, int bh, int pad, int[] out,
+ int rw, int rh, float cornerRadius, float refract, float specular) {
+ float hw = rw / 2f, hh = rh / 2f;
+ float r = cornerRadius < 0f ? Math.min(hw, hh) : Math.min(cornerRadius, Math.min(hw, hh));
+ if (r < 0f) r = 0f;
+ float band = Math.min(hw, hh) * 0.6f; // refraction active in the outer 60%
+ float rimW = 3.0f; // specular rim width (px)
+ for (int y = 0; y < rh; y++) {
+ float py = y + 0.5f;
+ for (int x = 0; x < rw; x++) {
+ float px = x + 0.5f;
+ float dx = Math.abs(px - hw) - (hw - r);
+ float dy = Math.abs(py - hh) - (hh - r);
+ float ax = dx > 0 ? dx : 0, ay = dy > 0 ? dy : 0;
+ float outside = (float) Math.sqrt(ax * ax + ay * ay);
+ float inside = Math.min(Math.max(dx, dy), 0f);
+ float sdf = outside + inside - r;
+ float depth = -sdf;
+ if (depth <= 0f) { out[y * rw + x] = 0; continue; }
+ float alpha = depth >= 1f ? 1f : depth;
+ float sx = x, sy = y;
+ if (refract > 0f && band > 0f && depth < band) {
+ float t = 1f - depth / band;
+ float distortion = 1f - (float) Math.sqrt(Math.max(0f, 1f - t * t));
+ sx = x - (px - hw) * distortion * refract;
+ sy = y - (py - hh) * distortion * refract;
+ }
+ int col = glassSampleBilinear(src, bw, bh, sx + pad, sy + pad);
+ int rr = (col >> 16) & 0xff, gg = (col >> 8) & 0xff, bb = col & 0xff;
+ if (specular > 0f && depth < rimW) {
+ float rim = 1f - depth / rimW;
+ float topBias = 0.55f + 0.45f * (1f - py / rh);
+ int add = (int) (specular * rim * topBias * 70f);
+ rr = rr + add > 255 ? 255 : rr + add;
+ gg = gg + add > 255 ? 255 : gg + add;
+ bb = bb + add > 255 ? 255 : bb + add;
+ }
+ int a = (int) (alpha * 255f);
+ out[y * rw + x] = (a << 24) | (rr << 16) | (gg << 8) | bb;
+ }
+ }
+ }
+
+ /// Bilinear ARGB sample with edge clamping; mirrors IOSImplementation.sampleBilinear.
+ private static int glassSampleBilinear(int[] buf, int w, int h, float fx, float fy) {
+ if (fx < 0f) fx = 0f; else if (fx > w - 1) fx = w - 1;
+ if (fy < 0f) fy = 0f; else if (fy > h - 1) fy = h - 1;
+ int x0 = (int) fx, y0 = (int) fy;
+ int x1 = x0 + 1 < w ? x0 + 1 : x0, y1 = y0 + 1 < h ? y0 + 1 : y0;
+ float tx = fx - x0, ty = fy - y0;
+ int p00 = buf[y0 * w + x0], p10 = buf[y0 * w + x1];
+ int p01 = buf[y1 * w + x0], p11 = buf[y1 * w + x1];
+ int rr = glassBilerp((p00 >> 16) & 0xff, (p10 >> 16) & 0xff, (p01 >> 16) & 0xff, (p11 >> 16) & 0xff, tx, ty);
+ int gg = glassBilerp((p00 >> 8) & 0xff, (p10 >> 8) & 0xff, (p01 >> 8) & 0xff, (p11 >> 8) & 0xff, tx, ty);
+ int bb = glassBilerp(p00 & 0xff, p10 & 0xff, p01 & 0xff, p11 & 0xff, tx, ty);
+ return (rr << 16) | (gg << 8) | bb;
+ }
+
+ private static int glassBilerp(int c00, int c10, int c01, int c11, float tx, float ty) {
+ float top = c00 + (c10 - c00) * tx;
+ float bot = c01 + (c11 - c01) * tx;
+ return (int) (top + (bot - top) * ty + 0.5f);
+ }
+
// iOS-26 tab selection-DROP lens constants (shared shape with METALView.m
// glassApplyLens). Uniform magnification in the central MAG_FLAT fraction of the
// radius, smooth falloff to 1.0 at the rim; chromatic aberration at the rim only;
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java
index e86b2b9bd28..c2b77958776 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java
@@ -181,11 +181,18 @@ public void blurRegion(int x, int y, int width, int height, float radius, float
addOp(new com.codename1.impl.html5.graphics.BlurRegion(x, y, width, height, radius, cornerRadius));
}
+ @Override
+ public void glassRegion(int x, int y, int width, int height, float radius, float cornerRadius,
+ float saturation, float scale, float offset, float refraction, float specular) {
+ addOp(new com.codename1.impl.html5.graphics.GlassRegion(x, y, width, height,
+ radius, cornerRadius, saturation, scale, offset, refraction, specular));
+ }
+
@Override
public void lensRegion(int x, int y, int width, int height, float cornerRadius, float magnify,
- int tintColor, float tintStrength) {
+ float aberration, int tintColor, float tintStrength) {
addOp(new com.codename1.impl.html5.graphics.LensRegion(x, y, width, height, cornerRadius,
- magnify, tintColor, tintStrength));
+ magnify, aberration, tintColor, tintStrength));
}
@Override
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java
index 3a01664d4b2..67952773511 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java
@@ -456,14 +456,24 @@ public void blurRegion(int x, int y, int width, int height, float radius, float
dispatchOp(new com.codename1.impl.html5.graphics.BlurRegion(x, y, width, height, radius, cornerRadius));
}
+ /**
+ * In-place Liquid Glass material; see
+ * {@link com.codename1.impl.html5.graphics.GlassRegion}.
+ */
+ public void glassRegion(int x, int y, int width, int height, float radius, float cornerRadius,
+ float saturation, float scale, float offset, float refraction, float specular) {
+ dispatchOp(new com.codename1.impl.html5.graphics.GlassRegion(x, y, width, height,
+ radius, cornerRadius, saturation, scale, offset, refraction, specular));
+ }
+
/**
* In-place selection-drop lens (magnify + tint); see
* {@link com.codename1.impl.html5.graphics.LensRegion}.
*/
public void lensRegion(int x, int y, int width, int height, float cornerRadius, float magnify,
- int tintColor, float tintStrength) {
+ float aberration, int tintColor, float tintStrength) {
dispatchOp(new com.codename1.impl.html5.graphics.LensRegion(x, y, width, height, cornerRadius,
- magnify, tintColor, tintStrength));
+ magnify, aberration, tintColor, tintStrength));
}
public void clearRect(int x, int y, int width, int height) {
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 4bc1a656805..789b7a3679e 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -6414,21 +6414,26 @@ public boolean blurRegion(Object graphics, int x, int y, int width, int height,
@Override
public boolean glassRegion(Object graphics, int x, int y, int width, int height, float radius,
float cornerRadius, float sat, float scale, float offset, float refract, float specular) {
- // Plain backdrop blur clipped to the glass shape. The full Liquid Glass
- // colour transform (saturation/scale/offset/refraction/specular) is the
- // iOS Metal port's material; the browser gets the honest frost, which
- // is what backdrop-filter delivers on the web anyway.
- g(graphics).blurRegion(x, y, width, height, radius, cornerRadius);
+ // Preserve the complete named GlassRecipe. The host bridge applies the
+ // same affine colour material and rounded-shape optics as iOS around
+ // the browser's native Gaussian blur. Dropping these parameters made
+ // the tab-bar pill far more transparent than native.
+ g(graphics).glassRegion(x, y, width, height, radius, cornerRadius,
+ sat, scale, offset, refract, specular);
return true;
}
@Override
public boolean lensRegion(Object graphics, int x, int y, int width, int height, float cornerRadius,
float magnify, float aberration, int tintColor, float tintStrength) {
- // The iOS-26 tab selection drop: magnify the content bulge + accent
- // tint. Chromatic aberration (the iOS Metal shader's extra) is dropped
- // on the web; magnify + tint carries the recognisable effect.
- g(graphics).lensRegion(x, y, width, height, cornerRadius, magnify, tintColor, tintStrength);
+ // The host-side canvas bridge runs the same per-pixel lens as JavaSE:
+ // centre magnification, rim refraction, chromatic aberration, luminance-
+ // keyed accent tint and the subtle glass highlights/shadows. Keeping the
+ // complete parameter set here is important -- the old browser fallback
+ // reduced this to a uniform zoom plus a blue rectangle, which made every
+ // in-flight frame visibly harsher than the Simulator.
+ g(graphics).lensRegion(x, y, width, height, cornerRadius, magnify,
+ aberration, tintColor, tintStrength);
return true;
}
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/GlassRegion.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/GlassRegion.java
new file mode 100644
index 00000000000..9b0aea225d0
--- /dev/null
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/GlassRegion.java
@@ -0,0 +1,66 @@
+/*
+ * 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.impl.html5.graphics;
+
+import com.codename1.html5.js.canvas.CanvasRenderingContext2D;
+
+/**
+ * Records a complete named Liquid Glass material for ordered host-side replay.
+ * The host applies the native affine colour transform, browser Gaussian blur,
+ * rounded shape mask, edge refraction and specular rim to the surface's own
+ * already-painted pixels.
+ */
+public final class GlassRegion implements ExecutableOp {
+ private final int x, y, width, height;
+ private final float radius, cornerRadius, saturation, scale, offset, refraction, specular;
+
+ public GlassRegion(int x, int y, int width, int height, float radius, float cornerRadius,
+ float saturation, float scale, float offset, float refraction, float specular) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ this.radius = radius;
+ this.cornerRadius = cornerRadius;
+ this.saturation = saturation;
+ this.scale = scale;
+ this.offset = offset;
+ this.refraction = refraction;
+ this.specular = specular;
+ }
+
+ @Override
+ public void execute(CanvasRenderingContext2D context) {
+ if (!(context instanceof SurfaceCommandRecorder)) {
+ return;
+ }
+ ((SurfaceCommandRecorder) context).glassSelfRegion(x, y, width, height,
+ radius, cornerRadius, saturation, scale, offset, refraction, specular);
+ }
+
+ @Override
+ public String getDescription() {
+ return "GlassRegion";
+ }
+}
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/LensRegion.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/LensRegion.java
index 93977d56d66..8da4ebec0e0 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/LensRegion.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/LensRegion.java
@@ -28,28 +28,28 @@
/**
* In-place iOS 26 selection-drop lens for the Tabs glass indicator. Records
* {@link SurfaceCommandRecorder#OP_LENS_SELF_REGION}: the host clips to the
- * region, magnifies the surface's own pixels there (the content "bulge"),
- * then optionally washes them toward the accent tint.
+ * region and applies the same per-pixel droplet shader as the JavaSE
+ * Simulator: centre magnification, rim refraction, chromatic aberration,
+ * luminance-keyed accent tint, saturation and subtle glass lighting.
*
- *
The native iOS port renders this with a Metal fragment shader that also
- * applies chromatic aberration; the web keeps the magnify + tint, which is
- * the readable part of the drop. Nothing crosses the worker<->host
- * barrier - the magnify uses a self-referential {@code drawImage} during
- * host-side command replay.
+ * Nothing crosses the worker<->host barrier: the worker records the lens
+ * parameters and the host applies them to the destination canvas during
+ * ordered command replay.
*/
public class LensRegion implements ExecutableOp {
final int x, y, w, h;
- final float cornerRadius, magnify, tintStrength;
+ final float cornerRadius, magnify, aberration, tintStrength;
final int tintColor;
public LensRegion(int x, int y, int w, int h, float cornerRadius, float magnify,
- int tintColor, float tintStrength) {
+ float aberration, int tintColor, float tintStrength) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.cornerRadius = cornerRadius;
this.magnify = magnify;
+ this.aberration = aberration;
this.tintColor = tintColor;
this.tintStrength = tintStrength;
}
@@ -59,25 +59,8 @@ public void execute(CanvasRenderingContext2D context) {
if (!(context instanceof SurfaceCommandRecorder)) {
return;
}
- String tintCss = null;
- if (tintStrength > 0) {
- int r = (tintColor >> 16) & 0xff;
- int g = (tintColor >> 8) & 0xff;
- int b = tintColor & 0xff;
- // The model's tintStrength assumes the native lens's luminance
- // KEYING (only dark glyph pixels drift to accent; the bright frost
- // is untouched). On canvas we can't key without a forbidden pixel
- // read, so a flat fill at the full strength paints a solid accent
- // blob over the whole drop and buries the glyph. Scale it right
- // down so the fill reads as a translucent blue CAST over the still-
- // visible magnified content instead of covering it.
- double a = Math.min(0.30, Math.max(0.0, tintStrength) * 0.32);
- tintCss = "rgba(" + r + "," + g + "," + b + "," + a + ")";
- }
- // magnify < ~1 would shrink; clamp to at least 1 so the drop only ever
- // bulges (the model can pass values slightly under 1 at rest).
- double mag = Math.max(1.0, magnify);
- ((SurfaceCommandRecorder) context).lensSelfRegion(x, y, w, h, cornerRadius, mag, tintCss);
+ ((SurfaceCommandRecorder) context).lensSelfRegion(x, y, w, h, cornerRadius,
+ magnify, aberration, tintColor, tintStrength);
}
@Override
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/SurfaceCommandRecorder.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/SurfaceCommandRecorder.java
index 7eafb9043a1..365eb3e35a7 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/SurfaceCommandRecorder.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/SurfaceCommandRecorder.java
@@ -135,14 +135,17 @@ public final class SurfaceCommandRecorder implements CanvasRenderingContext2D {
// side-channel host call would race the flush.
public static final int OP_BLUR_SELF_REGION = 80; // 6 nums (x, y, w, h, sigmaPx, cornerRadius)
- // In-place iOS 26 selection-DROP lens: MAGNIFY the surface's own pixels in
- // the region (content bulge) then optionally wash them toward an accent
- // tint. The full chromatic aberration of the native Metal shader is
- // dropped on the web; magnify + tint carries the recognisable "drop".
- // 6 nums (x, y, w, h, cornerRadius, magnify) + 1 obj (tint css color or
- // null when tintStrength<=0).
+ // In-place iOS 26 selection-DROP lens. The host mirrors JavaSE's per-pixel
+ // lens, including aberration and luminance-keyed tint.
+ // 9 nums: x, y, w, h, cornerRadius, magnify, aberration, tintColor,
+ // tintStrength.
public static final int OP_LENS_SELF_REGION = 81;
+ // Complete named Liquid Glass material: affine colour transform + blur +
+ // rounded shape optics. 11 nums: x, y, w, h, blurRadius, cornerRadius,
+ // saturation, scale, offset, refraction, specular.
+ public static final int OP_GLASS_SELF_REGION = 82;
+
private int[] ops = new int[64];
private int opCount;
private double[] nums = new double[256];
@@ -296,9 +299,21 @@ public void blurSelfRegion(double x, double y, double w, double h, double sigmaP
op(OP_BLUR_SELF_REGION); num(x); num(y); num(w); num(h); num(sigmaPx); num(cornerRadius);
}
- /** Records an in-place selection-drop lens; see OP_LENS_SELF_REGION. tintCss null = no tint. */
- public void lensSelfRegion(double x, double y, double w, double h, double cornerRadius, double magnify, String tintCss) {
- op(OP_LENS_SELF_REGION); num(x); num(y); num(w); num(h); num(cornerRadius); num(magnify); obj(tintCss);
+ /** Records an in-place selection-drop lens; see OP_LENS_SELF_REGION. */
+ public void lensSelfRegion(double x, double y, double w, double h, double cornerRadius,
+ double magnify, double aberration, int tintColor, double tintStrength) {
+ op(OP_LENS_SELF_REGION);
+ num(x); num(y); num(w); num(h); num(cornerRadius); num(magnify);
+ num(aberration); num(tintColor); num(tintStrength);
+ }
+
+ /** Records a complete in-place Liquid Glass material; see OP_GLASS_SELF_REGION. */
+ public void glassSelfRegion(double x, double y, double w, double h, double blurRadius,
+ double cornerRadius, double saturation, double scale, double offset,
+ double refraction, double specular) {
+ op(OP_GLASS_SELF_REGION);
+ num(x); num(y); num(w); num(h); num(blurRadius); num(cornerRadius);
+ num(saturation); num(scale); num(offset); num(refraction); num(specular);
}
@Override public String getFilter() { return "none"; }
@Override public void clearRect(double x, double y, double width, double height) {
diff --git a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java
index 3582aa114bf..a52111d04d7 100644
--- a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java
+++ b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java
@@ -1,3 +1,26 @@
+/*
+ * 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.codenameone.playground;
final class PlaygroundExamples {
@@ -214,18 +237,18 @@ public void start() {
Form form = new Form("Tabs", new BorderLayout());
Tabs tabs = new Tabs();
tabs.addTab("Home",
- FontImage.createMaterial(FontImage.MATERIAL_HOME, "Tab", 4),
+ FontImage.MATERIAL_HOME, 4,
BoxLayout.encloseY(
new SpanLabel("Latest activity"),
new Label("3 new notifications"),
new Button("Open inbox")));
tabs.addTab("Search",
- FontImage.createMaterial(FontImage.MATERIAL_SEARCH, "Tab", 4),
+ FontImage.MATERIAL_SEARCH, 4,
BoxLayout.encloseY(
new TextField("", "Search anything"),
new SpanLabel("Results appear here")));
tabs.addTab("Profile",
- FontImage.createMaterial(FontImage.MATERIAL_PERSON, "Tab", 4),
+ FontImage.MATERIAL_PERSON, 4,
BoxLayout.encloseY(
new Label("Ada Lovelace"),
new Label("ada@analytical.engine"),
diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/README.md b/scripts/fidelity-app/goldens/ios-26-metal-anim/README.md
new file mode 100644
index 00000000000..d575d32ced3
--- /dev/null
+++ b/scripts/fidelity-app/goldens/ios-26-metal-anim/README.md
@@ -0,0 +1,28 @@
+# Animation references — how they are captured and what they answer
+
+- **`native-tabs-*.mov`** — the REAL `UITabBar` Liquid Glass selection morph,
+ recorded from the NativeRef app on the iOS 26 simulator runtime named by
+ this golden set (`scripts/record-ios-native-anim.sh tabs `).
+
+ **The recording MUST be tap-driven.** UIKit plays the full Liquid Glass
+ morph — the frosted refracting drop that bulges past the bar with chromatic
+ rims — only for genuine touch-driven selection on a `UITabBarController`;
+ programmatic `selectedIndex`/`selectedItem` changes play a flat simplified
+ platter slide, and a bare `UITabBar` never shows the full effect at all. An
+ earlier reference was mis-recorded through the programmatic path, and its
+ flat drop briefly led the CN1 tuning astray. The recording script now drives
+ real taps through the XCUITest bundle in
+ `../../ios-native-ref/tap-driver/` (requires `xcodegen`, like the
+ input-validation iOS driver).
+
+- **`cn1-tabs-*.mov`** — the Codename One morph in motion, rendered
+ deterministically from the JavaSE simulator at the reference density
+ (60 fps, the theme's 480 ms `tabsAnimatedIndicatorDurationInt` timeline,
+ 1088x290 tile). The CN1 side of the same motion, at the same tile the
+ `../ios-26-metal-frames/TabsMorph_*` goldens use.
+
+- **`native-switch-*.mov`** — the real `UISwitch` toggle; the switch morph is
+ not interaction-gated, so the self-animating recording path is fine there.
+
+The four lens/glass implementations (Metal shader, iOS CPU reference, JavaSE,
+JavaScript) are held together by `scripts/verify-javascript-lens-parity.mjs`.
diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-dark.mov b/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-dark.mov
new file mode 100644
index 00000000000..bfcc65a1989
Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-dark.mov differ
diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-light.mov b/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-light.mov
new file mode 100644
index 00000000000..8a0059df154
Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-light.mov differ
diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-dark.mov b/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-dark.mov
index cdbf0b70067..25e601e14b3 100644
Binary files a/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-dark.mov and b/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-dark.mov differ
diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-light.mov b/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-light.mov
index c4c7918998c..e3be1da86f3 100644
Binary files a/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-light.mov and b/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-light.mov differ
diff --git a/scripts/fidelity-app/ios-native-ref/NativeRef.swift b/scripts/fidelity-app/ios-native-ref/NativeRef.swift
index 35cd0bd73a9..c8f91ac70e7 100644
--- a/scripts/fidelity-app/ios-native-ref/NativeRef.swift
+++ b/scripts/fidelity-app/ios-native-ref/NativeRef.swift
@@ -1,3 +1,26 @@
+/*
+ * 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.
+ */
+
// Standalone native iOS app that renders each reference UIKit widget in a REAL
// UIWindow and captures a real screenshot of it (drawHierarchy:afterScreenUpdates),
// so navigation/tab bars and other views that render blank off-screen come out
@@ -442,25 +465,45 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
// Default: the tab bar selection morph, first tab -> last tab and back,
// mirroring the deterministic CN1 TabsMorph frames (travel 0 -> last).
- let wPt = 60.0 * PT_PER_MM
- let hPt = 16.0 * PT_PER_MM
- let bar = UITabBar(frame: CGRect(x: 0, y: 0, width: wPt, height: hPt))
- let a = UITabBarItem(title: "Featured", image: UIImage(systemName: "star.fill"), tag: 0)
- let b = UITabBarItem(title: "Search", image: UIImage(systemName: "magnifyingglass"), tag: 1)
- let c = UITabBarItem(title: "More", image: UIImage(systemName: "ellipsis"), tag: 2)
- bar.items = [a, b, c]
- bar.selectedItem = a
- let ap = UITabBarAppearance()
- ap.configureWithDefaultBackground()
- bar.standardAppearance = ap
- if #available(iOS 15.0, *) { bar.scrollEdgeAppearance = ap }
- bar.center = CGPoint(x: host.bounds.midX, y: host.bounds.midY)
- host.addSubview(bar)
+ //
+ // This MUST be a real UITabBarController, not a bare UITabBar with a
+ // custom UITabBarAppearance: the full iOS 26 Liquid Glass selection
+ // morph (the frosted refracting drop the Files app shows) is rendered
+ // only by the controller-managed bar with the DEFAULT appearance. The
+ // earlier bare-bar + configureWithDefaultBackground() version fell back
+ // to a flat compact platter, which mis-recorded the native reference
+ // and (in an earlier PR iteration) led the CN1 tuning astray.
+ guard let window = self.window else { return }
+ let grey = UIColor(white: 0.5, alpha: 1)
+ func page(_ title: String, _ systemImage: String, _ tag: Int) -> UIViewController {
+ let vc = UIViewController()
+ vc.view.backgroundColor = grey
+ vc.tabBarItem = UITabBarItem(title: title,
+ image: UIImage(systemName: systemImage), tag: tag)
+ return vc
+ }
+ let tbc = UITabBarController()
+ tbc.viewControllers = [
+ page("Featured", "star.fill", 0),
+ page("Search", "magnifyingglass", 1),
+ page("More", "ellipsis", 2)
+ ]
+ tbc.overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light
+ window.rootViewController = tbc
print("NATIVEREF:ANIMATING tabs \(appearance)")
- var toLast = true
- Timer.scheduledTimer(withTimeInterval: 1.4, repeats: true) { _ in
- bar.selectedItem = toLast ? c : a
- toLast = !toLast
+ // UIKit plays the FULL Liquid Glass selection morph (the frosted
+ // refracting drop) only for genuine touch-driven selection -- the drop
+ // lifts under the finger. Programmatic selectedIndex changes play a
+ // flat, simplified platter slide, which mis-recorded the native
+ // reference. The recording harness drives REAL taps via an XCUITest
+ // runner (record-ios-native-anim.sh); NATIVEREF_TAPS=0 restores the
+ // legacy self-animating timer for manual eyeballing.
+ if ProcessInfo.processInfo.environment["NATIVEREF_TAPS"] == "0" {
+ var toLast = true
+ Timer.scheduledTimer(withTimeInterval: 1.4, repeats: true) { _ in
+ tbc.selectedIndex = toLast ? 2 : 0
+ toLast = !toLast
+ }
}
}
diff --git a/scripts/fidelity-app/ios-native-ref/tap-driver/HostStub/App.swift b/scripts/fidelity-app/ios-native-ref/tap-driver/HostStub/App.swift
new file mode 100644
index 00000000000..3e76caf7bbd
--- /dev/null
+++ b/scripts/fidelity-app/ios-native-ref/tap-driver/HostStub/App.swift
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+
+// Minimal host application for the UI-test bundle. XCUITest requires a host
+// app target; the actual work happens in the test, which launches and taps
+// the NativeRef app.
+import UIKit
+
+@main
+class AppDelegate: UIResponder, UIApplicationDelegate {
+ var window: UIWindow?
+
+ func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
+ let w = UIWindow(frame: UIScreen.main.bounds)
+ w.rootViewController = UIViewController()
+ w.makeKeyAndVisible()
+ window = w
+ return true
+ }
+}
diff --git a/scripts/fidelity-app/ios-native-ref/tap-driver/Tests/TapDriverTests.swift b/scripts/fidelity-app/ios-native-ref/tap-driver/Tests/TapDriverTests.swift
new file mode 100644
index 00000000000..79358612e7b
--- /dev/null
+++ b/scripts/fidelity-app/ios-native-ref/tap-driver/Tests/TapDriverTests.swift
@@ -0,0 +1,51 @@
+/*
+ * 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.
+ */
+
+// Drives REAL taps on the NativeRef app's tab bar while the recording host
+// captures the simulator screen. Genuine touches are what make UIKit play the
+// full Liquid Glass selection morph -- see the project.yml commentary.
+import XCTest
+
+final class TapDriverTests: XCTestCase {
+ func testDriveTabs() {
+ let env = ProcessInfo.processInfo.environment
+ let app = XCUIApplication(bundleIdentifier: "com.codenameone.fidelity.nativeref")
+ app.launchEnvironment["NATIVEREF_MODE"] = "animate"
+ app.launchEnvironment["NATIVEREF_ANIM"] = "tabs"
+ app.launchEnvironment["NATIVEREF_APPEARANCE"] = env["TAPDRIVER_APPEARANCE"] ?? "light"
+ app.launch()
+ sleep(2)
+ let bar = app.tabBars.firstMatch
+ XCTAssertTrue(bar.waitForExistence(timeout: 10), "NativeRef tab bar not found")
+ let buttons = bar.buttons
+ XCTAssertTrue(buttons.count >= 2, "NativeRef tab bar has too few items")
+ // First tab -> last tab and back, matching the deterministic CN1
+ // TabsMorph frames (travel 0 -> last), with a settle pause between.
+ for _ in 0..<3 {
+ buttons.element(boundBy: buttons.count - 1).tap()
+ Thread.sleep(forTimeInterval: 1.4)
+ buttons.element(boundBy: 0).tap()
+ Thread.sleep(forTimeInterval: 1.4)
+ }
+ }
+}
diff --git a/scripts/fidelity-app/ios-native-ref/tap-driver/project.yml b/scripts/fidelity-app/ios-native-ref/tap-driver/project.yml
new file mode 100644
index 00000000000..d987730bc74
--- /dev/null
+++ b/scripts/fidelity-app/ios-native-ref/tap-driver/project.yml
@@ -0,0 +1,31 @@
+# XCUITest tap driver for the native animation recordings.
+#
+# UIKit plays the FULL iOS 26 Liquid Glass tab-selection morph (the frosted
+# refracting drop) only for genuine touch-driven selection; programmatic
+# selectedIndex changes play a flat simplified platter slide. The recording
+# therefore drives REAL taps on the NativeRef app's UITabBarController via
+# this tiny UI-test bundle (record-ios-native-anim.sh generates the Xcode
+# project with xcodegen -- same dependency as the input-validation driver).
+name: NativeRefTapDriver
+options:
+ bundleIdPrefix: com.codenameone.fidelity.tapdriver
+targets:
+ HostStub:
+ type: application
+ platform: iOS
+ deploymentTarget: "15.0"
+ sources: [HostStub]
+ settings:
+ PRODUCT_BUNDLE_IDENTIFIER: com.codenameone.fidelity.tapdriver.hoststub
+ GENERATE_INFOPLIST_FILE: YES
+ NativeRefTapDriverUITests:
+ type: bundle.ui-testing
+ platform: iOS
+ deploymentTarget: "15.0"
+ sources: [Tests]
+ dependencies:
+ - target: HostStub
+ settings:
+ PRODUCT_BUNDLE_IDENTIFIER: com.codenameone.fidelity.tapdriver.uitests
+ GENERATE_INFOPLIST_FILE: YES
+ TEST_TARGET_NAME: HostStub
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java
index 9482f98386a..f43afa00217 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java
@@ -151,6 +151,7 @@ private static int testTimeoutMs(BaseTest testClass) {
new MorphTransitionScrubScreenshotTest(),
new MorphElementMorphScreenshotTest(),
new TabsAnimatedIndicatorScreenshotTest(),
+ new TabsLiquidGlassAnimationScreenshotTest(),
new PullToRefreshSpinnerScreenshotTest(),
new AnimateLayoutScreenshotTest(),
new AnimateHierarchyScreenshotTest(),
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsLiquidGlassAnimationScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsLiquidGlassAnimationScreenshotTest.java
new file mode 100644
index 00000000000..28414fce920
--- /dev/null
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsLiquidGlassAnimationScreenshotTest.java
@@ -0,0 +1,232 @@
+/*
+ * 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.codenameone.examples.hellocodenameone.tests;
+
+import com.codename1.io.Util;
+import com.codename1.ui.Component;
+import com.codename1.ui.Container;
+import com.codename1.ui.Display;
+import com.codename1.ui.FontImage;
+import com.codename1.ui.Form;
+import com.codename1.ui.Graphics;
+import com.codename1.ui.Image;
+import com.codename1.ui.Tabs;
+import com.codename1.ui.layouts.BorderLayout;
+import com.codename1.ui.plaf.Style;
+import com.codename1.ui.plaf.UIManager;
+import com.codename1.ui.util.Resources;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/// Mac JavaSE frame probe for the iOS 26 UITabBar Liquid Glass selection morph.
+///
+/// The native reference video is 1178x2556. Its 60mm x 16mm tab tile is the
+/// centred 1088x290 crop at (45,1133). This test renders that exact tile size and
+/// emits every distinct frame from the first complete native last-to-first
+/// transition, including the long ease-out/settle tail, rather than scaling six
+/// samples into a contact sheet.
+///
+/// Native source frames (seconds):
+/// 1.163333 through 1.760000. Relative times are normalized to the recorded
+/// 350ms travel; the remaining frames deliberately hold the settled state so a
+/// lingering optical/shape effect cannot disappear from the comparison. Each
+/// emitted PNG remains full resolution so magnification, refraction, rim light,
+/// glyph lift, and antialiasing can be compared one frame at a time.
+public class TabsLiquidGlassAnimationScreenshotTest extends BaseTest {
+ private static final int TILE_WIDTH = 1088;
+ private static final int TILE_HEIGHT = 290;
+ private static final int[] FRAME_MS = {
+ 0, 17, 35, 53, 70, 88, 103, 118,
+ 137, 155, 170, 188, 202, 220, 237, 253,
+ 268, 287, 302, 320, 335, 353, 370, 387,
+ 402, 457, 468, 487, 522, 538, 553, 597
+ };
+ private static final int[] FRAME_PROGRESS = {
+ 0, 5, 10, 15, 20, 25, 29, 34,
+ 39, 44, 49, 54, 58, 63, 68, 72,
+ 77, 82, 86, 91, 96, 100, 100, 100,
+ 100, 100, 100, 100, 100, 100, 100, 100
+ };
+
+ private Form layoutHost;
+ private Container tile;
+ private Tabs tabs;
+
+ @Override
+ public boolean runTest() {
+ if (!"SE".equals(Display.getInstance().getProperty("OS", ""))) {
+ System.out.println("CN1SS:INFO:test=TabsLiquidGlassAnimation"
+ + " status=SKIPPED reason=mac-javase-reference-only platform="
+ + Display.getInstance().getPlatformName());
+ done();
+ return true;
+ }
+ if (!installIosModernTheme()) {
+ fail("Unable to load /iOSModernTheme.res for the Liquid Glass frame probe");
+ return false;
+ }
+ System.out.println("CN1SS:INFO:test=TabsLiquidGlassAnimation"
+ + " display=" + Display.getInstance().getDisplayWidth() + "x"
+ + Display.getInstance().getDisplayHeight()
+ + " pxPerMm=" + Display.getInstance().convertToPixels(1f)
+ + " platform=" + Display.getInstance().getPlatformName());
+ markCaptureStarted();
+ renderAppearance(false, "light", 0);
+ return true;
+ }
+
+ private void renderAppearance(boolean dark, String appearance, int frameIndex) {
+ if (frameIndex == 0) {
+ Display.getInstance().setDarkMode(Boolean.valueOf(dark));
+ UIManager.getInstance().refreshTheme();
+ buildTile(dark);
+ }
+ if (frameIndex >= FRAME_PROGRESS.length) {
+ if (!dark) {
+ renderAppearance(true, "dark", 0);
+ } else {
+ restoreAppTheme();
+ done();
+ }
+ return;
+ }
+
+ int progress = FRAME_PROGRESS[frameIndex];
+ tabs.setMorphTestState(2, 0, progress);
+ Image frame = Image.createImage(TILE_WIDTH, TILE_HEIGHT, 0xff808080);
+ Graphics g = frame.getGraphics();
+ tile.paintComponent(g, true);
+ String name = "TabsLiquidGlassAnimation_" + appearance
+ + "_ms" + pad3(FRAME_MS[frameIndex])
+ + "_p" + pad3(progress);
+ final int next = frameIndex + 1;
+ Cn1ssDeviceRunnerHelper.emitImage(frame, name,
+ () -> renderAppearance(dark, appearance, next));
+ }
+
+ private void buildTile(boolean dark) {
+ // No title bar: the native reference crop begins at UITabBar y=0.
+ // A titled offscreen Form shifts its content pane down by the title's
+ // 104px preferred height even though only the tile itself is painted.
+ layoutHost = new Form(new BorderLayout());
+ layoutHost.setWidth(TILE_WIDTH);
+ layoutHost.setHeight(TILE_HEIGHT);
+ layoutHost.setVisible(true);
+
+ // Match the native UITabBar's component frame, not the JavaSE Tabs
+ // preferred size. The visible floating pill is inset by the iOS theme,
+ // but UIKit gives the tab bar the full 60mm x 16mm capture tile. Letting
+ // FlowLayout shrink Tabs to JavaSE's preferred size produced a 260px toy
+ // pill and made every lens comparison meaningless.
+ tile = new Container(new BorderLayout());
+ Style tileStyle = tile.getAllStyles();
+ tileStyle.setBgColor(0x808080);
+ tileStyle.setBgTransparency(255);
+ tileStyle.setPadding(0, 0, 0, 0);
+ tileStyle.setMargin(0, 0, 0, 0);
+
+ tabs = new Tabs(Component.TOP);
+ tabs.setTabTextPosition(Component.BOTTOM);
+ Style iconStyle = new Style();
+ iconStyle.setFgColor(dark ? 0xebebf5 : 0x2c2c2e);
+ iconStyle.setBgTransparency(0);
+ Image star = FontImage.createSFOrMaterial(FontImage.MATERIAL_STAR, iconStyle, 4.1f);
+ Image search = FontImage.createSFOrMaterial(FontImage.MATERIAL_SEARCH, iconStyle, 4.1f);
+ Image more = FontImage.createSFOrMaterial(FontImage.MATERIAL_MORE_HORIZ, iconStyle, 4.1f);
+ tabs.addTab("Featured", star, star, new Container());
+ tabs.addTab("Search", search, search, new Container());
+ tabs.addTab("More", more, more, new Container());
+ tabs.setSelectedIndex(0);
+
+ // UIKit's bar frame starts at the top edge of this crop. NORTH keeps
+ // the production Tabs preferred height while preserving the full tile
+ // width, which lands the themed pill at the native y=0 position.
+ tile.add(BorderLayout.NORTH, tabs);
+ layoutHost.add(BorderLayout.CENTER, tile);
+ layoutHost.layoutContainer();
+ tile.layoutContainer();
+ tabs.layoutContainer();
+ tabs.getTabsContainer().layoutContainer();
+ System.out.println("CN1SS:INFO:test=TabsLiquidGlassAnimation geometry="
+ + "tile=" + tile.getX() + "," + tile.getY() + ","
+ + tile.getWidth() + "x" + tile.getHeight()
+ + " tabs=" + tabs.getX() + "," + tabs.getY() + ","
+ + tabs.getWidth() + "x" + tabs.getHeight()
+ + " bar=" + tabs.getTabsContainer().getX() + ","
+ + tabs.getTabsContainer().getY() + ","
+ + tabs.getTabsContainer().getWidth() + "x"
+ + tabs.getTabsContainer().getHeight()
+ + " tab0=" + tabs.getTabsContainer().getComponentAt(0).getX() + ","
+ + tabs.getTabsContainer().getComponentAt(0).getY() + ","
+ + tabs.getTabsContainer().getComponentAt(0).getWidth() + "x"
+ + tabs.getTabsContainer().getComponentAt(0).getHeight());
+ }
+
+ private boolean installIosModernTheme() {
+ InputStream in = Display.getInstance().getResourceAsStream(
+ TabsLiquidGlassAnimationScreenshotTest.class, "/iOSModernTheme.res");
+ if (in == null) {
+ in = TabsLiquidGlassAnimationScreenshotTest.class.getResourceAsStream("/iOSModernTheme.res");
+ }
+ if (in == null) {
+ return false;
+ }
+ try {
+ Resources resources = Resources.open(in);
+ String[] names = resources.getThemeResourceNames();
+ if (names == null || names.length == 0) {
+ return false;
+ }
+ UIManager.getInstance().setThemeProps(resources.getTheme(names[0]));
+ UIManager.getInstance().refreshTheme();
+ return UIManager.getInstance().isThemeConstant("glassMaterialBool", false)
+ && UIManager.getInstance().isThemeConstant("tabsSelectionCapsuleBool", false);
+ } catch (IOException ex) {
+ System.out.println("CN1SS:ERR:test=TabsLiquidGlassAnimation theme=" + ex);
+ return false;
+ } finally {
+ Util.cleanup(in);
+ }
+ }
+
+ private void restoreAppTheme() {
+ tabs.setMorphTestState(2, 0, -1);
+ layoutHost = null;
+ tile = null;
+ tabs = null;
+ Display.getInstance().setDarkMode(null);
+ UIManager.initFirstTheme("/theme");
+ UIManager.getInstance().refreshTheme();
+ }
+
+ private static String pad3(int value) {
+ if (value < 10) {
+ return "00" + value;
+ }
+ if (value < 100) {
+ return "0" + value;
+ }
+ return String.valueOf(value);
+ }
+}
diff --git a/scripts/ios/screenshots-watch/ButtonTheme_dark.png b/scripts/ios/screenshots-watch/ButtonTheme_dark.png
index 4a6cecac6bc..b9ef60ede17 100644
Binary files a/scripts/ios/screenshots-watch/ButtonTheme_dark.png and b/scripts/ios/screenshots-watch/ButtonTheme_dark.png differ
diff --git a/scripts/ios/screenshots-watch/ButtonTheme_light.png b/scripts/ios/screenshots-watch/ButtonTheme_light.png
index 1ff4d997514..146c1b4055d 100644
Binary files a/scripts/ios/screenshots-watch/ButtonTheme_light.png and b/scripts/ios/screenshots-watch/ButtonTheme_light.png differ
diff --git a/scripts/ios/screenshots-watch/ChatInput_dark.png b/scripts/ios/screenshots-watch/ChatInput_dark.png
index 942de8cd3d0..3d640ac84da 100644
Binary files a/scripts/ios/screenshots-watch/ChatInput_dark.png and b/scripts/ios/screenshots-watch/ChatInput_dark.png differ
diff --git a/scripts/ios/screenshots-watch/ChatInput_light.png b/scripts/ios/screenshots-watch/ChatInput_light.png
index 577977b6a4a..760996f679f 100644
Binary files a/scripts/ios/screenshots-watch/ChatInput_light.png and b/scripts/ios/screenshots-watch/ChatInput_light.png differ
diff --git a/scripts/ios/screenshots-watch/PaletteOverrideTheme_dark.png b/scripts/ios/screenshots-watch/PaletteOverrideTheme_dark.png
index bed00c1b428..b774ea80b94 100644
Binary files a/scripts/ios/screenshots-watch/PaletteOverrideTheme_dark.png and b/scripts/ios/screenshots-watch/PaletteOverrideTheme_dark.png differ
diff --git a/scripts/ios/screenshots-watch/PaletteOverrideTheme_light.png b/scripts/ios/screenshots-watch/PaletteOverrideTheme_light.png
index 1ea9f4fb526..c3093fba905 100644
Binary files a/scripts/ios/screenshots-watch/PaletteOverrideTheme_light.png and b/scripts/ios/screenshots-watch/PaletteOverrideTheme_light.png differ
diff --git a/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png b/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png
index 7cbde7bc0a1..6cdbf91df01 100644
Binary files a/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png and b/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png differ
diff --git a/scripts/ios/screenshots-watch/ShowcaseTheme_light.png b/scripts/ios/screenshots-watch/ShowcaseTheme_light.png
index c88a6d971d5..84052bd794f 100644
Binary files a/scripts/ios/screenshots-watch/ShowcaseTheme_light.png and b/scripts/ios/screenshots-watch/ShowcaseTheme_light.png differ
diff --git a/scripts/ios/screenshots/ButtonTheme_dark.png b/scripts/ios/screenshots/ButtonTheme_dark.png
index a140fd991c6..848f0051aa6 100644
Binary files a/scripts/ios/screenshots/ButtonTheme_dark.png and b/scripts/ios/screenshots/ButtonTheme_dark.png differ
diff --git a/scripts/ios/screenshots/ButtonTheme_light.png b/scripts/ios/screenshots/ButtonTheme_light.png
index 1995fe403e6..2854439e25d 100644
Binary files a/scripts/ios/screenshots/ButtonTheme_light.png and b/scripts/ios/screenshots/ButtonTheme_light.png differ
diff --git a/scripts/ios/screenshots/PaletteOverrideTheme_dark.png b/scripts/ios/screenshots/PaletteOverrideTheme_dark.png
index 86e7e84a5a6..10fe70e92cd 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 8529a165829..8aa97ff612a 100644
Binary files a/scripts/ios/screenshots/PaletteOverrideTheme_light.png and b/scripts/ios/screenshots/PaletteOverrideTheme_light.png differ
diff --git a/scripts/javascript/screenshots/ButtonTheme_ios_dark.png b/scripts/javascript/screenshots/ButtonTheme_ios_dark.png
index 407d4d9a499..7c5b3aea25f 100644
Binary files a/scripts/javascript/screenshots/ButtonTheme_ios_dark.png and b/scripts/javascript/screenshots/ButtonTheme_ios_dark.png differ
diff --git a/scripts/javascript/screenshots/ButtonTheme_ios_light.png b/scripts/javascript/screenshots/ButtonTheme_ios_light.png
index bad43fb6532..d3eb686b5fb 100644
Binary files a/scripts/javascript/screenshots/ButtonTheme_ios_light.png and b/scripts/javascript/screenshots/ButtonTheme_ios_light.png differ
diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png
index 30b2f5f7f4f..8d88b78e753 100644
Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png differ
diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png
index 4f45fbb6cd5..d41be536653 100644
Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png differ
diff --git a/scripts/javascript/screenshots/PickerTheme_ios_dark.png b/scripts/javascript/screenshots/PickerTheme_ios_dark.png
index f40c1295ae5..d23ceba9e06 100644
Binary files a/scripts/javascript/screenshots/PickerTheme_ios_dark.png and b/scripts/javascript/screenshots/PickerTheme_ios_dark.png differ
diff --git a/scripts/javascript/screenshots/PickerTheme_ios_light.png b/scripts/javascript/screenshots/PickerTheme_ios_light.png
index d9d0a514374..0ecb46070bc 100644
Binary files a/scripts/javascript/screenshots/PickerTheme_ios_light.png and b/scripts/javascript/screenshots/PickerTheme_ios_light.png differ
diff --git a/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png b/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png
index b3112746308..54aa582b2ab 100644
Binary files a/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png and b/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png differ
diff --git a/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png b/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png
index f05eecc1f89..7c56e9122e6 100644
Binary files a/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png and b/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png differ
diff --git a/scripts/javascript/screenshots/TabsTheme_ios_dark.png b/scripts/javascript/screenshots/TabsTheme_ios_dark.png
index 43a1f72151f..20c774f8b3c 100644
Binary files a/scripts/javascript/screenshots/TabsTheme_ios_dark.png and b/scripts/javascript/screenshots/TabsTheme_ios_dark.png differ
diff --git a/scripts/javascript/screenshots/TabsTheme_ios_light.png b/scripts/javascript/screenshots/TabsTheme_ios_light.png
index 4bbf3070320..e149988a5ff 100644
Binary files a/scripts/javascript/screenshots/TabsTheme_ios_light.png and b/scripts/javascript/screenshots/TabsTheme_ios_light.png differ
diff --git a/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png b/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png
index 6faac71b51d..ae04b36caef 100644
Binary files a/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png and b/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png differ
diff --git a/scripts/javascript/screenshots/TextFieldTheme_ios_light.png b/scripts/javascript/screenshots/TextFieldTheme_ios_light.png
index 6649c2cc74f..f1f8b1daed2 100644
Binary files a/scripts/javascript/screenshots/TextFieldTheme_ios_light.png and b/scripts/javascript/screenshots/TextFieldTheme_ios_light.png differ
diff --git a/scripts/record-ios-native-anim.sh b/scripts/record-ios-native-anim.sh
index e17e055060e..84e66a47b9f 100755
--- a/scripts/record-ios-native-anim.sh
+++ b/scripts/record-ios-native-anim.sh
@@ -30,21 +30,58 @@ NATIVEREF_BUILD_ONLY=1 "$ROOT/scripts/build-ios-native-ref.sh" "$UDID"
mkdir -p "$OUT_DIR"
xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true
-log "Launching $ANIM/$APPEARANCE animation"
-SIMCTL_CHILD_NATIVEREF_MODE=animate \
-SIMCTL_CHILD_NATIVEREF_ANIM="$ANIM" \
-SIMCTL_CHILD_NATIVEREF_APPEARANCE="$APPEARANCE" \
-xcrun simctl launch "$UDID" "$BUNDLE_ID" >/dev/null
-sleep 2
-log "Recording ${SECONDS_TO_RECORD}s -> $OUT"
-rm -f "$OUT"
-xcrun simctl io "$UDID" recordVideo --codec h264 --force "$OUT" &
-REC_PID=$!
-sleep "$SECONDS_TO_RECORD"
-kill -INT "$REC_PID" 2>/dev/null || true
-wait "$REC_PID" 2>/dev/null || true
-xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true
+if [ "$ANIM" = "tabs" ]; then
+ # UIKit plays the FULL Liquid Glass tab-selection morph (the frosted
+ # refracting drop) only for genuine touch-driven selection; programmatic
+ # selectedIndex changes play a flat simplified platter slide. Drive the
+ # NativeRef tab bar with REAL taps via the committed XCUITest tap driver
+ # (needs xcodegen, like the input-validation iOS driver).
+ if ! command -v xcodegen >/dev/null 2>&1; then
+ log "xcodegen not on PATH (brew install xcodegen) -- required for tap-driven tab recording" >&2
+ exit 3
+ fi
+ DRIVER_SRC="$ROOT/scripts/fidelity-app/ios-native-ref/tap-driver"
+ DRIVER_TMP="$(mktemp -d)/tap-driver"
+ cp -R "$DRIVER_SRC" "$DRIVER_TMP"
+ (cd "$DRIVER_TMP" && xcodegen generate >/dev/null)
+ log "Building tap driver (xcodebuild build-for-testing)"
+ xcodebuild build-for-testing \
+ -project "$DRIVER_TMP/NativeRefTapDriver.xcodeproj" \
+ -scheme NativeRefTapDriverUITests \
+ -destination "id=$UDID" >/dev/null 2>&1
+ log "Recording tap-driven $ANIM/$APPEARANCE morph -> $OUT"
+ rm -f "$OUT"
+ xcrun simctl io "$UDID" recordVideo --codec h264 --force "$OUT" &
+ REC_PID=$!
+ TEST_RUNNER_TAPDRIVER_APPEARANCE="$APPEARANCE" \
+ xcodebuild test-without-building \
+ -project "$DRIVER_TMP/NativeRefTapDriver.xcodeproj" \
+ -scheme NativeRefTapDriverUITests \
+ -destination "id=$UDID" >/dev/null 2>&1 || {
+ kill -INT "$REC_PID" 2>/dev/null || true
+ log "FATAL: tap-driver test run failed"; exit 4
+ }
+ kill -INT "$REC_PID" 2>/dev/null || true
+ wait "$REC_PID" 2>/dev/null || true
+ xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true
+else
+ log "Launching $ANIM/$APPEARANCE animation"
+ SIMCTL_CHILD_NATIVEREF_MODE=animate \
+ SIMCTL_CHILD_NATIVEREF_ANIM="$ANIM" \
+ SIMCTL_CHILD_NATIVEREF_APPEARANCE="$APPEARANCE" \
+ xcrun simctl launch "$UDID" "$BUNDLE_ID" >/dev/null
+ sleep 2
+
+ log "Recording ${SECONDS_TO_RECORD}s -> $OUT"
+ rm -f "$OUT"
+ xcrun simctl io "$UDID" recordVideo --codec h264 --force "$OUT" &
+ REC_PID=$!
+ sleep "$SECONDS_TO_RECORD"
+ kill -INT "$REC_PID" 2>/dev/null || true
+ wait "$REC_PID" 2>/dev/null || true
+ xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true
+fi
if [ -s "$OUT" ]; then
log "Wrote $(du -h "$OUT" | cut -f1) reference video: $OUT"
diff --git a/scripts/verify-javascript-lens-parity.mjs b/scripts/verify-javascript-lens-parity.mjs
new file mode 100644
index 00000000000..df90cc286d9
--- /dev/null
+++ b/scripts/verify-javascript-lens-parity.mjs
@@ -0,0 +1,190 @@
+import fs from 'node:fs';
+import vm from 'node:vm';
+
+// Pixel-parity guard for the complete iOS-26 glass-tab effect. This evaluates
+// the implementation that browser_bridge.js actually ships (not a copied test
+// implementation) against checksums produced by JavaSEPort.applyLensBuffer()
+// and IOSImplementation's material/optics routines. A one-channel/one-pixel
+// drift changes the checksum.
+//
+// The glass material/optics pins below are shared by THREE CPU
+// implementations: IOSImplementation.{glassMaterialInPlace,applyGlassOptics},
+// JavaSEPort.{glassMaterialInPlace,applyGlassOptics} (the simulator's
+// backdrop-filter glass) and the browser bridge functions checked here. When
+// one of them changes, regenerate with a reflection probe over the SAME
+// glassPattern inputs (see the GlassParityDump/LensParityDump probes in the
+// PR description) and update all backends together.
+const bridgePath = new URL('../vm/ByteCodeTranslator/src/javascript/browser_bridge.js', import.meta.url);
+const bridge = fs.readFileSync(bridgePath, 'utf8');
+const javaSource = fs.readFileSync(new URL(
+ '../Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java', import.meta.url), 'utf8');
+const metalReference = fs.readFileSync(new URL(
+ '../Ports/iOSPort/nativeSources/METALView.m', import.meta.url), 'utf8');
+const metalShader = fs.readFileSync(new URL(
+ '../Ports/iOSPort/nativeSources/CN1MetalShaders.metal', import.meta.url), 'utf8');
+const start = bridge.indexOf(' var LENS_MAG_FLAT =');
+const end = bridge.indexOf(' // Replay one command stream', start);
+if (start < 0 || end < 0) {
+ throw new Error('Unable to locate the lens implementation in browser_bridge.js');
+}
+const sandbox = { Math, Uint8ClampedArray };
+vm.runInNewContext(bridge.substring(start, end)
+ + '\nthis.applyLens = applyLensSelfRegion;'
+ + '\nthis.applyMaterial = glassMaterialInPlace;'
+ + '\nthis.applyOptics = applyGlassOptics;', sandbox);
+
+// The same foreground lens is implemented four times because each backend has
+// a different pixel API. Fail before the CRC probe if a tuning constant drifts.
+const lensConstants = [
+ 'LENS_MAG_FLAT', 'LENS_TINT_HI', 'LENS_TINT_LO', 'LENS_LIFT_COEF',
+ 'LENS_GLARE', 'LENS_RIM', 'LENS_RIM_W', 'LENS_REFRACT',
+ 'LENS_EDGE_SHADOW', 'LENS_RIM_SCALE', 'LENS_GLASS_TINT_STR',
+ 'LENS_SAT_BOOST'
+];
+
+function constant(source, pattern, name, backend) {
+ const match = source.match(new RegExp(pattern.replace('%s', name)));
+ if (!match) {
+ throw new Error(`Missing ${name} in ${backend} lens implementation`);
+ }
+ return Number(match[1]);
+}
+
+for (const name of lensConstants) {
+ const values = {
+ javascript: constant(bridge, `var\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, name, 'JavaScript'),
+ javase: constant(javaSource,
+ `private\\s+static\\s+final\\s+double\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, name, 'JavaSE'),
+ metalReference: constant(metalReference,
+ `#define\\s+%s\\s+([0-9.]+)f`, name, 'Metal CPU reference'),
+ metalShader: constant(metalShader,
+ `constant\\s+float\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, name, 'Metal shader')
+ };
+ if (new Set(Object.values(values)).size !== 1) {
+ throw new Error(`Lens constant drift for ${name}: ${JSON.stringify(values)}`);
+ }
+}
+
+// The liquid-layer gate (glassAmt = smoothstep(1.085, 1.25, magnify)) is a
+// named pair only in the JavaScript port; the other backends inline the
+// literals. Pin all four so the gate cannot drift silently either.
+const gateStart = constant(bridge, `var\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, 'LENS_GLASS_START', 'JavaScript');
+const gateFull = constant(bridge, `var\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, 'LENS_GLASS_FULL', 'JavaScript');
+if (gateStart !== 1.085 || gateFull !== 1.25) {
+ throw new Error(`JavaScript liquid gate drift: ${gateStart}..${gateFull}, expected 1.085..1.25`);
+}
+for (const [backend, source, pattern] of [
+ ['JavaSE', javaSource, /lensSmoothstep\(1\.085,\s*1\.25,\s*magnify\)/],
+ ['Metal CPU reference', metalReference, /glassSmoothstep\(1\.085f,\s*1\.25f,\s*magnify\)/],
+ ['Metal shader', metalShader, /cn1_lens_smoothstep\(1\.085,\s*1\.25,\s*magnify\)/]
+]) {
+ if (!pattern.test(source)) {
+ throw new Error(`Liquid gate smoothstep(1.085, 1.25) not found in ${backend}`);
+ }
+}
+console.log(`lens constant parity PASS backends=4 constants=${lensConstants.length + 2}`);
+
+function crc32(bytes) {
+ let crc = 0xffffffff;
+ for (const value of bytes) {
+ crc ^= value;
+ for (let bit = 0; bit < 8; bit++) {
+ crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function rgbaToArgb(rgba) {
+ const argb = new Uint8Array(rgba.length);
+ for (let i = 0; i < rgba.length / 4; i++) {
+ argb[i * 4] = rgba[i * 4 + 3];
+ argb[i * 4 + 1] = rgba[i * 4];
+ argb[i * 4 + 2] = rgba[i * 4 + 1];
+ argb[i * 4 + 3] = rgba[i * 4 + 2];
+ }
+ return argb;
+}
+
+function glassPattern(width, height) {
+ const source = new Uint8ClampedArray(width * height * 4);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const offset = (y * width + x) * 4;
+ source[offset] = (x * 17 + y * 3) & 0xff;
+ source[offset + 1] = (x * 5 + y * 19) & 0xff;
+ source[offset + 2] = (x * 11 + y * 7) & 0xff;
+ source[offset + 3] = (x * 7 + y * 13 + 31) & 0xff;
+ }
+ }
+ return source;
+}
+
+// magnify/aberration sampled from the shipped "ios26" preset (rest 1.08x,
+// peak 1.18x, peak aberration 0.02) at representative flight envelopes.
+// Expected CRCs are produced by JavaSEPort.applyLensBuffer over the same
+// synthetic source (see the LensParityDump probe in the PR description).
+const frames = [
+ { progress: 0, width: 100, height: 40, magnify: 1.08, aberration: 0, expected: 0xda50e7bd },
+ { progress: 10, width: 100, height: 40, magnify: 1.1726, aberration: 0.01852, expected: 0xfb9082d1 },
+ { progress: 25, width: 100, height: 40, magnify: 1.18, aberration: 0.02, expected: 0x766a3feb },
+ { progress: 50, width: 100, height: 40, magnify: 1.18, aberration: 0.02, expected: 0x766a3feb },
+ { progress: 75, width: 98, height: 40, magnify: 1.13, aberration: 0.01, expected: 0x4fe082c7 },
+ { progress: 90, width: 99, height: 40, magnify: 1.08, aberration: 0, expected: 0xd0b8b264 },
+ { progress: 100, width: 100, height: 40, magnify: 1.08, aberration: 0, expected: 0xda50e7bd }
+];
+
+for (const frame of frames) {
+ const { width, height } = frame;
+ const source = new Uint8ClampedArray(width * height * 4);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const offset = (y * width + x) * 4;
+ source[offset] = (x * 17 + y * 3) & 0xff;
+ source[offset + 1] = (x * 5 + y * 19) & 0xff;
+ source[offset + 2] = (x * 11 + y * 7) & 0xff;
+ source[offset + 3] = 0xff;
+ }
+ }
+ let output;
+ const context = {
+ canvas: { width, height },
+ getTransform() { return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; },
+ getImageData() { return { data: new Uint8ClampedArray(source) }; },
+ createImageData(w, h) { return { data: new Uint8ClampedArray(w * h * 4) }; },
+ putImageData(image) { output = image.data; }
+ };
+ sandbox.applyLens(context, 0, 0, width, height, -1,
+ frame.magnify, frame.aberration, 0x0a84ff, 1);
+
+ // JavaSE's reference buffer is packed ARGB; Canvas ImageData is RGBA.
+ const actual = crc32(rgbaToArgb(output));
+ if (actual !== frame.expected && process.env.CN1_UPDATE_LENS_PARITY !== '1') {
+ throw new Error(`Lens parity failed at ${frame.progress}%: `
+ + `expected ${frame.expected.toString(16)}, got ${actual.toString(16)}`);
+ }
+ const status = process.env.CN1_UPDATE_LENS_PARITY === '1' ? 'UPDATE' : 'PASS';
+ console.log(`lens parity ${status} t=${frame.progress}% size=${width}x${height} crc=${actual.toString(16)}`);
+}
+
+for (const recipe of [
+ { name: 'pill-light', saturation: 1.8, scale: 1, offset: 108, expected: 0x5d960bce },
+ { name: 'pill-dark', saturation: 2.5, scale: 0.3, offset: 13, expected: 0x836a16cf }
+]) {
+ const material = glassPattern(64, 40);
+ sandbox.applyMaterial(material, recipe.saturation, recipe.scale, recipe.offset);
+ const actual = crc32(rgbaToArgb(material));
+ if (actual !== recipe.expected) {
+ throw new Error(`Glass material parity failed for ${recipe.name}: `
+ + `expected ${recipe.expected.toString(16)}, got ${actual.toString(16)}`);
+ }
+ console.log(`glass material parity PASS recipe=${recipe.name} crc=${actual.toString(16)}`);
+}
+
+const optics = sandbox.applyOptics(glassPattern(52, 32), 52, 32, 6,
+ 40, 20, -1, 0.4, 0.5);
+const opticsCrc = crc32(rgbaToArgb(optics));
+if (opticsCrc !== 0x8057dd7a) {
+ throw new Error(`Glass optics parity failed: expected 8057dd7a, got ${opticsCrc.toString(16)}`);
+}
+console.log(`glass optics parity PASS crc=${opticsCrc.toString(16)}`);
diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js
index e86883d1df8..769d8e79725 100644
--- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js
+++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js
@@ -1,3 +1,26 @@
+/*
+ * 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.
+ */
+
(function(global) {
function shouldEnableDiag() {
if (global.__parparDiagEnabled != null) {
@@ -932,7 +955,7 @@
CREATE_PATTERN_SURFACE: 57,
DRAW_IMAGE_XY: 60, DRAW_IMAGE_XYWH: 61, DRAW_IMAGE_SRCDST: 62,
BLIT_SURFACE_XY: 70, BLIT_SURFACE_XYWH: 71, BLIT_SURFACE_SRCDST: 72,
- BLUR_SELF_REGION: 80, LENS_SELF_REGION: 81
+ BLUR_SELF_REGION: 80, LENS_SELF_REGION: 81, GLASS_SELF_REGION: 82
};
// The display surface id. Mirrors HTML5Implementation.DISPLAY_SURFACE_ID.
var SURF_DISPLAY_ID = 1;
@@ -979,6 +1002,436 @@
return s;
}
+ // iOS-26 tab selection lens. Keep these values and the math in lock-step
+ // with JavaSEPort.applyLensBuffer(): the Simulator is the browser renderer's
+ // pixel reference for the glass-tab animation.
+ var LENS_MAG_FLAT = 0.75;
+ var LENS_TINT_HI = 150;
+ var LENS_TINT_LO = 55;
+ var LENS_LIFT_COEF = 0.40;
+ var LENS_GLARE = 0.09;
+ var LENS_RIM = 0.06;
+ var LENS_RIM_W = 0.06;
+ var LENS_REFRACT = 0.16;
+ var LENS_EDGE_SHADOW = 0.12;
+ var LENS_RIM_SCALE = 0.84;
+ var LENS_GLASS_TINT = 0xbcd8ff;
+ var LENS_GLASS_TINT_STR = 0.10;
+ var LENS_SAT_BOOST = 1.32;
+ var LENS_GLASS_START = 1.085;
+ var LENS_GLASS_FULL = 1.25;
+
+ function lensSmoothstep(a, b, x) {
+ var t = (x - a) / (b - a);
+ t = t < 0 ? 0 : (t > 1 ? 1 : t);
+ return t * t * (3 - 2 * t);
+ }
+
+ function lensBilinear(a, b, c, d, tx, ty) {
+ var top = a + (b - a) * tx;
+ var bottom = c + (d - c) * tx;
+ return (top + (bottom - top) * ty) | 0;
+ }
+
+ function lensSample(data, width, height, fx, fy, channel) {
+ if (fx < 0) { fx = 0; } else if (fx > width - 1) { fx = width - 1; }
+ if (fy < 0) { fy = 0; } else if (fy > height - 1) { fy = height - 1; }
+ var x0 = fx | 0, y0 = fy | 0;
+ var x1 = Math.min(x0 + 1, width - 1), y1 = Math.min(y0 + 1, height - 1);
+ var tx = fx - x0, ty = fy - y0;
+ var row0 = y0 * width, row1 = y1 * width;
+ return lensBilinear(
+ data[(row0 + x0) * 4 + channel], data[(row0 + x1) * 4 + channel],
+ data[(row1 + x0) * 4 + channel], data[(row1 + x1) * 4 + channel],
+ tx, ty
+ );
+ }
+
+ function lensDeviceRect(ctx, x, y, width, height) {
+ var tr = ctx.getTransform ? ctx.getTransform() : null;
+ if (!tr) {
+ return { x: Math.round(x), y: Math.round(y), w: Math.round(width), h: Math.round(height), scale: 1 };
+ }
+ // Tabs paint under translation/uniform scaling only. A pixel read ignores
+ // Canvas transforms, so resolve that axis-aligned transform explicitly.
+ // A rotated/sheared lens is outside the v1 contract and safely no-ops.
+ if (Math.abs(tr.b) > 1e-9 || Math.abs(tr.c) > 1e-9) {
+ return null;
+ }
+ var x0 = tr.a * x + tr.e, x1 = tr.a * (x + width) + tr.e;
+ var y0 = tr.d * y + tr.f, y1 = tr.d * (y + height) + tr.f;
+ return {
+ x: Math.round(Math.min(x0, x1)),
+ y: Math.round(Math.min(y0, y1)),
+ w: Math.round(Math.abs(x1 - x0)),
+ h: Math.round(Math.abs(y1 - y0)),
+ scale: Math.min(Math.abs(tr.a), Math.abs(tr.d))
+ };
+ }
+
+ function applyLensSelfRegion(ctx, x, y, width, height, cornerRadius,
+ magnify, aberration, tintColor, tintStrength) {
+ if (!ctx.canvas || width <= 0 || height <= 0) {
+ return;
+ }
+ var rect = lensDeviceRect(ctx, x, y, width, height);
+ if (!rect || rect.w <= 0 || rect.h <= 0) {
+ return;
+ }
+ var rx = rect.x, ry = rect.y, rw = rect.w, rh = rect.h;
+ var canvasWidth = ctx.canvas.width | 0, canvasHeight = ctx.canvas.height | 0;
+ if (rx < 0) { rw += rx; rx = 0; }
+ if (ry < 0) { rh += ry; ry = 0; }
+ if (rx + rw > canvasWidth) { rw = canvasWidth - rx; }
+ if (ry + rh > canvasHeight) { rh = canvasHeight - ry; }
+ if (rw <= 0 || rh <= 0) {
+ return;
+ }
+
+ var source = ctx.getImageData(rx, ry, rw, rh);
+ var src = source.data;
+ var result = ctx.createImageData(rw, rh);
+ var out = result.data;
+ var hw = rw / 2.0, hh = rh / 2.0;
+ var scaledCorner = cornerRadius * rect.scale;
+ var radius = scaledCorner < 0 ? Math.min(hw, hh)
+ : Math.min(scaledCorner, Math.min(hw, hh));
+ if (radius < 0) { radius = 0; }
+ var tr = (tintColor >> 16) & 0xff;
+ var tg = (tintColor >> 8) & 0xff;
+ var tb = tintColor & 0xff;
+ var liftMax = LENS_LIFT_COEF * (magnify - 1.0) * hh;
+ // The 3D-glass cues (edge refraction / edge shadow / glare) belong to the
+ // MORPH droplet, not the settled pill (rest 1.08x, peak 1.18x): they fade
+ // in above rest so a resting selection stays a flat subtle pill.
+ var glassAmount = lensSmoothstep(LENS_GLASS_START, LENS_GLASS_FULL, magnify);
+
+ for (var yy = 0; yy < rh; yy++) {
+ var py = yy + 0.5 - hh;
+ for (var xx = 0; xx < rw; xx++) {
+ var px = xx + 0.5 - hw;
+ var index = (yy * rw + xx) * 4;
+ var dxe = Math.abs(px) - (hw - radius);
+ var dye = Math.abs(py) - (hh - radius);
+ var axx = Math.max(dxe, 0), ayy = Math.max(dye, 0);
+ var outside = Math.sqrt(axx * axx + ayy * ayy);
+ var inside = Math.min(Math.max(dxe, dye), 0);
+ var depth = -(outside + inside - radius);
+ if (depth <= 0) {
+ out[index] = src[index];
+ out[index + 1] = src[index + 1];
+ out[index + 2] = src[index + 2];
+ out[index + 3] = src[index + 3];
+ continue;
+ }
+
+ var alpha = Math.min(depth, 1.0);
+ var rd = Math.min(1.0, Math.sqrt((px * px) / (hw * hw) + (py * py) / (hh * hh)));
+ var edge = lensSmoothstep(LENS_MAG_FLAT, 1.0, rd);
+ var rimScale = 1.0 + (LENS_RIM_SCALE - 1.0) * glassAmount;
+ var mag = magnify + (rimScale - magnify) * edge;
+ if (mag < 0.2) { mag = 0.2; }
+ var abr = aberration * edge;
+ var magR = mag * (1 - abr), magB = mag * (1 + abr);
+ if (magR < 0.05) { magR = 0.05; }
+ if (magB < 0.05) { magB = 0.05; }
+ var lift = liftMax * (1 - rd * rd);
+ var refract = 1.0 + LENS_REFRACT * glassAmount * lensSmoothstep(0.70, 1.0, rd);
+ var sampleYR = hh + (py / magR) * refract + lift;
+ var sampleYG = hh + (py / mag) * refract + lift;
+ var sampleYB = hh + (py / magB) * refract + lift;
+ var sr = lensSample(src, rw, rh, hw + (px / magR) * refract, sampleYR, 0);
+ var sg = lensSample(src, rw, rh, hw + (px / mag) * refract, sampleYG, 1);
+ var sb = lensSample(src, rw, rh, hw + (px / magB) * refract, sampleYB, 2);
+ var lum = 0.2126 * sr + 0.7152 * sg + 0.0722 * sb;
+ var tint = tintStrength * lensSmoothstep(LENS_TINT_HI, LENS_TINT_LO, lum);
+ var fr = sr + (tr - sr) * tint;
+ var fg = sg + (tg - sg) * tint;
+ var fb = sb + (tb - sb) * tint;
+ var glassTint = LENS_GLASS_TINT_STR * glassAmount;
+ fr += (((LENS_GLASS_TINT >> 16) & 0xff) - fr) * glassTint;
+ fg += (((LENS_GLASS_TINT >> 8) & 0xff) - fg) * glassTint;
+ fb += ((LENS_GLASS_TINT & 0xff) - fb) * glassTint;
+ var saturationLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb;
+ fr = saturationLum + (fr - saturationLum) * LENS_SAT_BOOST;
+ fg = saturationLum + (fg - saturationLum) * LENS_SAT_BOOST;
+ fb = saturationLum + (fb - saturationLum) * LENS_SAT_BOOST;
+ var gx = px / hw, gy = (py + 0.42 * hh) / hh;
+ var glare = LENS_GLARE * glassAmount * Math.exp(-(gx * gx * 1.15 + gy * gy * 2.6) * 2.1);
+ var rimWidth = Math.max(2.0, LENS_RIM_W * hh);
+ var rim = depth < rimWidth ? (1.0 - depth / rimWidth) * LENS_RIM : 0;
+ var bright = glare + rim;
+ if (bright > 0) {
+ fr += bright * (255 - fr);
+ fg += bright * (255 - fg);
+ fb += bright * (255 - fb);
+ }
+ var edgeShadowWidth = Math.max(2.0, 0.13 * Math.min(hw, hh));
+ if (depth < edgeShadowWidth) {
+ var edgeShadow = (1.0 - depth / edgeShadowWidth) * LENS_EDGE_SHADOW * glassAmount;
+ fr *= 1 - edgeShadow;
+ fg *= 1 - edgeShadow;
+ fb *= 1 - edgeShadow;
+ }
+ fr = fr < 0 ? 0 : (fr > 255 ? 255 : fr | 0);
+ fg = fg < 0 ? 0 : (fg > 255 ? 255 : fg | 0);
+ fb = fb < 0 ? 0 : (fb > 255 ? 255 : fb | 0);
+ out[index] = (src[index] + (fr - src[index]) * alpha) | 0;
+ out[index + 1] = (src[index + 1] + (fg - src[index + 1]) * alpha) | 0;
+ out[index + 2] = (src[index + 2] + (fb - src[index + 2]) * alpha) | 0;
+ out[index + 3] = src[index + 3];
+ }
+ }
+ ctx.putImageData(result, rx, ry);
+ }
+
+ function glassFloatMul(a, b) {
+ return Math.fround(Math.fround(a) * Math.fround(b));
+ }
+
+ function glassFloatAdd(a, b) {
+ return Math.fround(Math.fround(a) + Math.fround(b));
+ }
+
+ // Mirrors IOSImplementation.glassMaterialInPlace(). Math.fround preserves
+ // the native float evaluation points so the material does not drift by a
+ // channel value merely because JavaScript normally evaluates as double.
+ function glassMaterialInPlace(data, saturation, scale, offset) {
+ var sat = Math.fround(saturation), scl = Math.fround(scale), off = Math.fround(offset);
+ var lr = Math.fround(0.2126), lg = Math.fround(0.7152), lb = Math.fround(0.0722);
+ for (var i = 0; i < data.length; i += 4) {
+ var r = Math.fround(data[i]), g = Math.fround(data[i + 1]), b = Math.fround(data[i + 2]);
+ var lum = glassFloatAdd(glassFloatAdd(glassFloatMul(lr, r), glassFloatMul(lg, g)),
+ glassFloatMul(lb, b));
+ r = glassFloatAdd(glassFloatMul(glassFloatAdd(lum,
+ glassFloatMul(glassFloatAdd(r, -lum), sat)), scl), off);
+ g = glassFloatAdd(glassFloatMul(glassFloatAdd(lum,
+ glassFloatMul(glassFloatAdd(g, -lum), sat)), scl), off);
+ b = glassFloatAdd(glassFloatMul(glassFloatAdd(lum,
+ glassFloatMul(glassFloatAdd(b, -lum), sat)), scl), off);
+ data[i] = r < 0 ? 0 : (r > 255 ? 255 : r | 0);
+ data[i + 1] = g < 0 ? 0 : (g > 255 ? 255 : g | 0);
+ data[i + 2] = b < 0 ? 0 : (b > 255 ? 255 : b | 0);
+ }
+ }
+
+ function glassBilinear(a, b, c, d, tx, ty) {
+ var ftx = Math.fround(tx), fty = Math.fround(ty);
+ var top = glassFloatAdd(a, glassFloatMul(glassFloatAdd(b, -a), ftx));
+ var bottom = glassFloatAdd(c, glassFloatMul(glassFloatAdd(d, -c), ftx));
+ return glassFloatAdd(glassFloatAdd(top,
+ glassFloatMul(glassFloatAdd(bottom, -top), fty)), 0.5) | 0;
+ }
+
+ function glassSample(data, width, height, fx, fy, channel) {
+ var x = Math.fround(fx), y = Math.fround(fy);
+ if (x < 0) { x = 0; } else if (x > width - 1) { x = width - 1; }
+ if (y < 0) { y = 0; } else if (y > height - 1) { y = height - 1; }
+ var x0 = x | 0, y0 = y | 0;
+ var x1 = x0 + 1 < width ? x0 + 1 : x0;
+ var y1 = y0 + 1 < height ? y0 + 1 : y0;
+ var tx = Math.fround(x - x0), ty = Math.fround(y - y0);
+ var row0 = y0 * width, row1 = y1 * width;
+ return glassBilinear(
+ data[(row0 + x0) * 4 + channel], data[(row0 + x1) * 4 + channel],
+ data[(row1 + x0) * 4 + channel], data[(row1 + x1) * 4 + channel],
+ tx, ty
+ );
+ }
+
+ // Mirrors IOSImplementation.applyGlassOptics(). The returned patch retains
+ // the native shape alpha and is composited with drawImage below, just like
+ // IOSImplementation draws its generated ARGB image back onto the target.
+ function applyGlassOptics(blurred, bufferWidth, bufferHeight, pad,
+ width, height, cornerRadius, refraction, specular) {
+ var result = new Uint8ClampedArray(width * height * 4);
+ var hw = Math.fround(width / 2), hh = Math.fround(height / 2);
+ var radius = cornerRadius < 0 ? Math.min(hw, hh)
+ : Math.min(cornerRadius, Math.min(hw, hh));
+ if (radius < 0) { radius = 0; }
+ var band = glassFloatMul(Math.min(hw, hh), Math.fround(0.6));
+ var rimWidth = Math.fround(3.0);
+ var refract = Math.fround(refraction), spec = Math.fround(specular);
+ for (var yy = 0; yy < height; yy++) {
+ var py = Math.fround(yy + 0.5);
+ for (var xx = 0; xx < width; xx++) {
+ var px = Math.fround(xx + 0.5);
+ var dx = Math.fround(Math.abs(Math.fround(px - hw)) - Math.fround(hw - radius));
+ var dy = Math.fround(Math.abs(Math.fround(py - hh)) - Math.fround(hh - radius));
+ var ax = dx > 0 ? dx : 0, ay = dy > 0 ? dy : 0;
+ var outside = Math.fround(Math.sqrt(glassFloatAdd(glassFloatMul(ax, ax), glassFloatMul(ay, ay))));
+ var inside = Math.min(Math.max(dx, dy), 0);
+ var sdf = glassFloatAdd(glassFloatAdd(outside, inside), -radius);
+ var depth = Math.fround(-sdf);
+ if (depth <= 0) {
+ continue;
+ }
+ var coverage = depth >= 1 ? 1 : depth;
+ var sx = Math.fround(xx), sy = Math.fround(yy);
+ if (refract > 0 && band > 0 && depth < band) {
+ var edgeT = Math.fround(1 - Math.fround(depth / band));
+ var root = Math.fround(Math.sqrt(Math.max(0,
+ glassFloatAdd(1, -glassFloatMul(edgeT, edgeT)))));
+ var distortion = glassFloatAdd(1, -root);
+ sx = Math.fround(xx - glassFloatMul(glassFloatMul(Math.fround(px - hw), distortion), refract));
+ sy = Math.fround(yy - glassFloatMul(glassFloatMul(Math.fround(py - hh), distortion), refract));
+ }
+ var red = glassSample(blurred, bufferWidth, bufferHeight,
+ Math.fround(sx + pad), Math.fround(sy + pad), 0);
+ var green = glassSample(blurred, bufferWidth, bufferHeight,
+ Math.fround(sx + pad), Math.fround(sy + pad), 1);
+ var blue = glassSample(blurred, bufferWidth, bufferHeight,
+ Math.fround(sx + pad), Math.fround(sy + pad), 2);
+ if (spec > 0 && depth < rimWidth) {
+ var rim = Math.fround(1 - Math.fround(depth / rimWidth));
+ var topBias = Math.fround(0.55 + glassFloatMul(0.45,
+ Math.fround(1 - Math.fround(py / height))));
+ var add = glassFloatMul(glassFloatMul(glassFloatMul(spec, rim), topBias), 70) | 0;
+ red = Math.min(255, red + add);
+ green = Math.min(255, green + add);
+ blue = Math.min(255, blue + add);
+ }
+ var index = (yy * width + xx) * 4;
+ result[index] = red;
+ result[index + 1] = green;
+ result[index + 2] = blue;
+ result[index + 3] = glassFloatMul(coverage, 255) | 0;
+ }
+ }
+ return result;
+ }
+
+ function createGlassScratchCanvas(width, height) {
+ if (typeof global.OffscreenCanvas === 'function') {
+ return new global.OffscreenCanvas(width, height);
+ }
+ var doc = global.document || (global.window && global.window.document);
+ if (!doc || !doc.createElement) {
+ return null;
+ }
+ var canvas = doc.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+ }
+
+ function applyBlurSelfRegion(ctx, x, y, width, height, sigma, cornerRadius) {
+ if (width <= 0 || height <= 0 || !ctx.canvas) {
+ return;
+ }
+ ctx.save();
+ try {
+ ctx.beginPath();
+ if (cornerRadius) {
+ var round = cornerRadius < 0 ? Math.min(width, height) / 2
+ : Math.min(cornerRadius, Math.min(width, height) / 2);
+ ctx.moveTo(x + round, y);
+ ctx.arcTo(x + width, y, x + width, y + height, round);
+ ctx.arcTo(x + width, y + height, x, y + height, round);
+ ctx.arcTo(x, y + height, x, y, round);
+ ctx.arcTo(x, y, x + width, y, round);
+ ctx.closePath();
+ } else {
+ ctx.rect(x, y, width, height);
+ }
+ ctx.clip();
+ ctx.filter = 'blur(' + sigma + 'px)';
+ ctx.drawImage(ctx.canvas, 0, 0);
+ } finally {
+ ctx.restore();
+ }
+ }
+
+ function applyGlassSelfRegion(ctx, x, y, width, height, blurRadius, cornerRadius,
+ saturation, scale, offset, refraction, specular) {
+ if (!ctx.canvas || width <= 0 || height <= 0) {
+ return;
+ }
+ var rect = lensDeviceRect(ctx, x, y, width, height);
+ if (!rect || rect.w <= 0 || rect.h <= 0) {
+ return;
+ }
+ var rx = rect.x, ry = rect.y, rw = rect.w, rh = rect.h;
+ var canvasWidth = ctx.canvas.width | 0, canvasHeight = ctx.canvas.height | 0;
+ if (rx < 0) { rw += rx; rx = 0; }
+ if (ry < 0) { rh += ry; ry = 0; }
+ if (rx + rw > canvasWidth) { rw = canvasWidth - rx; }
+ if (ry + rh > canvasHeight) { rh = canvasHeight - ry; }
+ if (rw <= 0 || rh <= 0) {
+ return;
+ }
+
+ // CSS blur() accepts Gaussian sigma while the CN1/iOS recipe carries the
+ // calibrated blur radius. Keep the same radius-to-sigma conversion used
+ // by BlurRegion; the material transform is the missing opacity, not a
+ // stronger blur.
+ var sigma = Math.max(1, blurRadius * rect.scale / 2);
+ var pad = Math.ceil(sigma) * 3 + 1;
+ var bufferWidth = rw + pad * 2, bufferHeight = rh + pad * 2;
+ var ax0 = Math.max(0, rx - pad), ay0 = Math.max(0, ry - pad);
+ var ax1 = Math.min(canvasWidth, rx + rw + pad), ay1 = Math.min(canvasHeight, ry + rh + pad);
+ var availableWidth = ax1 - ax0, availableHeight = ay1 - ay0;
+ if (availableWidth <= 0 || availableHeight <= 0) {
+ return;
+ }
+ var available = ctx.getImageData(ax0, ay0, availableWidth, availableHeight).data;
+ var padded = new Uint8ClampedArray(bufferWidth * bufferHeight * 4);
+ for (var by = 0; by < bufferHeight; by++) {
+ var sourceY = ry - pad + by - ay0;
+ if (sourceY < 0) { sourceY = 0; }
+ if (sourceY >= availableHeight) { sourceY = availableHeight - 1; }
+ for (var bx = 0; bx < bufferWidth; bx++) {
+ var sourceX = rx - pad + bx - ax0;
+ if (sourceX < 0) { sourceX = 0; }
+ if (sourceX >= availableWidth) { sourceX = availableWidth - 1; }
+ var sourceIndex = (sourceY * availableWidth + sourceX) * 4;
+ var targetIndex = (by * bufferWidth + bx) * 4;
+ padded[targetIndex] = available[sourceIndex];
+ padded[targetIndex + 1] = available[sourceIndex + 1];
+ padded[targetIndex + 2] = available[sourceIndex + 2];
+ padded[targetIndex + 3] = available[sourceIndex + 3];
+ }
+ }
+ glassMaterialInPlace(padded, saturation, scale, offset);
+
+ var materialCanvas = createGlassScratchCanvas(bufferWidth, bufferHeight);
+ var blurredCanvas = createGlassScratchCanvas(bufferWidth, bufferHeight);
+ if (!materialCanvas || !blurredCanvas) {
+ applyBlurSelfRegion(ctx, x, y, width, height, sigma, cornerRadius);
+ return;
+ }
+ var materialContext = materialCanvas.getContext('2d');
+ var blurredContext = blurredCanvas.getContext('2d');
+ if (!materialContext || !blurredContext) {
+ applyBlurSelfRegion(ctx, x, y, width, height, sigma, cornerRadius);
+ return;
+ }
+ var materialImage = materialContext.createImageData(bufferWidth, bufferHeight);
+ materialImage.data.set(padded);
+ materialContext.putImageData(materialImage, 0, 0);
+ blurredContext.filter = 'blur(' + sigma + 'px)';
+ blurredContext.drawImage(materialCanvas, 0, 0);
+ var blurred = blurredContext.getImageData(0, 0, bufferWidth, bufferHeight).data;
+ var scaledCorner = cornerRadius * rect.scale;
+ var output = applyGlassOptics(blurred, bufferWidth, bufferHeight, pad,
+ rw, rh, scaledCorner, refraction, specular);
+ var outputCanvas = createGlassScratchCanvas(rw, rh);
+ var outputContext = outputCanvas && outputCanvas.getContext('2d');
+ if (!outputContext) {
+ return;
+ }
+ var result = outputContext.createImageData(rw, rh);
+ result.data.set(output);
+ outputContext.putImageData(result, 0, 0);
+ ctx.save();
+ try {
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.drawImage(outputCanvas, rx, ry);
+ } finally {
+ ctx.restore();
+ }
+ }
+
// Replay one command stream (opcodes + nums + objs) onto ``ctx``.
function replaySurfaceCommands(ctx, ops, opCount, nums, objs) {
var ni = 0; // num cursor
@@ -1097,69 +1550,38 @@
var _bsig = nums[ni++], _bcr = nums[ni++];
if (_bw > 0 && _bh > 0 && ctx.canvas) {
try {
- ctx.save();
- ctx.beginPath();
- if (_bcr) {
- var _brr = _bcr < 0 ? Math.min(_bw, _bh) / 2
- : Math.min(_bcr, Math.min(_bw, _bh) / 2);
- ctx.moveTo(_bx + _brr, _by);
- ctx.arcTo(_bx + _bw, _by, _bx + _bw, _by + _bh, _brr);
- ctx.arcTo(_bx + _bw, _by + _bh, _bx, _by + _bh, _brr);
- ctx.arcTo(_bx, _by + _bh, _bx, _by, _brr);
- ctx.arcTo(_bx, _by, _bx + _bw, _by, _brr);
- ctx.closePath();
- } else {
- ctx.rect(_bx, _by, _bw, _bh);
- }
- ctx.clip();
- ctx.filter = 'blur(' + _bsig + 'px)';
- ctx.drawImage(ctx.canvas, 0, 0);
+ applyBlurSelfRegion(ctx, _bx, _by, _bw, _bh, _bsig, _bcr);
} catch (_ebr) {
- } finally {
- try { ctx.restore(); } catch (_ebr2) {}
}
}
break;
}
case SURF.LENS_SELF_REGION: {
- // iOS-26 selection DROP: magnify this surface's own pixels in the
- // region (content bulge), then optionally wash them toward an accent
- // tint. drawImage(self) snapshots the source bitmap per the HTML
- // spec. cornerRadius: 0 = rect, -1 = capsule, >0 = rounded px.
+ // iOS-26 selection DROP: run the Simulator-equivalent per-pixel lens
+ // over this surface's own pixels. cornerRadius: 0 = rect, -1 =
+ // capsule, >0 = rounded px.
var _lx = nums[ni++], _ly = nums[ni++], _lw = nums[ni++], _lh = nums[ni++];
var _lcr = nums[ni++], _lmag = nums[ni++];
- var _ltint = objs[oi++];
+ var _lab = nums[ni++], _ltint = nums[ni++] | 0, _ltintStrength = nums[ni++];
if (_lw > 0 && _lh > 0 && ctx.canvas) {
try {
- ctx.save();
- ctx.beginPath();
- if (_lcr) {
- var _lrr = _lcr < 0 ? Math.min(_lw, _lh) / 2
- : Math.min(_lcr, Math.min(_lw, _lh) / 2);
- ctx.moveTo(_lx + _lrr, _ly);
- ctx.arcTo(_lx + _lw, _ly, _lx + _lw, _ly + _lh, _lrr);
- ctx.arcTo(_lx + _lw, _ly + _lh, _lx, _ly + _lh, _lrr);
- ctx.arcTo(_lx, _ly + _lh, _lx, _ly, _lrr);
- ctx.arcTo(_lx, _ly, _lx + _lw, _ly, _lrr);
- ctx.closePath();
- } else {
- ctx.rect(_lx, _ly, _lw, _lh);
- }
- ctx.clip();
- // Magnify: draw the region's own pixels scaled about its centre.
- // Source rect = region shrunk by 1/mag around centre; dest = the
- // full region -> the content bulges outward like the native lens.
- var _lm = _lmag > 1 ? _lmag : 1;
- var _lsw = _lw / _lm, _lsh = _lh / _lm;
- var _lsx = _lx + (_lw - _lsw) / 2, _lsy = _ly + (_lh - _lsh) / 2;
- ctx.drawImage(ctx.canvas, _lsx, _lsy, _lsw, _lsh, _lx, _ly, _lw, _lh);
- if (_ltint) {
- ctx.fillStyle = _ltint;
- ctx.fillRect(_lx, _ly, _lw, _lh);
- }
+ applyLensSelfRegion(ctx, _lx, _ly, _lw, _lh, _lcr,
+ _lmag, _lab, _ltint, _ltintStrength);
} catch (_elr) {
- } finally {
- try { ctx.restore(); } catch (_elr2) {}
+ }
+ }
+ break;
+ }
+ case SURF.GLASS_SELF_REGION: {
+ var _gx = nums[ni++], _gy = nums[ni++], _gw = nums[ni++], _gh = nums[ni++];
+ var _gblur = nums[ni++], _gcr = nums[ni++], _gsat = nums[ni++];
+ var _gscale = nums[ni++], _goffset = nums[ni++], _grefract = nums[ni++];
+ var _gspecular = nums[ni++];
+ if (_gw > 0 && _gh > 0 && ctx.canvas) {
+ try {
+ applyGlassSelfRegion(ctx, _gx, _gy, _gw, _gh, _gblur, _gcr,
+ _gsat, _gscale, _goffset, _grefract, _gspecular);
+ } catch (_egr) {
}
}
break;
diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java
index 462e7dbfbb8..bb3f1f2e3bb 100644
--- a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java
+++ b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java
@@ -1,3 +1,26 @@
+/*
+ * 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.tools.translator;
import org.junit.jupiter.params.ParameterizedTest;
@@ -14,6 +37,7 @@
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JavascriptTargetIntegrationTest {
@@ -90,6 +114,21 @@ void generatesBrowserBundleForJavascriptTarget(CompilerHelper.CompilerConfig con
"Generated host page should use the JavaScript port browser shell assets");
assertTrue(browserBridge.contains("cn1HostBridge") && browserBridge.contains("host-callback") && browserBridge.contains("host-call"),
"Browser bridge should expose host-call plumbing for the JavaScript port shell");
+ assertTrue(browserBridge.contains("function applyLensSelfRegion")
+ && browserBridge.contains("ctx.getImageData(rx, ry, rw, rh)")
+ && browserBridge.contains("LENS_GLASS_TINT_STR")
+ && browserBridge.contains("_lab = nums[ni++]")
+ && browserBridge.contains("_ltintStrength = nums[ni++]"),
+ "Browser bridge should ship the Simulator-equivalent per-pixel glass-tab lens");
+ assertTrue(browserBridge.contains("GLASS_SELF_REGION: 82")
+ && browserBridge.contains("function glassMaterialInPlace")
+ && browserBridge.contains("function applyGlassOptics")
+ && browserBridge.contains("function applyGlassSelfRegion")
+ && browserBridge.contains("_goffset = nums[ni++]")
+ && browserBridge.contains("ctx.drawImage(outputCanvas, rx, ry)"),
+ "Browser bridge should preserve and render the complete native glass recipe");
+ assertFalse(browserBridge.contains("var _lsw = _lw / _lm"),
+ "Glass-tab lens must not regress to the coarse uniform-zoom fallback");
assertTrue(protocolDoc.contains("Version: 1")
&& protocolDoc.contains("host-call")
&& protocolDoc.contains("host-callback")