Public landing, owner-list links, owner-conditional semi, PWA + mobile

Four related UX/privacy/install changes.

1. **Logged-out lands on the public list.** The root route now shows the
   same Home view as a logged-in user, minus their own private rows and
   the "Ny aktivitet" button. The nav exposes a "Logg inn" button when
   no session is present. Login becomes one click away, not the forced
   landing — anyone can browse the public + semi list anonymously.

2. **Public activities link to /<owner_username>/list.** When a public
   activity's owner has opted into a public list, the "Lagt til av X"
   line renders X as a link to /<username>/list. Server populates
   `owner_username` on every public-row serialisation (null when the
   owner hasn't opted in, so the client just renders plain text).

3. **Conditional owner_id on semi rows.** The server now serialises
   `owner_id` on a semi row ONLY when the viewer IS the owner. The
   wire type's `ActivitySemi.owner_id` is therefore optional. This
   solves the semi-delete UX without leaking attribution: owners see
   Edit/Delete buttons on their own semi rows; non-owners get the same
   bare row they got before. The privacy property is enforced at the
   API boundary, not in client-side render logic.

4. **Mobile-friendly + installable PWA.**
   - `manifest.webmanifest` with name, theme color, standalone display,
     and a maskable SVG icon (icon.svg).
   - Service worker (sw.js): cache-first for the bundled shell;
     network-only for /api/* (we never cache session-dependent or
     ciphertext data — see the comment in sw.js for the rationale).
     Falls back to the SPA shell for navigation requests when offline.
   - SW registered in main.ts only in production builds (import.meta.env.PROD).
   - viewport-fit=cover + env(safe-area-inset-*) padding so content
     doesn't slip under iOS notches when installed.
   - WCAG 2.5.5 touch-target sizing: min-height: 44px on buttons,
     with an explicit opt-out for tag-close buttons (24×24 still meets
     the 2.5.8 minimum).
   - 16px font on form inputs below 480px so iOS doesn't auto-zoom.

Server-side: server/index.ts now serves manifest, icon, and sw.js
from frontend/dist alongside /assets/*. The catch-all still serves
index.html so the SPA's /<username>/list path routing keeps working.

Smoke-tested with a production-mode server: manifest returns the
correct application/manifest+json MIME, SVG renders, sw.js is
loadable, and unknown paths fall through to index.html as expected.

26 tests still pass; both tsconfigs typecheck (frontend now pulls
vite/client types for import.meta.env.PROD); Vite build succeeds.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 12:57:59 +02:00
commit f0b4d735b5
15 changed files with 317 additions and 54 deletions

View file

@ -115,8 +115,11 @@ export interface FeedbackEntry {
export interface ActivityPublic {
id: string;
visibility: 'public';
owner_id: string; // serialized for public
owner_display: string; // display_name OR derived handle (email prefix)
owner_id: string; // serialized for public
owner_display: string; // display_name OR derived handle (email prefix)
// Owner's URL slug, if they've opted into a public list. When non-null, the
// client renders the owner attribution as a link to /<owner_username>/list.
owner_username: string | null;
title: string;
tags: string[];
loc_label: string | null;
@ -130,7 +133,10 @@ export interface ActivityPublic {
export interface ActivitySemi {
id: string;
visibility: 'semi';
// owner_id deliberately omitted — see SECURITY.md
// Set ONLY when the viewer is the owner. Lets the client surface
// Edit/Delete on the user's own semi rows without leaking attribution to
// anyone else. Stripped server-side for any other viewer; see SECURITY.md.
owner_id?: string;
title: string;
tags: string[];
loc_label: string | null;