diff --git a/app/index.html b/app/index.html
index 6d43959..74799ee 100644
--- a/app/index.html
+++ b/app/index.html
@@ -5,7 +5,9 @@
Hankan — 한국어 읽기
-
+
+
+
diff --git a/app/public/apple-touch-icon.png b/app/public/apple-touch-icon.png
new file mode 100644
index 0000000..a9c612b
Binary files /dev/null and b/app/public/apple-touch-icon.png differ
diff --git a/app/public/favicon-32.png b/app/public/favicon-32.png
new file mode 100644
index 0000000..1289c14
Binary files /dev/null and b/app/public/favicon-32.png differ
diff --git a/app/public/favicon.ico b/app/public/favicon.ico
new file mode 100644
index 0000000..62475e4
Binary files /dev/null and b/app/public/favicon.ico differ
diff --git a/app/public/icon-192.png b/app/public/icon-192.png
new file mode 100644
index 0000000..e85e53a
Binary files /dev/null and b/app/public/icon-192.png differ
diff --git a/app/public/icon-512-maskable.png b/app/public/icon-512-maskable.png
new file mode 100644
index 0000000..9ccc4bf
Binary files /dev/null and b/app/public/icon-512-maskable.png differ
diff --git a/app/public/icon-512.png b/app/public/icon-512.png
new file mode 100644
index 0000000..d7cdf57
Binary files /dev/null and b/app/public/icon-512.png differ
diff --git a/app/vite.config.ts b/app/vite.config.ts
index aaa10c4..5b268e6 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -35,7 +35,7 @@ export default defineConfig({
react(),
VitePWA({
registerType: "autoUpdate",
- includeAssets: ["favicon.svg"],
+ includeAssets: ["favicon.ico", "favicon-32.png", "apple-touch-icon.png"],
manifest: {
name: "Hankan — 한국어 읽기",
short_name: "Hankan",
@@ -46,10 +46,12 @@ export default defineConfig({
background_color: "#F1F4F1",
theme_color: "#0F6B5C",
icons: [
- { src: "icon-192.png", sizes: "192x192", type: "image/png" },
- { src: "icon-512.png", sizes: "512x512", type: "image/png" },
+ { src: "icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
+ { src: "icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
+ // Android masks icons to a circle; this one keeps 한칸 inside the
+ // safe zone so the crop cannot clip it.
{
- src: "icon-512.png",
+ src: "icon-512-maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
diff --git a/tools/icons/make-icons.py b/tools/icons/make-icons.py
new file mode 100644
index 0000000..ab83c09
--- /dev/null
+++ b/tools/icons/make-icons.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Hankan's app icons: 한칸 on a jade ground.
+
+Drawn procedurally with Pillow rather than authored as SVG, because there is
+no dependable SVG rasteriser to convert from — and an icon that renders
+differently on the build machine than in the browser is worse than no icon.
+Pillow's FreeType + RAQM stack shapes Hangul correctly, which is the only
+hard requirement here.
+
+The mark follows the app's own register: the serif face it already uses for
+한칸 on the boot screen, in the app's jade and paper colours.
+
+Two glyphs read well at 180px and up. At favicon sizes 한칸 turns to mush, so
+the small sizes carry 한 alone — the same mark, cropped to what survives.
+
+ python3 tools/icons/make-icons.py
+
+Deterministic: re-running writes byte-identical files.
+"""
+
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFont
+
+ROOT = Path(__file__).resolve().parents[2]
+OUT = ROOT / "app" / "public"
+
+# --serif, matching the boot screen and display numerals.
+FONT = "/usr/share/fonts/opentype/noto/NotoSerifCJK-Bold.ttc"
+
+JADE = (15, 107, 92) # --jade #0F6B5C
+PAPER = (241, 244, 241) # --bg #F1F4F1
+
+WORDMARK = "한칸"
+GLYPH = "한"
+
+
+def render(size: int, text: str, *, coverage: float, out: Path) -> None:
+ """Draw `text` centred on a jade square, optically centred by ink bounds.
+
+ `coverage` is the fraction of the square the ink should span. Android
+ masks icons to a circle and can crop ~10% off each edge, so a maskable
+ icon passes a smaller value to keep the mark inside the safe zone.
+ """
+ img = Image.new("RGB", (size, size), JADE)
+ draw = ImageDraw.Draw(img)
+
+ # Binary-search the point size that lands the ink at the target width.
+ # Cheaper than solving it: font metrics include side bearings and CJK
+ # line gap, so the drawn extent is not a fixed ratio of the point size.
+ target = size * coverage
+ lo, hi = 1, size * 2
+ best = ImageFont.truetype(FONT, 1)
+ while lo <= hi:
+ mid = (lo + hi) // 2
+ font = ImageFont.truetype(FONT, mid)
+ left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
+ extent = max(right - left, bottom - top)
+ if extent <= target:
+ best, lo = font, mid + 1
+ else:
+ hi = mid - 1
+
+ # Centre on the ink itself, not the font's line box — CJK metrics leave
+ # asymmetric space above and below, which would sit the mark visibly high.
+ left, top, right, bottom = draw.textbbox((0, 0), text, font=best)
+ x = (size - (right - left)) / 2 - left
+ y = (size - (bottom - top)) / 2 - top
+ draw.text((x, y), text, font=best, fill=PAPER)
+
+ img.save(out, "PNG", optimize=True)
+ print(f" {out.relative_to(ROOT)} {size}×{size} {out.stat().st_size:,}B")
+
+
+def main() -> None:
+ OUT.mkdir(parents=True, exist_ok=True)
+ print("Icons → app/public/")
+
+ render(192, WORDMARK, coverage=0.74, out=OUT / "icon-192.png")
+ render(512, WORDMARK, coverage=0.74, out=OUT / "icon-512.png")
+ # Android crops to a circle: keep the mark well inside the safe zone.
+ render(512, WORDMARK, coverage=0.56, out=OUT / "icon-512-maskable.png")
+ render(180, WORDMARK, coverage=0.72, out=OUT / "apple-touch-icon.png")
+
+ # Small sizes: one glyph, so it is still legible in a browser tab.
+ render(32, GLYPH, coverage=0.82, out=OUT / "favicon-32.png")
+
+ ico = OUT / "favicon.ico"
+ base = Image.new("RGB", (48, 48), JADE)
+ d = ImageDraw.Draw(base)
+ f = ImageFont.truetype(FONT, 44)
+ l, t, r, b = d.textbbox((0, 0), GLYPH, font=f)
+ d.text(((48 - (r - l)) / 2 - l, (48 - (b - t)) / 2 - t), GLYPH, font=f, fill=PAPER)
+ base.save(ico, "ICO", sizes=[(16, 16), (32, 32), (48, 48)])
+ print(f" {ico.relative_to(ROOT)} 16/32/48 {ico.stat().st_size:,}B")
+
+
+if __name__ == "__main__":
+ main()