diff --git a/src/data/pwa-data.ts b/src/data/pwa-data.ts index ab987d1..0b17607 100644 --- a/src/data/pwa-data.ts +++ b/src/data/pwa-data.ts @@ -160,6 +160,20 @@ export const PwaDefaultInstallIcon: PwaInstallIcon = { export const PwaCornerMarkInset = 0.2; 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 * kept as a square of the source size. @@ -167,12 +181,18 @@ export const PwaCornerMarkScale = 0.22; export const PwaInstallIconMinSide = 512; 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 = { name: string; size: number; /** * `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"; }; diff --git a/src/functions/backend/install-icon/save-install-icon.ts b/src/functions/backend/install-icon/save-install-icon.ts index 8505302..40c9819 100644 --- a/src/functions/backend/install-icon/save-install-icon.ts +++ b/src/functions/backend/install-icon/save-install-icon.ts @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { + PwaInstallIconRenderRevision, PwaInstallIconSettingKey, type PwaInstallIcon, type PwaInstallIconMode, @@ -57,7 +58,7 @@ export default async function saveInstallIcon({ mode, stone, mark, image }: Para const version = is_stock ? null : createHash("sha256") - .update(JSON.stringify({ mode, stone, mark })) + .update(JSON.stringify({ mode, stone, mark, render: PwaInstallIconRenderRevision })) .update(source || "") .digest("hex") .slice(0, 12); diff --git a/src/functions/server/pwa/render-install-icons.ts b/src/functions/server/pwa/render-install-icons.ts index 6c7ea8a..17ecf6f 100644 --- a/src/functions/server/pwa/render-install-icons.ts +++ b/src/functions/server/pwa/render-install-icons.ts @@ -1,10 +1,14 @@ -import sharp from "sharp"; +import sharp, { type OverlayOptions } from "sharp"; import { PwaAppIconMarkScale, PwaCornerMarkInset, PwaCornerMarkScale, + PwaFaviconCornerMarkInset, + PwaFaviconCornerMarkScale, PwaInstallIconFiles, PwaMaskableMarkScale, + PwaSquareCornerMarkInset, + PwaSquareCornerMarkScale, type PwaInstallIcon, type PwaInstallIconFile, } from "@/src/data/pwa-data"; @@ -26,7 +30,8 @@ type Params = { /** * 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 - * 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) { const files: Record = {}; @@ -79,19 +84,41 @@ async function renderImageFile({ .resize(file.size, file.size, { fit: "cover" }) .flatten({ background }); - if (!mark || file.kind == "favicon" || file.kind == "plain") { - return base.png().toBuffer(); + const layers: OverlayOptions[] = []; + + 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)); - const inset = Math.round(file.size * PwaCornerMarkInset); - const stone = await sharp(stone_svg) - .resize(mark_size, mark_size, { fit: "contain" }) - .png() - .toBuffer(); + if (file.kind == "favicon") { + const half = file.size / 2; + const circle = Buffer.from( + ``, + ); - return base - .composite([{ input: stone, left: inset, top: inset }]) - .png() - .toBuffer(); + layers.push({ input: circle, blend: "dest-in" }); + } + + // 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 }; } diff --git a/src/pages/admin/settings/general/(partials)/install-icon-shape.tsx b/src/pages/admin/settings/general/(partials)/install-icon-shape.tsx index 5718703..bc12b8c 100644 --- a/src/pages/admin/settings/general/(partials)/install-icon-shape.tsx +++ b/src/pages/admin/settings/general/(partials)/install-icon-shape.tsx @@ -20,7 +20,8 @@ type Props = { /** * 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, - * 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) { const stone_src = `/brand/icons/${stone}.svg`; @@ -41,7 +42,12 @@ export default function InstallIconShape({ shape, mode, stone, mark, image_src, ) : null}