Mark and round the uploaded icon's favicon, enlarge the square icon's mark

An uploaded image's favicon now carries the stone mark like the PWA
icons and is cut to a circle. The mark is larger on the favicon and on
the regular (rounded-square) icons, and the settings preview matches.
A render revision in the set's version gives re-saved icons a new URL
past the immutable cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 05:09:21 +01:00
co-authored by Claude Opus 5.5
parent 8c4658f6e3
commit 50dd6976ee
4 changed files with 72 additions and 18 deletions
+21 -1
View File
@@ -160,6 +160,20 @@ export const PwaDefaultInstallIcon: PwaInstallIcon = {
export const PwaCornerMarkInset = 0.2; export const PwaCornerMarkInset = 0.2;
export const PwaCornerMarkScale = 0.22; export const PwaCornerMarkScale = 0.22;
/**
* The corner mark on the regular (rounded-square) icons, which aren't cut
* to a circle: larger and nearer the corner, clear of its rounding.
*/
export const PwaSquareCornerMarkInset = 0.16;
export const PwaSquareCornerMarkScale = 0.3;
/**
* The corner mark on a round favicon: larger, to stay readable at 32px,
* and still inside the circle.
*/
export const PwaFaviconCornerMarkInset = 0.15;
export const PwaFaviconCornerMarkScale = 0.32;
/** /**
* Uploads must be at least this many pixels on their shorter side, and are * Uploads must be at least this many pixels on their shorter side, and are
* kept as a square of the source size. * kept as a square of the source size.
@@ -167,12 +181,18 @@ export const PwaCornerMarkScale = 0.22;
export const PwaInstallIconMinSide = 512; export const PwaInstallIconMinSide = 512;
export const PwaInstallIconSourceSide = 1024; export const PwaInstallIconSourceSide = 1024;
/**
* Bump when `renderInstallIcons` draws differently: it's part of a set's
* version, so a re-saved icon gets a new URL past the `immutable` cache.
*/
export const PwaInstallIconRenderRevision = 4;
export type PwaInstallIconFile = { export type PwaInstallIconFile = {
name: string; name: string;
size: number; size: number;
/** /**
* `plain` is the image without the mark (the settings preview); * `plain` is the image without the mark (the settings preview);
* `favicon` never gets the mark (unreadable at 32px). * `favicon` is cut to a circle, the mark kept inside it.
*/ */
kind: "any" | "maskable" | "favicon" | "plain"; kind: "any" | "maskable" | "favicon" | "plain";
}; };
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { import {
PwaInstallIconRenderRevision,
PwaInstallIconSettingKey, PwaInstallIconSettingKey,
type PwaInstallIcon, type PwaInstallIcon,
type PwaInstallIconMode, type PwaInstallIconMode,
@@ -57,7 +58,7 @@ export default async function saveInstallIcon({ mode, stone, mark, image }: Para
const version = is_stock const version = is_stock
? null ? null
: createHash("sha256") : createHash("sha256")
.update(JSON.stringify({ mode, stone, mark })) .update(JSON.stringify({ mode, stone, mark, render: PwaInstallIconRenderRevision }))
.update(source || "") .update(source || "")
.digest("hex") .digest("hex")
.slice(0, 12); .slice(0, 12);
@@ -1,10 +1,14 @@
import sharp from "sharp"; import sharp, { type OverlayOptions } from "sharp";
import { import {
PwaAppIconMarkScale, PwaAppIconMarkScale,
PwaCornerMarkInset, PwaCornerMarkInset,
PwaCornerMarkScale, PwaCornerMarkScale,
PwaFaviconCornerMarkInset,
PwaFaviconCornerMarkScale,
PwaInstallIconFiles, PwaInstallIconFiles,
PwaMaskableMarkScale, PwaMaskableMarkScale,
PwaSquareCornerMarkInset,
PwaSquareCornerMarkScale,
type PwaInstallIcon, type PwaInstallIcon,
type PwaInstallIconFile, type PwaInstallIconFile,
} from "@/src/data/pwa-data"; } from "@/src/data/pwa-data";
@@ -26,7 +30,8 @@ type Params = {
/** /**
* Every file of an install's icon set (`PwaInstallIconFiles`), by name. * Every file of an install's icon set (`PwaInstallIconFiles`), by name.
* A stone icon is the stone on paper, like the stock one. An image fills * A stone icon is the stone on paper, like the stock one. An image fills
* the icon, with the stone in the top-left corner when `mark` is on. * the icon, with the stone in the top-left corner when `mark` is on; its
* favicon is cut to a circle, the mark kept inside it.
*/ */
export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) { export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) {
const files: Record<string, Buffer> = {}; const files: Record<string, Buffer> = {};
@@ -79,19 +84,41 @@ async function renderImageFile({
.resize(file.size, file.size, { fit: "cover" }) .resize(file.size, file.size, { fit: "cover" })
.flatten({ background }); .flatten({ background });
if (!mark || file.kind == "favicon" || file.kind == "plain") { const layers: OverlayOptions[] = [];
return base.png().toBuffer();
if (mark && file.kind != "plain") {
const { scale, inset_scale } = markPlacement({ kind: file.kind });
const mark_size = Math.max(1, Math.round(file.size * scale));
const inset = Math.round(file.size * inset_scale);
const stone = await sharp(stone_svg)
.resize(mark_size, mark_size, { fit: "contain" })
.png()
.toBuffer();
layers.push({ input: stone, left: inset, top: inset });
} }
const mark_size = Math.max(1, Math.round(file.size * PwaCornerMarkScale)); if (file.kind == "favicon") {
const inset = Math.round(file.size * PwaCornerMarkInset); const half = file.size / 2;
const stone = await sharp(stone_svg) const circle = Buffer.from(
.resize(mark_size, mark_size, { fit: "contain" }) `<svg xmlns="http://www.w3.org/2000/svg" width="${file.size}" height="${file.size}"><circle cx="${half}" cy="${half}" r="${half}"/></svg>`,
.png() );
.toBuffer();
return base layers.push({ input: circle, blend: "dest-in" });
.composite([{ input: stone, left: inset, top: inset }]) }
.png()
.toBuffer(); // Layers apply in order, so the circle cuts the mark too.
return base.ensureAlpha().composite(layers).png().toBuffer();
}
function markPlacement({ kind }: { kind: PwaInstallIconFile["kind"] }) {
if (kind == "favicon") {
return { scale: PwaFaviconCornerMarkScale, inset_scale: PwaFaviconCornerMarkInset };
}
if (kind == "any") {
return { scale: PwaSquareCornerMarkScale, inset_scale: PwaSquareCornerMarkInset };
}
return { scale: PwaCornerMarkScale, inset_scale: PwaCornerMarkInset };
} }
@@ -20,7 +20,8 @@ type Props = {
/** /**
* The app icon as a launcher would show it, laid out like the generated * The app icon as a launcher would show it, laid out like the generated
* files (`renderInstallIcons`): a stone at 52% (maskable) or 80% on paper, * files (`renderInstallIcons`): a stone at 52% (maskable) or 80% on paper,
* or the image with the stone 20% in from the top-left at 22% of the side. * or the image with the stone in the top-left: 20% in at 22% of the side
* (maskable, round), 16% in at 30% (rounded square).
*/ */
export default function InstallIconShape({ shape, mode, stone, mark, image_src, className }: Props) { export default function InstallIconShape({ shape, mode, stone, mark, image_src, className }: Props) {
const stone_src = `/brand/icons/${stone}.svg`; const stone_src = `/brand/icons/${stone}.svg`;
@@ -41,7 +42,12 @@ export default function InstallIconShape({ shape, mode, stone, mark, image_src,
<Img <Img
src={stone_src} src={stone_src}
alt="" alt=""
className="absolute left-[20%] top-[20%] size-[22%]" className={twMerge(
"absolute",
shape == "circle"
? "left-[20%] top-[20%] size-[22%]"
: "left-[16%] top-[16%] size-[30%]",
)}
/> />
) : null} ) : null}
</> </>