FN-6512: refresh mobile PWA icons

Refresh the dashboard launcher artwork so installed mobile PWAs use the Fusion brand mark.

- Generate 192px and 512px dashboard PWA icons from the canonical logo in the desktop icon generator.
- Bump the service-worker cache name so installed PWAs refresh cached icon assets.
- Strengthen PWA tests to validate icon wiring, PNG dimensions, opacity, and non-blank brand content.
- Document the PWA icon regeneration workflow and add a patch changeset.

Files changed:
 .changeset/fn-6512-dashboard-pwa-icons.md        |   5 +
 docs/dashboard-guide.md                          |   4 +
 packages/dashboard/app/__tests__/pwa.test.ts     | 178 ++++++++++++++++++++---
 packages/dashboard/app/public/icons/icon-192.png | Bin 5466 -> 4634 bytes
 packages/dashboard/app/public/icons/icon-512.png | Bin 17687 -> 14826 bytes
 packages/dashboard/app/public/sw.js              |   2 +-
 packages/desktop/scripts/generate-icons.ts       |  75 +++++++---
 7 files changed, 227 insertions(+), 37 deletions(-)

Fusion-Task-Id: FN-6512

Fusion-Task-Lineage: a7a46e0d-fa7d-4ca1-b49c-f083aed92b17
This commit is contained in:
gsxdsm
2026-06-16 22:29:07 -07:00
parent af1ddda318
commit d35f93e1ef
7 changed files with 224 additions and 34 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Refresh dashboard mobile and PWA home-screen icons from the canonical Fusion logo and bump the service-worker cache for installed app updates.

View File

@@ -8,6 +8,10 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, restart Fusion to apply the new version because the already-running dashboard server is unchanged until restart.
## Mobile/PWA app icons
The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images.
## Browser Navigation
The dashboard now handles browser back navigation consistently on desktop and mobile.

View File

@@ -1,8 +1,16 @@
import { readFileSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { inflateSync } from "node:zlib";
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
type DecodedPng = {
width: number;
height: number;
colorType: number;
pixels: Buffer;
};
function getStandaloneDisplayModeBlock(css: string): string {
const match = /@media\s*\(\s*display-mode:\s*standalone\s*\)\s*\{/.exec(css);
expect(match).toBeTruthy();
@@ -21,6 +29,89 @@ function getStandaloneDisplayModeBlock(css: string): string {
return css.slice(start, i);
}
function decodeRgbaPng(filePath: string): DecodedPng {
const buffer = readFileSync(filePath);
const signature = buffer.subarray(0, 8).toString("hex");
expect(signature).toBe("89504e470d0a1a0a");
let offset = 8;
let width = 0;
let height = 0;
let bitDepth = 0;
let colorType = 0;
const idatChunks: Buffer[] = [];
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.subarray(offset + 4, offset + 8).toString("ascii");
const dataStart = offset + 8;
const dataEnd = dataStart + length;
const data = buffer.subarray(dataStart, dataEnd);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data.readUInt8(8);
colorType = data.readUInt8(9);
} else if (type === "IDAT") {
idatChunks.push(data);
} else if (type === "IEND") {
break;
}
offset = dataEnd + 4;
}
expect(bitDepth).toBe(8);
expect(colorType).toBe(6);
const bytesPerPixel = 4;
const stride = width * bytesPerPixel;
const inflated = inflateSync(Buffer.concat(idatChunks));
const pixels = Buffer.alloc(width * height * bytesPerPixel);
let inputOffset = 0;
let outputOffset = 0;
for (let y = 0; y < height; y += 1) {
const filter = inflated[inputOffset];
inputOffset += 1;
for (let x = 0; x < stride; x += 1) {
const raw = inflated[inputOffset + x];
const left = x >= bytesPerPixel ? pixels[outputOffset + x - bytesPerPixel] : 0;
const up = y > 0 ? pixels[outputOffset + x - stride] : 0;
const upLeft = y > 0 && x >= bytesPerPixel ? pixels[outputOffset + x - stride - bytesPerPixel] : 0;
let value: number;
if (filter === 0) {
value = raw;
} else if (filter === 1) {
value = raw + left;
} else if (filter === 2) {
value = raw + up;
} else if (filter === 3) {
value = raw + Math.floor((left + up) / 2);
} else if (filter === 4) {
const predictor = left + up - upLeft;
const pa = Math.abs(predictor - left);
const pb = Math.abs(predictor - up);
const pc = Math.abs(predictor - upLeft);
const paeth = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft;
value = raw + paeth;
} else {
throw new Error(`Unsupported PNG filter ${filter} in ${filePath}`);
}
pixels[outputOffset + x] = value & 0xff;
}
inputOffset += stride;
outputOffset += stride;
}
return { width, height, colorType, pixels };
}
describe("PWA configuration", () => {
it("manifest defines required PWA fields and icon sizes", () => {
const manifestPath = resolve(__dirname, "../public/manifest.json");
@@ -29,7 +120,7 @@ describe("PWA configuration", () => {
short_name?: string;
start_url?: string;
display?: string;
icons?: Array<{ sizes?: string }>;
icons?: Array<{ src?: string; sizes?: string; type?: string; purpose?: string }>;
};
expect(manifest.name).toBe("Fusion");
@@ -37,8 +128,18 @@ describe("PWA configuration", () => {
expect(manifest.start_url).toBe("/");
expect(manifest.display).toBe("standalone");
expect(Array.isArray(manifest.icons)).toBe(true);
expect(manifest.icons?.some((icon) => icon.sizes?.includes("192"))).toBe(true);
expect(manifest.icons?.some((icon) => icon.sizes?.includes("512"))).toBe(true);
expect(manifest.icons).toContainEqual({
src: "/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
});
expect(manifest.icons).toContainEqual({
src: "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
});
});
it("index.html includes required PWA meta tags", () => {
@@ -93,7 +194,7 @@ describe("PWA configuration", () => {
expect(swSource).toContain('addEventListener("install"');
expect(swSource).toContain('addEventListener("fetch"');
expect(swSource).toContain('addEventListener("activate"');
expect(swSource).toMatch(/fusion-cache-v\d+/);
expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v4";');
});
it("service worker bypasses SSE requests instead of trying to cache them", () => {
@@ -162,21 +263,60 @@ describe("PWA configuration", () => {
expect(logoSvg).not.toContain("r=\"20\"");
});
it("PWA icon files exist with correct sizes", async () => {
const fs = await import("node:fs");
it("PWA icon files exist, decode to expected sizes, and are opaque non-blank PNGs", () => {
const icons = [
{ path: resolve(__dirname, "../public/icons/icon-192.png"), size: 192 },
{ path: resolve(__dirname, "../public/icons/icon-512.png"), size: 512 },
];
const icon192Path = resolve(__dirname, "../public/icons/icon-192.png");
const icon512Path = resolve(__dirname, "../public/icons/icon-512.png");
for (const icon of icons) {
expect(existsSync(icon.path)).toBe(true);
expect(statSync(icon.path).size).toBeGreaterThan(icon.size * 12);
expect(fs.existsSync(icon192Path)).toBe(true);
expect(fs.existsSync(icon512Path)).toBe(true);
const png = decodeRgbaPng(icon.path);
expect(png.width).toBe(icon.size);
expect(png.height).toBe(icon.size);
expect(png.colorType).toBe(6);
// Verify PNG files have reasonable size (not empty)
const stats192 = fs.statSync(icon192Path);
const stats512 = fs.statSync(icon512Path);
let opaquePixels = 0;
let transparentPixels = 0;
let brandMarkPixels = 0;
const brandBackground = [0x1a, 0x1a, 0x2e];
expect(stats192.size).toBeGreaterThan(100);
expect(stats512.size).toBeGreaterThan(100);
for (let index = 0; index < png.pixels.length; index += 4) {
const alpha = png.pixels[index + 3];
if (alpha === 255) opaquePixels += 1;
else transparentPixels += 1;
const colorDistance =
Math.abs(png.pixels[index] - brandBackground[0]) +
Math.abs(png.pixels[index + 1] - brandBackground[1]) +
Math.abs(png.pixels[index + 2] - brandBackground[2]);
if (colorDistance > 8) brandMarkPixels += 1;
}
expect(transparentPixels).toBe(0);
expect(opaquePixels).toBe(icon.size * icon.size);
expect(brandMarkPixels).toBeGreaterThan(icon.size * icon.size * 0.1);
}
});
it("wires the same PWA icons through manifest, apple touch, and service-worker precache", () => {
const manifest = JSON.parse(readFileSync(resolve(__dirname, "../public/manifest.json"), "utf8")) as {
icons?: Array<{ src?: string; sizes?: string; purpose?: string }>;
};
const indexHtml = readFileSync(resolve(__dirname, "../index.html"), "utf8");
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
const iconSources = ["/icons/icon-192.png", "/icons/icon-512.png"];
for (const iconSource of iconSources) {
expect(manifest.icons?.some((icon) => icon.src === iconSource && icon.purpose === "any")).toBe(true);
expect(swSource).toContain(`"${iconSource}"`);
}
expect(indexHtml).toContain('<link rel="icon" type="image/svg+xml" href="/logo.svg" />');
expect(indexHtml).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v4";');
});
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "fusion-cache-v3";
const CACHE_NAME = "fusion-cache-v4";
const APP_SHELL_URLS = [
"/",
"/index.html",

View File

@@ -9,20 +9,52 @@ const repoRoot = resolve(packageDir, "../..");
const sourceSvgPath = resolve(repoRoot, "packages/dashboard/app/public/logo.svg");
const outputDir = resolve(packageDir, "src/icons");
const dashboardPwaIconDir = resolve(repoRoot, "packages/dashboard/app/public/icons");
const iconSizes = [16, 32, 48] as const;
const dashboardPwaIconSizes = [192, 512] as const;
const APP_ICON_SIZE = 1024;
const APP_ICON_PADDING = 128;
const APP_ICON_BG = "#0d1117";
const APP_ICON_FG = "#58a6ff";
const DASHBOARD_PWA_ICON_BG = "#1a1a2e";
const DASHBOARD_PWA_ICON_FG = APP_ICON_FG;
async function renderIconTile(options: {
tintedSvg: string;
size: number;
padding: number;
background: string;
outputPath: string;
}): Promise<void> {
const markSize = options.size - options.padding * 2;
const mark = await sharp(Buffer.from(options.tintedSvg), { density: 2048 })
.resize(markSize, markSize, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
await sharp({
create: {
width: options.size,
height: options.size,
channels: 4,
background: options.background,
},
})
.composite([{ input: mark, top: options.padding, left: options.padding }])
.png({ compressionLevel: 9 })
.toFile(options.outputPath);
}
async function main(): Promise<void> {
const sourceSvg = await readFile(sourceSvgPath, "utf8");
const trayTintedSvg = sourceSvg.replaceAll("currentColor", "#333333");
const appTintedSvg = sourceSvg.replaceAll("currentColor", APP_ICON_FG);
const dashboardPwaTintedSvg = sourceSvg.replaceAll("currentColor", DASHBOARD_PWA_ICON_FG);
await mkdir(outputDir, { recursive: true });
await mkdir(dashboardPwaIconDir, { recursive: true });
await Promise.all(
iconSizes.map(async (size) => {
@@ -34,25 +66,34 @@ async function main(): Promise<void> {
}),
);
const markSize = APP_ICON_SIZE - APP_ICON_PADDING * 2;
const mark = await sharp(Buffer.from(appTintedSvg), { density: 2048 })
.resize(markSize, markSize, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
await renderIconTile({
tintedSvg: appTintedSvg,
size: APP_ICON_SIZE,
padding: APP_ICON_PADDING,
background: APP_ICON_BG,
outputPath: resolve(outputDir, "icon.png"),
});
await sharp({
create: {
width: APP_ICON_SIZE,
height: APP_ICON_SIZE,
channels: 4,
background: APP_ICON_BG,
},
})
.composite([{ input: mark, top: APP_ICON_PADDING, left: APP_ICON_PADDING }])
.png({ compressionLevel: 9 })
.toFile(resolve(outputDir, "icon.png"));
/*
FNXC:DashboardPWAIcons 2026-06-16-21:10:
Dashboard mobile home-screen icons must be derived from packages/dashboard/app/public/logo.svg so the installed PWA, iOS apple-touch tile, and Android launcher glyph stay aligned with the in-app Fusion brand mark.
Regenerate these assets and bump the dashboard service-worker cache whenever the canonical logo.svg brand mark changes.
*/
await Promise.all(
dashboardPwaIconSizes.map(async (size) => {
await renderIconTile({
tintedSvg: dashboardPwaTintedSvg,
size,
padding: Math.round(size * (APP_ICON_PADDING / APP_ICON_SIZE)),
background: DASHBOARD_PWA_ICON_BG,
outputPath: resolve(dashboardPwaIconDir, `icon-${size}.png`),
});
}),
);
console.log(`Generated ${iconSizes.length} tray icons and app icon in ${outputDir}`);
console.log(
`Generated ${iconSizes.length} tray icons and app icon in ${outputDir}; ${dashboardPwaIconSizes.length} dashboard PWA icons in ${dashboardPwaIconDir}`,
);
}
void main();