# OwnCord Client UX Specification (target state) **Verified against:** commit `5630aa1`, 2026-08-04 **Companion:** [../client.md](../client.md) (structural module map) · [../../audit-2026-08-04-docs-and-coverage.md](../../audit-2026-08-04-docs-and-coverage.md) This directory specifies **how the Tauri client should behave** — what every UI step does, and how each view reacts to server events, permission state, and failure. Unlike [client.md](../client.md), which maps the code as-built, these documents are **prescriptive (to-be)**: they describe the intended target UX. Where today's code diverges, each flow carries a **⚠ Current gap** callout — so this set doubles as a UX improvement backlog. Gaps are grounded in real `file:line` references from the client. > **Scope.** This is a behavior spec, not a visual design spec. It defines > states, transitions, events, and reactions — not pixel layout, spacing, or > color. Those live in `src/styles/tokens.css` and the component CSS. ## Documents | Doc | Covers | |-----|--------| | [connection-and-auth.md](connection-and-auth.md) | App boot, server profiles, connect/health, login, TOTP, register-by-invite, the connected handshake, reconnect, and cert-TOFU trust prompts | | [messaging.md](messaging.md) | Composer + send (optimistic), edit/delete, reactions, attachments, replies, pins, search, read/unread, slow-mode, announcement read-only gating | | [channels-members-dms.md](channels-members-dms.md) | Channel list/switch/categories, member list + presence + typing, roles, DM open/close, blocking | | [voice-and-e2ee.md](voice-and-e2ee.md) | Voice join/leave, mute/deafen/camera/screenshare, push-to-talk, active-speaker, and the E2EE securing/key-ready indicators | | [settings-and-admin.md](settings-and-admin.md) | Settings tabs, profile/password/2FA/delete-account, appearance/theming, the inline admin surface (ban/kick/roles, channel CRUD, invites), and the updater | The cross-cutting vocabulary and global reaction matrices below apply to **every** document; the per-flow docs reference them rather than repeating them. --- ## 1. View-state vocabulary Every data-bearing view must be able to represent each of these states and must choose a defined presentation for each (a view may legitimately collapse some — e.g. a view that can never be empty — but that must be a decision, not an omission): | State | Meaning | Default presentation | |-------|---------|----------------------| | `loading` | A fetch/subscription is in flight and no cached data is shown yet | Skeleton or inline spinner in the view's own region — **never** a full-screen blocker except the initial connected handshake | | `ready` | Data present and current | The normal view | | `empty` | Fetch succeeded, zero items | A labelled empty state with a one-line "what goes here / what to do next" hint | | `error` | Fetch/action failed | Inline error with a **Retry** affordance for recoverable errors; a toast only for fire-and-forget actions | | `stale` | Data shown but known out of date (e.g. during reconnect) | The normal view plus a non-blocking status hint (connection banner); interactions that require a live socket are disabled with a reason | | `permission-denied` | The user may see the view but not act | The view renders read-only; the disallowed control is **disabled with a visible reason**, never hidden silently and never enabled-then-rejected | | `offline` | No live socket | Live-only controls disabled with the connection status surfaced | **Principle — no silent states.** Every terminal outcome (success, empty, failure, denial) produces *some* observable feedback. A control that will be rejected by the server must be pre-disabled with a reason; an action that succeeds without a visible result must emit a confirmation. --- ## 2. Feedback primitives The client has a fixed set of feedback surfaces. Each has one job; pick by the decision table, don't improvise. | Primitive | Source | Use for | Do **not** use for | |-----------|--------|---------|--------------------| | **Toast** (`info`/`success`/`error`, 5 s auto-dismiss, max 5) | `lib/toast.ts` → `components/Toast.ts` | Transient results of an explicit user action (sent, copied, saved, "couldn't reach server") | Anything the user must act on; anything that must survive navigation | | **Inline field error** | per-form | Validation and per-field server rejections (bad password, weak input) | Global/connection state | | **Inline section error + Retry** | per-view | A failed load of a view's own data (messages, invites, pins) | One-shot actions (use a toast) | | **Persistent banner** | `components/ServerBanner.ts` (reconnect/restart), ad-hoc cert banner | Connection status: reconnecting, server-restart countdown, first-trust cert notice | Per-action results | | **Blocking modal** | `lib/modalFactory.ts` (+ `CertMismatchModal`) | Decisions that must be made before proceeding: cert mismatch, destructive confirm | Routine feedback; anything dismissable-by-ignoring | | **Two-click / inline confirm** | `AdminActions.ts` `withConfirmation`, `PendingDeleteManager` | Reversible-ish destructive actions in dense menus (kick, ban, delete channel, delete message) | Irreversible account-level actions (use a modal with typed confirm) | | **Disabled control + reason** | per-control | Actions not currently permitted (offline, no permission, slow-mode cooldown, upload in flight) | Errors that already happened | | **Transient-error store** (`ui.store.setTransientError`) | survives navigation | A message that must appear on the *connect* page after a forced disconnect (banned, kicked, restart) | In-session messaging (use a toast) | --- ## 3. Connection status is a first-class, observable state Every live-only interaction keys off one connection status. Target: a single source of truth in `ui.store.connectionStatus` (`connected | reconnecting | disconnected`), written from the WS client's `onStateChange`, and read by any control that needs a live socket. > **✓ Implemented (2026-07).** `ui.store.connectionStatus` is now the single > source of truth: `main.ts` calls `wireConnectionStatus(ws)`, whose writer > lives in `lib/dispatcher.ts` > (`ws.onStateChange` → `toConnectionStatus` → `setConnectionStatus`), mapping > the internal 5-state machine onto the 3-state status (`connecting` / > `authenticating` read as `reconnecting`, since a reconnect cycle passes > through them). Consumers subscribe to the store instead of wiring ad-hoc > callbacks: the reconnect banner (`MainPage`, synced at mount and now also > showing "Disconnected" instead of going stale), the composer gating > (`ChannelController`, "Reconnecting…" / "Not connected" per the table), and > the presence picker (`UserBar` — previously dead in production because > `SidebarArea` never passed it a `ws`; it now gates on the store and receives > the `ws` send path). The one-shot connected-overlay wiring in `main.ts` stays > on `ws.onStateChange` deliberately — it needs the exact internal transition. > The voice column is now wired too: the VoiceWidget freezes its in-call controls > (disabled + a "Reconnecting…" / "Not connected" reason) while > `connectionStatus !== "connected"`, **and** the join affordance itself — the > voice-channel row in the `ChannelSidebar` — takes the same disabled-with-reason > state (`.disabled` class, `aria-disabled`, a "Reconnecting…" / "Not connected" > title) and its click becomes a no-op, so joining/leaving is visibly gated, not a > silent dead click. The join/leave callbacks (`VoiceCallbacks.ts`) still refuse to > fire `voice_join`/`voice_leave` over a down socket as a defensive backstop. > LiveKit's own reconnection keeps retrying underneath — only the UI is gated, > never LiveKit's machinery. | Status | Composer / send | Voice controls | Presence picker | Reconnect banner | |--------|-----------------|----------------|-----------------|------------------| | `connected` | enabled | enabled | enabled | hidden | | `reconnecting` | disabled, "Reconnecting…" | frozen, retrying underneath | disabled | visible, spinner | | `disconnected` | disabled | torn down | disabled | visible or → connect page on fatal | --- ## 4. Global event → reaction map The dispatcher (`src/lib/dispatcher.ts`) is the single fan-in from the socket to the stores. Target: **every** inbound message type produces a defined store mutation *and*, where user-visible, a defined UI reaction. The per-flow docs detail each; this is the index. | Inbound event | Store effect | Target UI reaction | |---------------|--------------|--------------------| | `auth_ok` | `auth.setAuth` | Advance handshake → ready overlay | | `auth_error` | `ui.setTransientError` + `auth.clearAuth` | Return to connect page with the reason shown | | `ready` | bulk-load channels/roles/members/voice/dm | Render main view; resolve the connected overlay | | `chat_message` | `messages.addMessage` (+ unread/DM/notify) | Append; reconcile a pending optimistic row if it's our echo | | `chat_send_ok` | `messages.confirmSend` | Mark the optimistic row **sent** (see gap in [messaging.md](messaging.md)) | | `chat_edited` / `chat_deleted` | `messages.editMessage` / `deleteMessage` | In-place edit / tombstone | | `chat_bulk_deleted` | `messages.bulkDeleteMessages` | Remove every purged row in one pass | | `reaction_update` | `messages.updateReaction` | Toggle the pill + count, reflect `me` | | `typing` | `members.setTyping` (5 s auto-clear) | Typing indicator | | `presence` / `member_update` / `user_update` | `members.*` | Live member-list update | | `member_join` / `member_leave` / `member_ban` | `members.add/remove` | Member-list add/remove | | `channel_create` / `channel_update` / `channel_delete` | `channels.*` | Sidebar update; redirect if the active channel was deleted | | `roles_update` | `channels.setRoles` | Refresh name colors + permission-gated affordances | | `emoji_update` | `emoji.setCustomEmoji` | Refresh picker, autocomplete, and rendered custom emoji | | `voice_state` / `voice_leave` / `voice_config` / `voice_speakers` | `voice.*` | Voice roster + speaking rings | | `voice_moved` / `voice_disconnected` | `voice.*` + `livekitSession` | Follow a mod move by rejoining the new channel / tear down after a mod kick with an error toast naming the reason | | `voice_token` / `voice_e2ee_*` | `livekitSession.*` | Drive the voice-join + securing indicators | | `dm_channel_open` / `dm_channel_close` | `dm.*` | DM list add/remove | | `server_restart` | `ui.setTransientError` | Restart banner with countdown | | `error` | `ui.setTransientError` (+ `clearAuth` on `BANNED`) | Map the code → the reaction in §5 | `call_incoming` / `call_declined` are deliberately *not* routed through the dispatcher: `MainPage.ts` subscribes to them directly (page-scoped listeners) and drives the ring state machine in `lib/call-ring.ts` + `components/IncomingCallBanner.ts`. > **✓ Implemented (2026-07).** Error codes are no longer silently dropped for > sends: the server echoes the request id on error replies, so `SLOW_MODE`, > `FORBIDDEN`, `RATE_LIMITED`, `BAD_REQUEST`, etc. are mapped to the exact > optimistic row that failed (retry offered), and `chat_send_ok`'s > `message_id`/`timestamp` now reconcile the pending row. See the optimistic > lifecycle in [messaging.md](messaging.md). --- ## 5. Error & permission reaction matrix One canonical reaction per failure class, applied everywhere. Today error handling is per-call-site with no shared mapper (`doFetch()` in `lib/api.ts` centralizes only 401); this matrix is the target contract. | Class | Source | Target reaction | |-------|--------|-----------------| | **401 Unauthorized** | any REST call | Global: `clearAuth()` → disconnect → connect page, with "Your session expired — sign in again." (centralized in `api.ts` + `main.ts`; since 2026-07 `uploadFile` honors it too, and the connect page shows the session-expired reason) | | **403 Forbidden** (action) | REST/WS | Toast "You don't have permission to do that." **and** pre-disable the control so it can't be attempted again in that context | | **403 Suspended/Banned** | login REST / WS `BANNED` | Transient-error store → connect page: "Your account has been suspended." Force logout, no reconnect | | **429 Rate-limited** | REST/WS `RATE_LIMITED` | Non-destructive toast "You're doing that too fast — try again in a moment." Keep the user's input; re-enable the control after a short cooldown | | **Slow-mode** | WS `SLOW_MODE` | Disable send with a live countdown in the composer; do not drop the drafted message | | **Validation (400)** | REST | Inline field error with the server message (capped to a safe length — the login form caps at 200 chars in the `handleFormSubmit()` catch block, `pages/connect-page/LoginForm.ts`; apply everywhere) | | **Conflict/Not-found (404/409)** | REST/WS | Contextual inline message + refresh the affected view (the target moved/vanished) | | **5xx / network** | REST | Inline section error + **Retry**; for one-shot actions, a toast "Couldn't reach the server." Never a silent drop | | **Transport backpressure** | WS `ws_send` "channel full" | Mark the optimistic row failed with Retry (✓ since 2026-07: `ws.onSendFailure` → dispatcher → `markSendFailed` with `NETWORK`/`OFFLINE`; id-less sends like heartbeats stay silent) | | **Cert first-use** | Rust `cert-tofu: first_use` | **Blocking trust modal** (`createCertFirstUseModal`): the Rust proxy *rejects* the first connection rather than auto-pinning; Accept stores the pin and retries, Cancel leaves the server untrusted (already: the `ws.onCertFirstUse(...)` handler in `main.ts`) | | **Cert mismatch** | Rust `cert-tofu: mismatch` | Blocking `CertMismatchModal`; Accept re-pins + reconnects, Reject disconnects + returns to connect (already: the `ws.onCertMismatch(...)` handler in `main.ts`) | --- ## 6. Cross-cutting principles 1. **Optimistic where the user acts, authoritative where the server decides.** Local actions (send, react, mute) reflect immediately with a *pending* marker, then reconcile against the server echo; on failure they roll back visibly with a retry — never silently. 2. **Permission is expressed as affordance, not as rejection.** If the server will refuse, the client disables the control with a reason first. The announcement-channel composer is the canonical example (see [messaging.md](messaging.md)). 3. **Connection state gates live controls reactively** (§3), not per-click. 4. **One reaction per failure class** (§5), applied uniformly. 5. **No silent success and no silent failure** (§1). ## Maintenance rule Same as the blueprint set: if a PR changes a client flow, event handler, or the set of states a view must represent, it updates the corresponding UX doc in the same change. These specs reference stable identifiers (event-type strings, store action names, component names) over line numbers; the `file:line` anchors in the gap callouts are point-in-time and dated by the header.