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

View file

@ -58,19 +58,21 @@ function close(): void {
<div class="u-top">
<span class="u-icon">{{ def.icon }}</span>
<span class="u-name">{{ def.name }}</span>
<span class="pips">
<i v-for="n in def.maxLevel" :key="n" :class="{ on: levelOf(def.id) >= n }" />
</span>
</div>
<div class="u-desc">{{ def.desc }}</div>
<div class="u-effect">{{ def.effectDesc(Math.max(1, levelOf(def.id))) }}</div>
<button
class="buy"
:disabled="nextCost(def) === null || crystals < (nextCost(def) ?? 0)"
@click="buy(def)"
>
{{ nextCost(def) === null ? 'MAX' : `💎 ${nextCost(def)}` }}
</button>
<div class="u-bottom">
<button
class="buy"
:disabled="nextCost(def) === null || crystals < (nextCost(def) ?? 0)"
@click="buy(def)"
>
{{ nextCost(def) === null ? 'MAX' : `💎 ${nextCost(def)}` }}
</button>
<span class="pips" :title="`${levelOf(def.id)} / ${def.maxLevel}`">
<i v-for="n in def.maxLevel" :key="n" :class="{ on: levelOf(def.id) >= n }" />
</span>
</div>
</div>
</div>
</div>
@ -178,6 +180,7 @@ h2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.u-icon {
font-size: 18px;
@ -185,11 +188,18 @@ h2 {
.u-name {
font-weight: 700;
font-size: 13.5px;
flex: 1;
}
.u-bottom {
margin-top: 4px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pips {
display: flex;
gap: 3px;
flex-shrink: 0;
}
.pips i {
width: 8px;
@ -211,7 +221,6 @@ h2 {
font-weight: 700;
}
.buy {
margin-top: 4px;
background: var(--panel-inset);
border: 1px solid #3d6f9e;
color: var(--text);
@ -221,7 +230,6 @@ h2 {
padding: 6px 10px;
cursor: pointer;
font-family: inherit;
align-self: flex-start;
}
.buy:hover:not(:disabled) {
border-color: var(--accent);
@ -235,4 +243,12 @@ h2 {
color: #ff8a7a;
font-size: 13px;
}
/* Phones: tighter modal padding, one upgrade column */
@media (max-width: 560px) {
.card {
padding: 16px 14px;
max-height: 92vh;
}
}
</style>

View file

@ -5,3 +5,13 @@ import { initAuth } from './game/auth'
createApp(App).mount('#app')
void initAuth()
// PWA: register the service worker (installable app + offline play).
// Dev mode is excluded so vite's HMR is never bypassed by a cached shell.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
/* offline support is optional ignore registration errors */
})
})
}