From c51ddb3d6eae5daf088559982ac7698dfa78356d Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 28 Oct 2026 14:54:45 +0200
Subject: [PATCH 1/5] JavaScript port: loading screen with the app icon and
light/dark colours (#6117)
Build hints javascript.splash.* set the colours, appearance mode and logo;
the theme is written into
so a dark app never opens on white, and
platformHint.javascript.splashAppearance remembers the app's own choice.
Co-Authored-By: Claude Opus 5.5
---
.../impl/html5/HTML5Implementation.java | 28 +++
.../impl/html5/JSOImplementations.java | 11 ++
.../working-with-javascript.html | 9 -
.../Working-With-Javascript.asciidoc | 38 +++-
.../builders/JavaScriptBuildHints.java | 26 +++
.../builders/JavaScriptBuildHintsTest.java | 23 +++
.../build/shared/BuildHintsDesktop.java | 72 ++++++++
.../translator/JavascriptBundleWriter.java | 169 ++++++++++++++++++
.../src/javascript/index.html | 38 +++-
.../JavascriptPageSettingsTest.java | 52 ++++++
10 files changed, 450 insertions(+), 16 deletions(-)
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 7b53f4effe7..e2c0528d312 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
@@ -5349,6 +5349,31 @@ private JavaScriptRuntimeEnvironment createRuntimeEnvironment(final WindowExt wi
);
}
+ /// Remembers the application's light/dark choice for the loading screen of the NEXT launch:
+ /// `light` or `dark`, or null/anything else to follow the browser again. The page reads the
+ /// key in `` before any Java runs (`JavascriptBundleWriter.splashTheme` in the
+ /// ByteCodeTranslator, which also names the key); a build whose `javascript.splash.appearance`
+ /// is `light` or `dark` ignores it.
+ private static void setSplashAppearance(String value) {
+ // The cast stays outside the try: a CHECKCAST is unchecked on ParparVM, so a handler
+ // around it would never run (scripts/check-cast-semantics.sh).
+ WindowExt win = (WindowExt) Window.current();
+ try {
+ JSOImplementations.JSStorage storage = win.getLocalStorage();
+ if (storage == null) {
+ return;
+ }
+ if ("light".equals(value) || "dark".equals(value)) {
+ storage.setItem("cn1.splashAppearance", value);
+ } else {
+ storage.removeItem("cn1.splashAppearance");
+ }
+ } catch (RuntimeException e) {
+ // Storage disabled (private mode, a sandboxed frame): the splash follows the browser.
+ Log.e(e);
+ }
+ }
+
@JSBody(params={"msg"}, script="window.onbeforeunload=function(){return msg;}")
private native static void setBeforeUnloadMessage(String msg);
@@ -5357,6 +5382,9 @@ private JavaScriptRuntimeEnvironment createRuntimeEnvironment(final WindowExt wi
@Override
public void setPlatformHint(String key, String value) {
+ if ("platformHint.javascript.splashAppearance".equalsIgnoreCase(key)) {
+ setSplashAppearance(value);
+ }
if ("platformHint.javascript.beforeUnloadMessage".equalsIgnoreCase(key)) {
if (value == null) {
removeBeforeUnload();
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java
index 0147e4d636e..adaeba3d29d 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JSOImplementations.java
@@ -66,7 +66,18 @@ public interface JSFontMetrics extends JSObject {
JSObject getBounds();
}
+ /// The page's `window.localStorage`, for the few values the page itself reads before any
+ /// Java runs (the loading screen's remembered appearance).
+ public interface JSStorage extends JSObject {
+ void setItem(String key, String value);
+
+ void removeItem(String key);
+ }
+
public interface WindowExt extends JSObject {
+
+ @JSProperty
+ abstract JSStorage getLocalStorage();
@JSProperty
diff --git a/docs/demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html b/docs/demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html
index 093262deb4f..551391157dd 100644
--- a/docs/demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html
+++ b/docs/demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html
@@ -1,14 +1,5 @@
// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
-// tag::working-with-javascript-html-001[]
-
-
-
-
-
...Loading...
-
-// end::working-with-javascript-html-001[]
-
// tag::working-with-javascript-html-002[]
// end::working-with-javascript-html-002[]
diff --git a/docs/developer-guide/Working-With-Javascript.asciidoc b/docs/developer-guide/Working-With-Javascript.asciidoc
index a489cc6c3d1..65daf2ce0a1 100644
--- a/docs/developer-guide/Working-With-Javascript.asciidoc
+++ b/docs/developer-guide/Working-With-Javascript.asciidoc
@@ -262,15 +262,45 @@ This tells Apache to proxy all requests for '/app' to the domain https://www.mye
[id="splash_screen", reftext="Customizing the Splash Screen"]
=== Customizing the splash screen
-Since your application may include many resource files, videos, etc., the build-server will generate a splash screen for your app to display while it's loading. This basically shows a progress indicator with your app's icon.
+While the application downloads and starts, the page shows a loading screen: the app icon, an animated progress bar and a "Loading" label. It stays up until the first form is shown.
-You can customize this splash screen by modifying the HTML source inside the *cn1-splash* `div` tag of your app's index.html file:
+Build hints set its look, so the settings survive every build instead of being edited into each generated bundle:
-[source,html]
+[cols="1,1,3",options="header"]
+|===
+|Build hint |Default |Meaning
+
+|`javascript.splash.appearance`
+|`auto`
+|`auto` follows the browser's light/dark preference. `light` or `dark` always uses that set of colors, for an app with a single appearance.
+
+|`javascript.splash.background`, `javascript.splash.textColor`, `javascript.splash.accentColor`
+|`#ffffff`, `#666666`, `#0072ff`
+|The page background, the label and the progress bar in the light appearance.
+
+|`javascript.splash.darkBackground`, `javascript.splash.darkTextColor`, `javascript.splash.darkAccentColor`
+|`#121212`, `#b3b3b3`, `#4da3ff`
+|The same three in the dark appearance.
+
+|`javascript.splash.logo`
+|The app icon
+|A different image, as a path relative to the bundle's `index.html`, or `none` for no image.
+|===
+
+Colors are hex values (`#rgb`, `#rrggbb`, with optional alpha) or CSS color names. The page applies them before it paints anything, and the background stays behind the app until the first form covers it, so a dark app no longer opens on a white page.
+
+An app that lets the user choose its own appearance can have the next launch's loading screen match that choice, even when it differs from the browser's preference:
+
+[source,java]
----
-include::../demos/javascript/src/main/snippets/developer-guide/working-with-javascript.html[tag=working-with-javascript-html-001,indent=0]
+CN.setProperty("platformHint.javascript.splashAppearance", "dark"); // or "light"
+CN.setProperty("platformHint.javascript.splashAppearance", null); // follow the browser again
----
+The choice is kept in the browser's local storage and applies only in `auto` mode.
+
+For anything the hints don't cover, the markup is the `cn1-splash` `div` in the generated `index.html`; edit it there if you host the bundle yourself.
+
[id="debugging", reftext="Debugging in Chrome"]
=== Debugging
diff --git a/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java
index 746b00ba83f..5151b8ce87e 100644
--- a/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java
+++ b/maven/build-engine/src/main/java/com/codename1/builders/JavaScriptBuildHints.java
@@ -89,6 +89,19 @@ public final class JavaScriptBuildHints {
"javascript.native.theme", "javascript.desktopTheme"
};
+ /// The `javascript.splash.*` hints that style the loading screen, each passed to the
+ /// translator as `codename1.` (see `JavascriptBundleWriter.SplashSettings`, which
+ /// validates them).
+ static final String[] SPLASH_HINTS = {
+ "javascript.splash.appearance", "javascript.splash.background",
+ "javascript.splash.textColor", "javascript.splash.accentColor",
+ "javascript.splash.darkBackground", "javascript.splash.darkTextColor",
+ "javascript.splash.darkAccentColor", "javascript.splash.logo"
+ };
+
+ /// The 192px icon `JavaScriptPwa` writes into every bundle.
+ static final String SPLASH_DEFAULT_LOGO = "_icons/icon-192.png";
+
private JavaScriptBuildHints() {
}
@@ -126,6 +139,19 @@ static List translatorOptions(BuildRequest request) {
if (darkReader != null) {
out.add("-Dcodename1.javascript.darkreaderLock=" + !"false".equalsIgnoreCase(darkReader.trim()));
}
+ for (String hint : SPLASH_HINTS) {
+ String value = request.getArg(hint, null);
+ if (value != null && value.trim().length() > 0) {
+ out.add("-Dcodename1." + hint + "=" + value.trim());
+ }
+ }
+ // The loading screen shows the application's icon unless the developer named another
+ // logo (or none). JavaScriptPwa writes the icon to this path after the translator runs,
+ // from the same request.getIcon(); without an icon it writes a placeholder square, which
+ // identifies nothing, so then there is no logo.
+ if (request.getArg("javascript.splash.logo", null) == null && request.getIcon() != null) {
+ out.add("-Dcodename1.javascript.splash.logo=" + SPLASH_DEFAULT_LOGO);
+ }
return out;
}
diff --git a/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java b/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java
index 9f079da8cb1..b7d25274ba5 100644
--- a/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java
+++ b/maven/build-engine/src/test/java/com/codename1/builders/JavaScriptBuildHintsTest.java
@@ -107,6 +107,29 @@ void translatorGetsTheTitleAndOnlyTheOptOutsThatWereSet() {
assertTrue(opts.contains("-Dcodename1.javascript.darkreaderLock=false"), opts.toString());
}
+ @Test
+ void splashHintsReachTheTranslatorAndTheIconIsTheDefaultLogo() {
+ BuildRequest r = request("javascript.splash.appearance", " dark ",
+ "javascript.splash.darkBackground", "#1e1e1e");
+ List opts = JavaScriptBuildHints.translatorOptions(r);
+ assertTrue(opts.contains("-Dcodename1.javascript.splash.appearance=dark"), opts.toString());
+ assertTrue(opts.contains("-Dcodename1.javascript.splash.darkBackground=#1e1e1e"), opts.toString());
+ // No icon: the PWA step writes a placeholder square, which is not worth showing.
+ assertFalse(opts.toString().contains("splash.logo"), opts.toString());
+
+ r.setIcon(new byte[] {1});
+ opts = JavaScriptBuildHints.translatorOptions(r);
+ assertTrue(opts.contains("-Dcodename1.javascript.splash.logo=" + JavaScriptBuildHints.SPLASH_DEFAULT_LOGO),
+ opts.toString());
+
+ r = request("javascript.splash.logo", "none");
+ r.setIcon(new byte[] {1});
+ opts = JavaScriptBuildHints.translatorOptions(r);
+ assertTrue(opts.contains("-Dcodename1.javascript.splash.logo=none"), opts.toString());
+ assertFalse(opts.contains("-Dcodename1.javascript.splash.logo=" + JavaScriptBuildHints.SPLASH_DEFAULT_LOGO),
+ opts.toString());
+ }
+
@Test
void noHintsShipsOnlyTheLegacyPair() {
assertEquals(set("iOS7Theme", "android_holo_light"), ship(request()));
diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java
index 1a44124a328..ec2bd6db845 100644
--- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java
+++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java
@@ -111,6 +111,78 @@ static void register(List h) {
+ "lock in its default Dynamic mode only; its Filter, Filter+ and Static modes "
+ "ignore it. Set to `false` to omit the tag."));
+ h.add(new Hint("javascript.splash.appearance")
+ .group(HintGroup.JAVASCRIPT)
+ .values("SplashAppearance", "auto", "light", "dark")
+ .def("auto")
+ .platform("javascript")
+ .doc("Which colours the loading screen uses. `auto` (the default) follows the "
+ + "browser's light/dark preference, or the choice the app remembered with "
+ + "`Display.setProperty(\"platformHint.javascript.splashAppearance\", \"dark\")`. "
+ + "`light` and `dark` always use that set, for apps with one appearance."));
+
+ h.add(new Hint("javascript.splash.background")
+ .group(HintGroup.JAVASCRIPT)
+ .type(HintType.STRING)
+ .def("#ffffff")
+ .platform("javascript")
+ .doc("The loading screen's background in light appearance, as a hex colour (`#rgb`, `#rrggbb`, "
+ + "with optional alpha) or a CSS colour name. Applied from the first frame the "
+ + "page paints until the first form shows."));
+
+ h.add(new Hint("javascript.splash.textColor")
+ .group(HintGroup.JAVASCRIPT)
+ .type(HintType.STRING)
+ .def("#666666")
+ .platform("javascript")
+ .doc("The loading screen's text colour in light appearance, as a hex colour (`#rgb`, `#rrggbb`, "
+ + "with optional alpha) or a CSS colour name. Applied from the first frame the "
+ + "page paints until the first form shows."));
+
+ h.add(new Hint("javascript.splash.accentColor")
+ .group(HintGroup.JAVASCRIPT)
+ .type(HintType.STRING)
+ .def("#0072ff")
+ .platform("javascript")
+ .doc("The loading screen's progress bar colour in light appearance, as a hex colour (`#rgb`, `#rrggbb`, "
+ + "with optional alpha) or a CSS colour name. Applied from the first frame the "
+ + "page paints until the first form shows."));
+
+ h.add(new Hint("javascript.splash.darkBackground")
+ .group(HintGroup.JAVASCRIPT)
+ .type(HintType.STRING)
+ .def("#121212")
+ .platform("javascript")
+ .doc("The loading screen's background in dark appearance, as a hex colour (`#rgb`, `#rrggbb`, "
+ + "with optional alpha) or a CSS colour name. Applied from the first frame the "
+ + "page paints until the first form shows."));
+
+ h.add(new Hint("javascript.splash.darkTextColor")
+ .group(HintGroup.JAVASCRIPT)
+ .type(HintType.STRING)
+ .def("#b3b3b3")
+ .platform("javascript")
+ .doc("The loading screen's text colour in dark appearance, as a hex colour (`#rgb`, `#rrggbb`, "
+ + "with optional alpha) or a CSS colour name. Applied from the first frame the "
+ + "page paints until the first form shows."));
+
+ h.add(new Hint("javascript.splash.darkAccentColor")
+ .group(HintGroup.JAVASCRIPT)
+ .type(HintType.STRING)
+ .def("#4da3ff")
+ .platform("javascript")
+ .doc("The loading screen's progress bar colour in dark appearance, as a hex colour (`#rgb`, `#rrggbb`, "
+ + "with optional alpha) or a CSS colour name. Applied from the first frame the "
+ + "page paints until the first form shows."));
+
+ h.add(new Hint("javascript.splash.logo")
+ .group(HintGroup.JAVASCRIPT)
+ .type(HintType.STRING)
+ .platform("javascript")
+ .doc("The image the loading screen shows above the progress bar, as a path relative to "
+ + "the bundle's `index.html` (letters, digits, `/`, `.`, `-` and `_`). Defaults to the "
+ + "app icon; `none` shows no image."));
+
h.add(new Hint("javascript.desktopTheme")
.group(HintGroup.JAVASCRIPT)
.type(HintType.STRING)
diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java
index e7e196e75a5..1a3022eac13 100644
--- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java
+++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptBundleWriter.java
@@ -1693,6 +1693,7 @@ private static void writeIndex(File outputDirectory) throws IOException {
System.getProperty(PAGE_TITLE_PROPERTY),
Boolean.parseBoolean(Util.systemProperty(ALLOW_TRANSLATION_PROPERTY, "false")),
!"false".equalsIgnoreCase(Util.systemProperty(DARKREADER_LOCK_PROPERTY, "true")));
+ index = applySplashSettings(index, SplashSettings.fromSystemProperties());
Util.writeUtf8(new File(outputDirectory, "index.html"), index);
}
@@ -1741,6 +1742,174 @@ static String applyPageSettings(String index, String title, boolean allowTransla
.replace("__APP_TITLE__", escapeHtml(pageTitle));
}
+ /// Prefix of the translator properties the `javascript.splash.*` build hints become.
+ static final String SPLASH_PROPERTY_PREFIX = "codename1.javascript.splash.";
+
+ /// Local storage key an application writes, through the
+ /// `platformHint.javascript.splashAppearance` property, to have the next launch's loading
+ /// screen follow its own light/dark choice instead of the browser's. `HTML5Implementation`
+ /// writes it; the inline script [#splashTheme(SplashSettings)] emits reads it before the
+ /// first paint.
+ static final String SPLASH_APPEARANCE_STORAGE_KEY = "cn1.splashAppearance";
+
+ /// The loading screen's look: the logo and the colours for each appearance. Every value is
+ /// validated here, because each one is written verbatim into the page's CSS or markup.
+ static final class SplashSettings {
+ /// `auto` (follow the browser, or the app's remembered choice), `light` or `dark`.
+ final String appearance;
+ final String background;
+ final String text;
+ final String accent;
+ final String darkBackground;
+ final String darkText;
+ final String darkAccent;
+ /// A URL relative to the bundle root, or null for no logo.
+ final String logo;
+
+ SplashSettings(String appearance, String background, String text, String accent,
+ String darkBackground, String darkText, String darkAccent, String logo) {
+ this.appearance = appearance(appearance);
+ this.background = color("background", background, "#ffffff");
+ this.text = color("textColor", text, "#666666");
+ this.accent = color("accentColor", accent, "#0072ff");
+ this.darkBackground = color("darkBackground", darkBackground, "#121212");
+ this.darkText = color("darkTextColor", darkText, "#b3b3b3");
+ this.darkAccent = color("darkAccentColor", darkAccent, "#4da3ff");
+ this.logo = logo(logo);
+ }
+
+ static SplashSettings defaults() {
+ return new SplashSettings(null, null, null, null, null, null, null, null);
+ }
+
+ static SplashSettings fromSystemProperties() {
+ return new SplashSettings(prop("appearance"), prop("background"), prop("textColor"),
+ prop("accentColor"), prop("darkBackground"), prop("darkTextColor"),
+ prop("darkAccentColor"), prop("logo"));
+ }
+
+ private static String prop(String name) {
+ return System.getProperty(SPLASH_PROPERTY_PREFIX + name);
+ }
+
+ private static String appearance(String value) {
+ if (value == null || value.trim().length() == 0) {
+ return "auto";
+ }
+ String v = value.trim();
+ if (v.equalsIgnoreCase("auto") || v.equalsIgnoreCase("light") || v.equalsIgnoreCase("dark")) {
+ return asciiLower(v);
+ }
+ throw new IllegalArgumentException("javascript.splash.appearance must be auto, light or dark, not '"
+ + value + "'");
+ }
+
+ /// A hex colour (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) or a CSS colour keyword. Nothing
+ /// else: `rgb(...)` and friends would need a parser to be sure they cannot close the
+ /// declaration, and the hex forms express every colour they can.
+ private static String color(String hint, String value, String def) {
+ if (value == null || value.trim().length() == 0) {
+ return def;
+ }
+ String v = value.trim();
+ boolean ok;
+ if (v.charAt(0) == '#') {
+ int n = v.length() - 1;
+ ok = n == 3 || n == 4 || n == 6 || n == 8;
+ for (int i = 1; ok && i < v.length(); i++) {
+ char c = v.charAt(i);
+ ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
+ }
+ } else {
+ ok = true;
+ for (int i = 0; ok && i < v.length(); i++) {
+ char c = v.charAt(i);
+ ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+ }
+ }
+ if (!ok) {
+ throw new IllegalArgumentException("javascript.splash." + hint
+ + " must be a hex colour such as #1e1e1e or a CSS colour name, not '" + value + "'");
+ }
+ return v;
+ }
+
+ /// `none` (or nothing) for no logo; otherwise a path inside the bundle. Restricted to
+ /// plain path characters so it can go into a `src` attribute as it is, and never `..`
+ /// or a scheme, so it cannot point the page at another origin.
+ private static String logo(String value) {
+ if (value == null || value.trim().length() == 0 || value.trim().equalsIgnoreCase("none")) {
+ return null;
+ }
+ String v = value.trim();
+ boolean ok = v.charAt(0) != '/' && v.indexOf("..") < 0;
+ for (int i = 0; ok && i < v.length(); i++) {
+ char c = v.charAt(i);
+ ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
+ || c == '/' || c == '.' || c == '-' || c == '_';
+ }
+ if (!ok) {
+ throw new IllegalArgumentException("javascript.splash.logo must be a path inside the web "
+ + "bundle (letters, digits, '/', '.', '-', '_'), not '" + value + "'");
+ }
+ return v;
+ }
+ }
+
+ private static String asciiLower(String s) {
+ StringBuilder out = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ out.append(c >= 'A' && c <= 'Z' ? (char) (c + ('a' - 'A')) : c);
+ }
+ return out.toString();
+ }
+
+ /// Fills the loading screen's placeholders: its colours and its logo.
+ ///
+ /// This adds an inline `");
+ } else {
+ out.append("");
+ }
+ return out.toString();
+ }
+
+ private static String vars(String background, String text, String accent) {
+ return "--cn1-splash-background:" + background + ";--cn1-splash-text:" + text
+ + ";--cn1-splash-accent:" + accent + ";";
+ }
+
private static String escapeHtml(String text) {
StringBuilder out = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
diff --git a/vm/ByteCodeTranslator/src/javascript/index.html b/vm/ByteCodeTranslator/src/javascript/index.html
index 2a4b0587370..cda1f49a17e 100644
--- a/vm/ByteCodeTranslator/src/javascript/index.html
+++ b/vm/ByteCodeTranslator/src/javascript/index.html
@@ -5,26 +5,57 @@
__APP_TITLE__
+