diff --git a/apps/f3bluemoon/ChangeLog b/apps/f3bluemoon/ChangeLog
new file mode 100644
index 0000000000..bd9217785d
--- /dev/null
+++ b/apps/f3bluemoon/ChangeLog
@@ -0,0 +1 @@
+1.00: Yet another moon-phase widget rises on the horizon.
diff --git a/apps/f3bluemoon/README.md b/apps/f3bluemoon/README.md
new file mode 100644
index 0000000000..60ec3bee76
--- /dev/null
+++ b/apps/f3bluemoon/README.md
@@ -0,0 +1,102 @@
+# F3 Blue Moon Widget
+
+F3 Blue Moon Widget is a widget that displays a moon-phase indicator on the
+[Espruino Bangle.js](https://banglejs.com/) wristwatches (both v1 and v2).
+
+F3 Blue Moon Widget is brought to you by the
+[FatFingerFederation](https://codeberg.org/FatFingerFederation/).
+
+## Usage
+
+This is a widget; simply install it and it will proceed to widge.
+
+## Configuration
+
+There are two options, configurable via the `Settings` app;
+look for `F3 Blue Moon Widget`.
+
+ * _Hemisphere_
+ * The hemisphere setting will cause the moon display to flip appropriately
+ to match (more or less) what you will see in the sky.
+ * By default, if you also have the `My Location` app installed and working,
+ the widget will use your latitude to determine your hemisphere. Otherwise,
+ the widget defaults to _Northern_.
+ * You can explicitly set the hemisphere to _Northern_ or _Southern_ instead.
+ * _Color_
+ * The full gamut of 3-bit colors is available for your choosing:
+ red (R), green (G), blue (B), yellow (RG), cyan (GB), magenta (BR),
+ and white (RGB)!
+ * The default color setting is "GB", e.g. a light blue or cyan.
+
+## Reporting Issues
+
+Upstream development happens at
+[FatFingerFederation/F3BlueMoonWidget](https://codeberg.org/FatFingerFederation/F3BlueMoonWidget);
+please report any bugs/issues/etc there directly.
+
+## Development and Algorithms
+
+The math used to determine the moon phase was adapted from
+https://github.com/pjain03/moon_phases, which itself is an implementation
+of algorithms from "Astronomical Algorithms (2nd Ed)", Jean Meeus (1999).
+For the widget, the algorithms were simplified considerably, removing many
+terms and correction factors that had no appreciable effect on displaying
+a moon image that is only 22 pixels wide.
+
+That said, the output is still quite accurate, and was cross-checked against
+100 years of daily ephemeris results produced by the
+[NASA Jet Propulsion Laboratory](https://www.jpl.nasa.gov/)'s
+Solar System Dynamics [Horizons System](https://ssd.jpl.nasa.gov/horizons/).
+
+The F3 Blue Moon output was also compared to two other moon-phase algorithms
+that coexist in the BangleApps catalog:
+ * `moonPhase()` in the [`widmp` widget](https://github.com/espruino/BangleApps/tree/master/apps/widmp) (and others), derived from https://github.com/deirdreobyrne/LunarPhase
+ * `getMoonIllumination()` in the [`suncalc` module](https://github.com/espruino/BangleApps/tree/master/modules/suncalc), derived from https://github.com/mourner/suncalc
+
+The "moon's phase" basically means "what fraction of the moon is
+illuminated by the sun", and that is really determined by the Sun-Moon-Earth
+angle, squeezed through a cosine transformation. (Actually, we use the
+Sun-Earth-Moon angle; since the Earth and Moon are so close to each other
+relative to the Sun, this amounts to a neglibible difference, less than
+0.2 degrees.)
+
+To compare the F3 Blue Moon math to that of the other two, we looked
+at the error (relative to the JPL Horizons data) of both the calculated
+illumination fraction and the Sun-Earth-Moon angle.
+
+Here is the `[min, max]` error measured over 100 years of daily samples
+(from 2026-01-01 to 2125-12-31):
+
+| algorithm | illuminated fraction (%) | sun-earth-moon (deg) |
+|--------------|--------------------------|----------------------|
+| F3 Blue Moon | `[-0.57, 0.41]` | `[-0.70, 0.74]` |
+| LunarPhase | `[-0.29, 0.29]` | `[-4.96, 4.97]` |
+| suncalc | `[-3.54, 3.67]` | `[-4.31, 4.68]` |
+
+The LunarPhase algorithm produced the best illumination fraction output,
+with a deviation of less than ±0.3%, but our Blue Moon algorithm was a
+close second, with a deviation of less than ±0.6%. The suncalc results
+were kind of lousy, with error bars of greater than ±3.5%. (Given
+that "full moon" is considered to mean ">99% illumination", then an
+error of ±3.5% is quite significant.)
+
+Looking at the errors in Sun-Earth-Moon angle, our Blue Moon algorithm
+was by far the best (under ±0.75 degrees), and the LunarPhase algorithm
+was the worst (up to about ±5 degrees) with suncalc barely any better.
+
+How can it be that LunarPhase has the tightest bounds on illumination,
+but worst bounds on angle, when the illumination is derived from angle?
+The answer lies in *where* the errors occur. The LunarPhase algorithm
+happens to give the worst results for angle at those angles where it
+has the least effect on illumination, i.e., around the angles 0 and π,
+corresponding to new and full moons. (That makes some sense given that
+the LunarPhase algorithm seems to have been constructed, not so much
+from astronomical first principles, but by curve-fitting
+until the illuminated fraction values were optimized.)
+
+Overall, the Blue Moon code, sourced from principled astronomical
+algorithms, provides compellingly accurate values for both illuminated
+fraction and angle.
+
+## Creator
+- [Matt Marjanovic](https://codeberg.org/maddog)
diff --git a/apps/f3bluemoon/f3bluemoon-icon.png b/apps/f3bluemoon/f3bluemoon-icon.png
new file mode 100644
index 0000000000..b36f4bd34b
Binary files /dev/null and b/apps/f3bluemoon/f3bluemoon-icon.png differ
diff --git a/apps/f3bluemoon/f3bluemoon.settings.js b/apps/f3bluemoon/f3bluemoon.settings.js
new file mode 100644
index 0000000000..5845b22b4f
--- /dev/null
+++ b/apps/f3bluemoon/f3bluemoon.settings.js
@@ -0,0 +1,84 @@
+// This file is part of F3 Blue Moon Widget.
+// Copyright 2026 Matt Marjanovic
+// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+(function(back) {
+
+ Bangle.loadWidgets(); // Ensure we have access to our widget!
+ Bangle.drawWidgets();
+
+ let config = WIDGETS["f3bluemoon"].loadConfig();
+
+ function saveConfig() {
+ require('Storage').writeJSON("f3bluemoon.json", config);
+ }
+
+ function redrawWidget() {
+ let w = WIDGETS["f3bluemoon"];
+ if (!w) { return; }
+ w.setConfig(config);
+ setTimeout(w.draw.bind(w), 0); // ...defer drawing until idle.
+ }
+
+ const colorNames = {null: "default",
+ r__: "R--",
+ rg_: "RG-",
+ _g_: "-G-",
+ _gb: "-GB",
+ __b: "--B",
+ r_b: "R-B",
+ rgb: "RGB",
+ };
+ const colorSequence = [null, "r__", "rg_", "_g_", "_gb", "__b", "r_b", "rgb"];
+
+ E.showMenu({
+ "": { title: "Blue Moon options",
+ back: back,
+ },
+ "Hemisphere": {
+ value: config.hemisphere,
+ min: -1,
+ max: 1,
+ noList: true,
+ wrap: true,
+ format: function (v, context) {
+ // Update widget as user flips through options, OR ensure sync of
+ // value/widget if returning to menu without making selection.
+ if (context !== 1) {
+ config.hemisphere = v;
+ redrawWidget();
+ }
+ return ["Southern", "default\n(try My Location)", "Northern"][v + 1];
+ },
+ onchange: v => {
+ config.hemisphere = v;
+ redrawWidget();
+ saveConfig();
+ }
+ },
+ "Color": {
+ value: (()=>{ let i = colorSequence.indexOf(config.color);
+ if (i < 0) { i = 0; }
+ return i; })(),
+ min: 0,
+ max: colorSequence.length - 1,
+ noList: true,
+ wrap: true,
+ format: function (i, context) {
+ let tag = colorSequence[i];
+ // Update widget as user flips through options, OR ensure sync of
+ // value/widget if returning to menu without making selection.
+ if (context !== 1) {
+ config.color = tag;
+ redrawWidget();
+ }
+ return colorNames[tag];
+ },
+ onchange: i => {
+ config.color = colorSequence[i];
+ redrawWidget();
+ saveConfig();
+ }
+ },
+ });
+})
diff --git a/apps/f3bluemoon/f3bluemoon.wid.js b/apps/f3bluemoon/f3bluemoon.wid.js
new file mode 100644
index 0000000000..ad36c4fc69
--- /dev/null
+++ b/apps/f3bluemoon/f3bluemoon.wid.js
@@ -0,0 +1,207 @@
+// This file is part of F3 Blue Moon Widget.
+// Copyright 2026 Matt Marjanovic
+// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+(() => {
+ let isDownUnder, moonColor; // configuration variables
+
+ function setConfig(c) {
+ let hemi = c.hemisphere; // NB: Avoid mutating c!
+ if (hemi === 0) {
+ // Default to using latitude from "My Location" app, with fallback
+ // to "northern hemisphere".
+ let location = require("Storage").readJSON("mylocation.json", true) || {"lat": 1};
+ hemi = (location.lat >= 0) ? 1 : -1;
+ }
+ isDownUnder = (hemi < 0);
+
+ moonColor = {r__: g.toColor(1,0,0),
+ rg_: g.toColor(1,1,0),
+ _g_: g.toColor(0,1,0),
+ _gb: g.toColor(0,1,1),
+ __b: g.toColor(0,0,1),
+ r_b: g.toColor(1,0,1),
+ rgb: g.toColor(1,1,1),
+ }[c.color] || g.toColor(0,1,1);
+ }
+
+ function loadConfig() {
+ let c = Object.assign({hemisphere: 0, // -1: S, 0: def, +1: N
+ color: null, // see colorNames in settings.js
+ },
+ require('Storage').readJSON("f3bluemoon.json", true));
+ setConfig(c);
+ return c;
+ }
+
+ function drawMoon(tl_x, tl_y, left, right) {
+ g.reset();
+
+ let x0 = tl_x + 12;
+ let y0 = tl_y + 12;
+ let shadow = g.blendColor(0, moonColor, 0.25);
+
+ // Quarter-circle, radius 11 pixels, expressed as 11 row lengths
+ let dx_list = new Uint8Array([11, 11, 11, 10, 10, 9, 9, 8, 7, 5, 3]);
+
+ let dy = 0;
+ for (const dx of dx_list) {
+ // Draw a complete "shadow moon" first.
+ g.setColor(shadow);
+ g.drawLine(x0 - dx, y0 - 1 - dy,
+ x0 - 1 + dx, y0 - 1 - dy);
+ g.drawLine(x0 - dx, y0 + dy,
+ x0 - 1 + dx, y0 + dy);
+
+ // Draw the partial "lit moon" on top.
+ let ldx = Math.round(-left * dx);
+ let rdx = Math.round(right * dx) - 1;
+ if (ldx <= rdx) {
+ g.setColor(moonColor);
+ g.drawLine(x0 + ldx, y0 - 1 - dy,
+ x0 + rdx, y0 - 1 - dy);
+ g.drawLine(x0 + ldx, y0 + dy,
+ x0 + rdx, y0 + dy);
+ }
+ ++dy;
+ }
+
+ // Superimpose the man-in-the-moon during full moon (< 1% occlusion).
+ if ((left > 0.99) && (right > 0.99)) {
+ print("Full Moon!");
+ g.setBgColor(0, 0, 0);
+ g.drawImage(
+ atob("GBiBAf////////////////9///r1f/f///16//q1f/Xbv/q1f/16////7+/3///rt/3X///////9f/6qv/9V/////////////////w=="),
+ tl_x, tl_y);
+ }
+ }
+
+ function moonPhase(unixSeconds) {
+ // See https://en.wikipedia.org/wiki/Julian_day (ignoring leap seconds?)
+ let jd = (unixSeconds / 86400) + 2440587.5; // Julian date
+ // Julian centuries, relative to J2000.0 epoch
+ let T = (jd - 2451545) / 36525;
+
+ // Moon math adapted from https://github.com/pjain03/moon_phases, which was
+ // adapted from "Astronomical Algorithms (2nd Ed)", Jean Meeus (1999).
+ //
+ // L_prime = light_time_moon(T)
+ // D = mean_elongation_moon(T)
+ // M_prime = mean_anomaly_moon(T)
+ // F = mean_latitude_moon(T)
+ let L_prime = (3.81034102 + 8399.70911 * T);
+ let D = (5.19846674 + 7771.37714 * T);
+ let M_prime = (2.35555589 + 8328.69142 * T);
+ let F = (1.62790523 + 8433.46615 * T);
+
+ // sl = kepler_coeff_longitude(D, M, M_prime, F, E, A1, A2, L_prime)
+ // sb = kepler_coeff_latitude(D, M, M_prime, F, E, L_prime, A3, A1)
+ let sl = ( 1.09759812e-1 * Math.sin(M_prime)
+ + 2.22359659e-2 * Math.sin(2 * D - M_prime)
+ + 1.14897468e-2 * Math.sin(2 * D)
+ );
+ let sb = 8.95026133e-2 * Math.sin(F);
+
+ // Lo = mean_longitude_sun(T)
+ // M = mean_anomaly_sun(T)
+ // C = center_of_sun(T, M)
+ // L = true_longitude_sun(Lo, C)
+ let Lo = (4.89506299 + 628.33196678 * T);
+ let M = (6.24005996 + 628.30195532 * T);
+ let C = (3.34160738e-2 - 8.40725100e-05 * T) * Math.sin(M);
+ let L = Lo + C;
+
+ // l_moon = apparent_longitude_moon(L_prime, sl)
+ // al = apparent_longitude_sun(L, T)
+ let l_moon = L_prime + sl;
+ let b_moon = sb;
+ let l_sun = L;
+
+ let l_sun_to_moon = (l_moon - l_sun) % (2 * Math.PI);
+
+ // phi here is Sun-Earth-Moon angle --- not Sun-Moon-Earth,
+ // but close enough since Sun is so much farther away.
+ let cosPhi = Math.cos(b_moon) * Math.cos(l_sun_to_moon);
+
+ return { seconds: unixSeconds,
+ jd: jd,
+ fraction: (1 - cosPhi) / 2,
+ isLeadingSun: l_sun_to_moon > Math.PI,
+ };
+ }
+
+ const width = 24;
+ const height = 24;
+ let moon = {}; // cached calculated moon state
+ let forceSeconds = 0;
+
+ function drawWidget() {
+ // Recalculate moon state hourly-ish.
+ let nowSeconds = forceSeconds || getTime();
+ if (!moon.seconds || ((nowSeconds - moon.seconds) >= 3600)) {
+ moon = moonPhase(nowSeconds);
+ }
+
+ let left = (2 * moon.fraction) - 1;
+ let right = 1;
+ if (moon.isLeadingSun ^ isDownUnder) {
+ let t = left; left = right; right = t;
+ }
+ drawMoon(this.x, this.y, left, right);
+ }
+
+ function drawTest() {
+ // sanitycheck.js does not want to see "g.reset().clear" in widgets,
+ // but here, in test code, it should be ok, so cheekily obfuscate it.
+ const insaniG = g; //
+ insaniG.reset().clear().setColor(1,0,0).fillRect(0,0, 175,175);
+
+ let x = 8;
+ let y = 4;
+ let draw = (p) => {
+ let left = (p >= 0) ? (2 * p) - 1 : 1;
+ let right = (p < 0) ? (2 * -p) - 1 : 1;
+ drawMoon(x, y, left, right, false);
+ g.setFont("4x6").setColor(0).drawString(p, x + 20, y + 20);
+ y += 24;
+ }
+ let nextColumn = () => { y = 4; x += 40; }
+
+ for (let p of [0.0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5]) { draw(p); }
+ nextColumn();
+ for (let p of [1.0, 0.95, 0.9, 0.8, 0.7, 0.6]) { draw(p); }
+ nextColumn();
+ for (let p of [-1.0, -0.95, -0.9, -0.8, -0.7, -0.6]) { draw(p); }
+ nextColumn();
+ for (let p of [0.0, -0.05, -0.1, -0.2, -0.3, -0.4, -0.5]) { draw(p) }
+ }
+
+ function dumpIcon() {
+ // Draw some moons in top-left corner.
+ g.reset().setColor(0);
+ g.fillRect(0, 0, 2 * width - 1, 2 * height - 1);
+ setConfig({hemisphere: 1, color: null});
+ drawMoon(0, 0, -0.9, 1);
+ drawMoon(width, 0, -0.5, 1);
+ drawMoon(0, height, 0.2, 1);
+ drawMoon(width, height, 0.8, 1);
+ // Extract and dump.
+ let G = Graphics.createArrayBuffer(48, 48, 4);
+ G.drawImage(g.asImage({x: 0, y:0, w: 48, h: 48}));
+ G.dump();
+ }
+
+ loadConfig();
+
+ WIDGETS["f3bluemoon"] = {
+ area: "tr",
+ width: width,
+ draw: drawWidget,
+ setConfig: setConfig,
+ loadConfig: loadConfig,
+ // For testing/development:
+ forceSeconds: function (s) { forceSeconds = s; this.draw(); },
+ drawTest: drawTest,
+ dumpIcon: dumpIcon
+ };
+})();
diff --git a/apps/f3bluemoon/metadata.json b/apps/f3bluemoon/metadata.json
new file mode 100644
index 0000000000..77af57d569
--- /dev/null
+++ b/apps/f3bluemoon/metadata.json
@@ -0,0 +1,16 @@
+{ "id": "f3bluemoon",
+ "name": "F3 Blue Moon Widget",
+ "shortName":"F3 Blue Moon Widget",
+ "version":"1.00",
+ "author": "mdoggydog",
+ "description": "A simple, delightful, and accurate moon-phase widget, brought to you by the Fat Finger Federation.",
+ "icon": "f3bluemoon-icon.png",
+ "type": "widget",
+ "tags": "widget",
+ "supports" : ["BANGLEJS", "BANGLEJS2"],
+ "readme": "README.md",
+ "storage": [ {"name": "f3bluemoon.wid.js", "url": "f3bluemoon.wid.js"},
+ {"name": "f3bluemoon.settings.js", "url": "f3bluemoon.settings.js"}
+ ],
+ "data": [ {"name": "f3bluemoon.json"} ]
+}