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.
The game layout stacked HUD, board, and tower shop vertically with
desktop-sized chrome, leaving a tiny unusable board on phones (about
250x136px in landscape). Touch input and responsive canvas scaling
already existed — only the surrounding layout blocked mobile play.
Landscape phones / short viewports (max-height: 560px):
- New .game-main wrapper switches to a row layout: the board keeps the
full remaining width and the tower shop becomes a vertical column on
the right edge with one compact row per tower (icon + name + cost)
- HUD compacts to a single row (no 78px wave section, no preview chips,
smaller stats and buttons); layout padding and gaps shrink
- Board grows from ~250x136 to ~487x268 on a 740x360 viewport
Portrait phones (max-width: 560px):
- Tower shop renders five compact icon tiles in a single row (69px each,
no horizontal overflow)
- HUD wraps into compact rows; difficulty chip and preview hidden
All screens:
- Overlay panels (tower info, obstacle, pause, end screen) may now scale
down to 0.65 via --ui-scale so they no longer cover a small board
(previously clamped to a minimum of 1)
Verified in a real browser: landscape 740x360 (board 487x268, shop column
right), portrait 390x844 (board 366x201, shop row fits, no overflow),
desktop 1280x800 unchanged (column layout, board 1020x561). Touch flow
tested end-to-end: tap shop card -> tap board builds a tower (gold
260->210), tap tower opens the scaled info panel with upgrade/sell.
The app container had a fixed height: 100dvh with overflow: hidden, which
is correct for the game layout but cut off the start screen on small
viewports (mobile phones) — map cards, difficulty selection, and the help
section were unreachable with no way to scroll.
Changes:
- App.vue: add a "scrollable" class (overflow-y: auto) to the app container
when the screen is "menu" or "lobby"; the in-game layout keeps the fixed
locked viewport
- LobbyScreen.vue: change .lobby height: 100dvh to min-height: 100dvh so a
tall lobby card grows instead of being clipped by flex centering on small
screens
Verified in a real browser at 390x700 (mobile viewport): the menu content
overflows (2261px vs 700px viewport), .app reports overflow-y: auto and is
scrollable, and after a simulated swipe the help section at the bottom
becomes visible (scrollTop 900 of max 1561).
1Password showed "No items to display" on the signup password field
because login and registration shared a single form whose password input
switched autocomplete between current-password and new-password, and the
display-name field was dynamically added via v-if. Per 1Password compatible
website design, unrelated flows must be separate forms with stable fields.
Changes:
- Split into two distinct <form> elements: login (autocomplete
current-password) and signup (autocomplete new-password), each with
unique field ids so 1Password does not cache the field as a login field
- Add passwordrules, minlength=8, and maxlength=128 to the signup password
input so 1Password can generate a password matching the server rules
(min 8 chars)
- Remove the dynamic autocomplete computed and the v-if display-name field;
each form now has a static, complete field set
- Add .fields form styling (flex column, gap) now that the forms sit inside
the card container
Password managers (1Password, Bitwarden, …) could not reliably detect or
fill the auth fields because the inputs were loose labels inside a div with
no real form, no name attributes, and a static autocomplete hint.
Changes:
- Wrap the auth card in a real <form @submit.prevent> so password managers
recognize the credential form and its submit flow
- Add id/name attributes to all fields (username, displayName, password)
- Use a dynamic autocomplete hint on the password field:
current-password for login (autofill) and new-password for registration
(offer a generated strong password)
- Set autocomplete="nickname" on the display-name field so it is not
mistaken for a username or password field
- Mark the close, tab-switch, and OIDC buttons as type="button" so they no
longer default to type="submit" and trigger form submission
- Make the primary action a type="submit" button so Enter submits natively
- Add margin: 0 to .card since it is now a form element
The bind-mount permission fix only takes effect after a rebuild, so a
stale image keeps failing with the opaque "unable to open database file".
This hardens both sides so any remaining failure is self-explanatory:
- docker-entrypoint.sh: fall back to chmod 777 when chown is unsupported
(network/9p mounts), and fall back to running as root when su-exec is
unavailable, so the data dir is always writable on any filesystem.
- server/db.mjs: wrap the DatabaseSync open in a try/catch and, on
failure, report the exact path, whether the directory is writable, and
the process UID instead of the bare SQLite error.
Verified with a real container: a normal bind mount serves /health and
creates trxtd.db; a read-only mount now prints the directory-permission
diagnostic instead of the raw SQLite error.
The container ran as the unprivileged "node" user, but bind-mounted host
directories (e.g. ./data:/app/data in docker-compose or an Unraid appdata
folder) are mounted with root ownership. The node user therefore could not
create trxtd.db in /app/data, failing at startup with:
Error: unable to open database file
at file:///app/server/db.mjs:12
Fix:
- Add docker-entrypoint.sh that mkdir/chown /app/data to node:node on
container start, then drops privileges back to node via su-exec
- Install su-exec in the runtime stage (apk add --no-cache su-exec)
- Remove USER node so the entrypoint runs as root and can fix ownership,
with privilege dropping handled inside the entrypoint instead
- Wire the script up as ENTRYPOINT, keeping CMD as the app itself
The mount path itself is unchanged (/app/data, host ./data). Verified with
a real bind mount: /health responds ok and trxtd.db/-shm/-wal are created
in the mounted directory.
- Add a toggle on the picture-in-picture window to swap the big board
with the mini view of the opponent's field
- Color-coded frame and badge identify whose board is currently shown
(blue = player 1, orange = player 2)
- Block building and rushing while spectating the opponent's board;
the PiP then shows your own field instead
- Clarify duel-mode descriptions in lobby and start screen, add hints
in the multiplayer bar
- Replace previous announcement banners so only one shows at a time
- Add CSP, frame and referrer headers for served static files
- Enforce WebSocket origin check to prevent cross-site hijacking
- Trust X-Forwarded-For only when the peer is from a private proxy network
- Limit concurrent connections (500 total / 20 per IP) and rooms (300)
- Add ALLOWED_ORIGINS env var for additional WebSocket origins
- Document reverse proxy setup (NPM/NPMplus) in README
- Add scripts/security-test.mjs to verify origin and limit behavior
- Add Dockerfile (multi-stage build, unprivileged user, healthcheck),
docker-compose.yml and .dockerignore
- Server now serves static dist/ frontend and WebSocket relay on a
single port (PORT, default 3001) with path-traversal protection,
immutable asset caching and SPA fallback
- Client connects via same origin in production; dedicated ws port is
only used for vite dev/preview
- Document Docker usage, env vars and manual production mode in README