feat(pwa): installable app + fix research tree pip overflow

Research tree UI:
- The level pips shared the header row with the upgrade name and fought
  for width; long names like "Festungsmauern" (5 pips) pushed the pips
  out of the upgrade card. Pips now sit in the bottom row next to the
  buy button (space-between), where width is plentiful, and carry a
  "level / max" tooltip
- Tighter modal padding on phones (max-width 560px), scrollable at 92vh

PWA (installable as app in mobile browsers):
- public/manifest.webmanifest: standalone display, any orientation,
  TRXTD theme colors, German lang, start_url "/"
- public/sw.js: network-first service worker with runtime caching.
  index.html is always fetched fresh so new deploys are picked up
  immediately; the cache only serves as offline fallback. API calls
  and cross-origin requests are never intercepted
- Icon set generated by scripts/generate-icons.mjs (pure Node PNG
  encoder, zero image dependencies): gold tower on the game-themed
  dark gradient in 192px, 512px, maskable 512px (motif inside the safe
  zone) and 180px apple-touch-icon
- index.html: manifest link, favicon, apple-touch-icon, theme-color,
  mobile-web-app-capable, apple-mobile-web-app meta tags
- src/main.ts: service worker registration in production builds only
  (dev HMR stays untouched)
- server.mjs: serve .webmanifest as application/manifest+json (Chrome
  requires the correct MIME type for installability)

Verified against the production server: manifest (application/
manifest+json), icons (image/png) and sw.js (text/javascript) respond
with 200, index.html references all PWA tags; npm test 48/48 green.
This commit is contained in:
Tronax 2026-08-17 14:53:47 +02:00
parent 914af42210
commit 31cb594cb0
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
12 changed files with 296 additions and 13 deletions

168
scripts/generate-icons.mjs Normal file
View file

@ -0,0 +1,168 @@
/**
* Generates the PWA icon set (pure Node, no image dependencies):
* public/icons/icon-192.png (manifest, "any")
* public/icons/icon-512.png (manifest, "any")
* public/icons/icon-maskable-512.png (manifest, "maskable" content in safe zone)
* public/icons/apple-touch-icon.png (iOS home screen, 180px)
*
* Run: node scripts/generate-icons.mjs
*/
import fs from 'node:fs'
import path from 'node:path'
import zlib from 'node:zlib'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const OUT = path.join(ROOT, 'public', 'icons')
// ---------------------------------------------------------------- tiny canvas
/** analytic-coverage rasterizer: supersample 4x4 point samples per pixel */
function render(size, shapes, background) {
const px = new Uint8Array(size * size * 4)
const S = 4 // supersampling factor
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let r = 0
let g = 0
let b = 0
let covered = 0
for (let sy = 0; sy < S; sy++) {
for (let sx = 0; sx < S; sx++) {
const fx = x + (sx + 0.5) / S
const fy = y + (sy + 0.5) / S
const hit = topmost(shapes, fx, fy)
const bg = background(fx, fy)
const c = hit ?? bg
r += c[0]
g += c[1]
b += c[2]
if (hit) covered++
}
}
const n = S * S
const i = (y * size + x) * 4
px[i] = Math.round(r / n)
px[i + 1] = Math.round(g / n)
px[i + 2] = Math.round(b / n)
px[i + 3] = 255
}
}
return px
}
function topmost(shapes, x, y) {
for (let i = shapes.length - 1; i >= 0; i--) {
if (shapes[i].hit(x, y)) return shapes[i].color
}
return null
}
function rect(x1, y1, x2, y2, color) {
return { hit: (x, y) => x >= x1 && x < x2 && y >= y1 && y < y2, color }
}
function circle(cx, cy, r, color) {
return { hit: (x, y) => (x - cx) ** 2 + (y - cy) ** 2 < r * r, color }
}
// ---------------------------------------------------------------- design
// coordinates in a 512x512 design space; `s` scales the motif into the
// maskable safe zone, everything stays centered
function towerShapes(s = 1) {
const t = (v) => 256 + (v - 256) * s
const gold = [245, 197, 66]
const goldDark = [217, 169, 46]
const goldDeep = [196, 148, 32]
const dark = [23, 18, 10]
return [
rect(t(168), t(396), t(344), t(432), goldDeep), // plinth
rect(t(186), t(196), t(326), t(400), gold), // tower body
rect(t(186), t(196), t(210), t(400), goldDark), // body shading (left)
rect(t(186), t(166), t(218), t(198), gold), // merlon 1
rect(t(237), t(166), t(269), t(198), gold), // merlon 2
rect(t(288), t(166), t(320), t(198), gold), // merlon 3
circle(t(256), t(338), t(20) - t(0), dark), // door arch
rect(t(236), t(338), t(276), t(400), dark), // door
rect(t(247), t(238), t(265), t(292), dark), // window slit
]
}
/** vertical gradient background matching the game theme */
function bg(x, y) {
const k = y / 511
const top = [30, 42, 61]
const bottom = [14, 19, 26]
return [
Math.round(top[0] + (bottom[0] - top[0]) * k),
Math.round(top[1] + (bottom[1] - top[1]) * k),
Math.round(top[2] + (bottom[2] - top[2]) * k),
]
}
// ---------------------------------------------------------------- PNG encoder
const CRC_TABLE = (() => {
const table = new Int32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
table[n] = c
}
return table
})()
function crc32(buf) {
let c = -1
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
return (c ^ -1) >>> 0
}
function chunk(type, data) {
const out = Buffer.alloc(12 + data.length)
out.writeUInt32BE(data.length, 0)
out.write(type, 4, 'ascii')
data.copy(out, 8)
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length)
return out
}
function encodePng(size, rgba) {
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(size, 0)
ihdr.writeUInt32BE(size, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // color type RGBA
const raw = Buffer.alloc(size * (size * 4 + 1))
for (let y = 0; y < size; y++) {
raw[y * (size * 4 + 1)] = 0 // filter: none
Buffer.from(rgba.buffer, y * size * 4, size * 4).copy(raw, y * (size * 4 + 1) + 1)
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
])
}
// ---------------------------------------------------------------- generate
fs.mkdirSync(OUT, { recursive: true })
const targets = [
{ file: 'icon-192.png', size: 192, maskable: false },
{ file: 'icon-512.png', size: 512, maskable: false },
{ file: 'icon-maskable-512.png', size: 512, maskable: true },
{ file: 'apple-touch-icon.png', size: 180, maskable: false },
]
for (const { file, size, maskable } of targets) {
const k = size / 512 // design space -> pixel space
const shapes = towerShapes(maskable ? 0.66 : 0.92).map((sh) => {
// scale design coordinates into pixel space by wrapping the hit test
const hit = sh.hit
return { hit: (x, y) => hit(x / k, y / k), color: sh.color }
})
const background = (x, y) => bg(x / k, y / k)
const px = render(size, shapes, background)
fs.writeFileSync(path.join(OUT, file), encodePng(size, px))
console.log(`erstellt: public/icons/${file} (${size}x${size}${maskable ? ', maskable' : ''})`)
}