The role store I added in the moderation work is a module-level singleton seeded ONCE at import. `goto()` is a client-side navigation, so leaving and re-joining in the same tab re-imports no module and re-runs no onMount — the previous user's role simply stayed resident. Nothing reset it: not join, recover, admin login, "Event verlassen", `clearAuth`, nor the api.ts 401 auto-clear. So a host who left, followed by a guest joining on the same phone, left that guest with `isStaff === true` and a "🚫 Beitrag entfernen" action on other people's photos. The backend 403s the delete, so this was a false affordance rather than a privilege escalation — but `/feed` never fetched `/me/context`, so unlike every other route it never self-corrected either. It survived until a hard reload. The mirror case was equally broken and easier to overlook: a guest who recovered into a host account got NO host affordances. `clearAuth` already had a hook registry for exactly this shape of problem, with a comment explaining it exists to avoid circular imports. Add the missing mirror, `onSetAuth`, fired by both `setAuth` and `setAdminAuth` after the new token is resident, and have the role store register on both sides: clear to null on logout, re-seed from the new token on login. That also gives `syncRoleFromToken` — dead code with zero callers since I introduced it — its intended purpose. Seeding from the claim fixes the reported bug, but the claim is frozen for the token's 30-day life, so a promotion or demotion still wouldn't reach the feed. `/feed` now calls the existing `refreshEventState()` on mount, which fetches `/me/context` and applies both the authoritative role and the lock/release state in one request. The feed is the one route gating a destructive action on the role, so it should not be the only route running on a stale claim. Tests: 04-host/role-identity-reset drives the real flows. The first asserts the host DOES see the action before asserting the newcomer does not — a negative assertion alone would pass against a build that shipped no moderation at all. The second covers the mirror, promoting a guest server-side while their resident token still claims `role: guest`, so a fix that only cleared the role would fail it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lib/ conventions
Short rules. The patterns we already follow as of v0.16 — write new code that fits.
One store per cross-cutting concern. A single *-store.ts file owns each one:
auth.ts— JWT / PIN inlocalStorage,isAuthenticatedwritableui-store.ts— bottom-nav visibility, upload-sheet open state, FAB badge countdata-mode-store.ts— Saver vs Original media-loading preferenceprivacy-note-store.ts— admin-configured Datenschutzhinweis textquota-store.ts— live per-user storage snapshotupload-queue.ts— IndexedDB-persisted upload queue + processing state
Don't import these into other stores unless strictly necessary; let pages compose them.
DTOs mirror Rust types. All TS interfaces live in types.ts. Each one carries a
// mirrors backend/src/path::TypeName comment so the two stay searchable. If you add
a Rust DTO, add the TS twin in the same PR.
Gestures via Svelte actions in actions/. Long-press, double-tap, future swipe —
each is a use: action that fires a CustomEvent. Components stay free of gesture
plumbing.
Reusable bottom sheets via ContextSheet.svelte. Pass an actions: ContextAction[]
array. Any page that needs a long-press / kebab context menu uses the same primitive.
SSE relays are listed in sse.ts::KNOWN_EVENTS. New server event → add one entry
to that array, that's it.
Diashow transitions live in diashow/transitions/. Each is a Svelte component
plus one entry in transitions/index.ts. Adding a new animation is two-line work; no
diashow code needs to change.
No new global stores beyond the list above unless the new concept is genuinely app-wide. Page state belongs in the page.