fix: unstretched PWA icons, maskable purpose, theme-aware favicon
- generate-icons.py: preserve SVG aspect ratio (was stretched 1.49x), art at 80% for maskable safe zone, emit transparent cross-gold.png - manifest icons: purpose 'any maskable' - applyTheme(): compose favicon per app theme (chrome bg + gold cross)
This commit is contained in:
1 parent
610cdca3ad
commit
b4ebd37cc3
6 files changed
+74
-13
No files matched your search
+37
-11
@@ -15,31 +15,57 @@ os.makedirs(ICONS_DIR, exist_ok=True)
|
|||||||
GOLD = (0xC5, 0xA5, 0x5A)
|
GOLD = (0xC5, 0xA5, 0x5A)
|
||||||
NAVY = (0x1A, 0x1A, 0x2E)
|
NAVY = (0x1A, 0x1A, 0x2E)
|
||||||
RENDER_SIZE = 2048
|
RENDER_SIZE = 2048
|
||||||
|
ART_SCALE = 0.8 # art occupies this fraction of the canvas (maskable safe zone)
|
||||||
|
|
||||||
def generate_icon(size):
|
|
||||||
|
def render_svg():
|
||||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||||
tmp_path = tmp.name
|
tmp_path = tmp.name
|
||||||
try:
|
try:
|
||||||
subprocess.run([
|
subprocess.run([
|
||||||
'rsvg-convert',
|
'rsvg-convert',
|
||||||
'-w', str(RENDER_SIZE),
|
|
||||||
'-h', str(RENDER_SIZE),
|
'-h', str(RENDER_SIZE),
|
||||||
|
'--keep-aspect-ratio',
|
||||||
SVG_PATH,
|
SVG_PATH,
|
||||||
'-o', tmp_path,
|
'-o', tmp_path,
|
||||||
], check=True)
|
], check=True)
|
||||||
img = Image.open(tmp_path).convert('RGBA')
|
return Image.open(tmp_path).convert('RGBA')
|
||||||
result = Image.new('RGBA', (size, size), NAVY + (255,))
|
|
||||||
cross = img.resize((size, size), Image.LANCZOS)
|
|
||||||
gold_layer = Image.new('RGBA', (size, size), GOLD + (255,))
|
|
||||||
result.paste(gold_layer, mask=cross.split()[3])
|
|
||||||
return result
|
|
||||||
finally:
|
finally:
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def gold_silhouette():
|
||||||
|
img = render_svg()
|
||||||
|
gold = Image.new('RGBA', img.size, GOLD + (255,))
|
||||||
|
gold.putalpha(img.split()[3])
|
||||||
|
return gold
|
||||||
|
|
||||||
|
|
||||||
|
def gold_art(long_side):
|
||||||
|
img = gold_silhouette()
|
||||||
|
scale = long_side / max(img.size)
|
||||||
|
return img.resize((round(img.width * scale), round(img.height * scale)), Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_app_icon(size):
|
||||||
|
canvas = Image.new('RGBA', (size, size), NAVY + (255,))
|
||||||
|
art = gold_art(round(size * ART_SCALE))
|
||||||
|
canvas.alpha_composite(art, ((size - art.width) // 2, (size - art.height) // 2))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def compose_transparent_art(size):
|
||||||
|
canvas = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||||
|
art = gold_art(size)
|
||||||
|
canvas.alpha_composite(art, ((size - art.width) // 2, (size - art.height) // 2))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
for size in [192, 512]:
|
for size in [192, 512]:
|
||||||
icon = generate_icon(size)
|
|
||||||
out = os.path.join(ICONS_DIR, f'icon-{size}.png')
|
out = os.path.join(ICONS_DIR, f'icon-{size}.png')
|
||||||
icon.save(out, 'PNG')
|
compose_app_icon(size).save(out, 'PNG')
|
||||||
print(f'Saved {out} ({size}x{size})')
|
print(f'Saved {out} ({size}x{size})')
|
||||||
|
|
||||||
print('Icons generated.')
|
transparent_out = os.path.join(ICONS_DIR, 'cross-gold.png')
|
||||||
|
compose_transparent_art(512).save(transparent_out, 'PNG')
|
||||||
|
print(f'Saved {transparent_out} (512x512)')
|
||||||
@@ -29,12 +29,47 @@ export class App {
|
|||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private faviconImage: HTMLImageElement | null = null;
|
||||||
|
private faviconCanvas: HTMLCanvasElement | null = null;
|
||||||
|
|
||||||
private applyTheme() {
|
private applyTheme() {
|
||||||
document.documentElement.className = 'theme-' + (this.settings.theme || 'default');
|
document.documentElement.className = 'theme-' + (this.settings.theme || 'default');
|
||||||
const chromeColor = getComputedStyle(document.documentElement).getPropertyValue('--clr-browser-chrome').trim();
|
const chromeColor = getComputedStyle(document.documentElement).getPropertyValue('--clr-browser-chrome').trim();
|
||||||
if (chromeColor) {
|
if (chromeColor) {
|
||||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', chromeColor);
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', chromeColor);
|
||||||
}
|
}
|
||||||
|
this.updateFavicon(chromeColor || '#1a1a2e');
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFavicon(bgColor: string) {
|
||||||
|
const size = 64;
|
||||||
|
if (!this.faviconCanvas) this.faviconCanvas = document.createElement('canvas');
|
||||||
|
const canvas = this.faviconCanvas;
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
const draw = () => {
|
||||||
|
if (!this.faviconImage?.complete || !this.faviconImage.naturalWidth) return;
|
||||||
|
const artSize = Math.round(size * 0.8);
|
||||||
|
ctx.drawImage(this.faviconImage, (size - artSize) / 2, (size - artSize) / 2, artSize, artSize);
|
||||||
|
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.type = 'image/png';
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
};
|
||||||
|
if (!this.faviconImage) {
|
||||||
|
this.faviconImage = new Image();
|
||||||
|
this.faviconImage.onload = draw;
|
||||||
|
this.faviconImage.src = '/icons/cross-gold.png';
|
||||||
|
}
|
||||||
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkDataVersion() {
|
private async checkDataVersion() {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 6.6 KiB |
+2
-2
@@ -51,8 +51,8 @@ export default defineConfig({
|
|||||||
theme_color: '#1a1a2e',
|
theme_color: '#1a1a2e',
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
icons: [
|
icons: [
|
||||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
|
||||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in new issue
Block a user