Files
OwnCord/docs/architecture/ux
J3vbandClaude 5202e3fe1e fix: correctness fixes from the 2026-08-20 bug hunt (#1398)
* fix(identity): 2 defect(s) (OC-0192, OC-0197)

OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.

OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.

* fix(ws): 1 defect(s) (OC-0196)

A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.

* fix(api): 1 defect(s) (OC-0198)

* fix(ws): 1 defect(s) (OC-0200)

normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.

* fix(api): 1 defect(s) (OC-0202)

* fix(admin): 1 defect(s) (OC-0203)

Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.

* fix(client): 1 defect(s) (OC-0205)

* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)

* fix(client): 1 defect(s) (OC-0208)

* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)

OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.

OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).

OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.

* fix(ws): 1 defect(s) (OC-0211)

* fix(identity): 1 defect(s) (OC-0214)

The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.

* fix(client): 1 defect(s) (OC-0215)

* fix(voice): 1 defect(s) (OC-0216)

* fix(client): 1 defect(s) (OC-0217)

* fix(voice): 1 defect(s) (OC-0219)

rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.

* fix(client): 2 defect(s) (OC-0220, OC-0224)

dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.

updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.

* fix(client): 1 defect(s) (OC-0221)

Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.

* fix(ws): 1 defect(s) (OC-0222)

handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.

* fix(mentions): 1 defect(s) (OC-0223)

* fix(admin): 1 defect(s) (OC-0225)

* fix(client): 1 defect(s) (OC-0226)

* fix(client): 1 defect(s) (OC-0228)

* fix(client): 1 defect(s) (OC-0230)

Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.

* fix(voice): 1 defect(s) (OC-0231)

* fix(client): 1 defect(s) (OC-0232)

Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.

* fix(client): 1 defect(s) (OC-0233)

notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).

* fix(client): 1 defect(s) (OC-0234)

* fix(client): 1 defect(s) (OC-0236)

* fix(ws): 1 defect(s) (OC-0237)

* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)

* fix(identity): 1 defect(s) (OC-0195)

Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.

* fix(dm): 1 defect(s) (OC-0199)

handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.

* fix(voice): 1 defect(s) (OC-0206)

vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.

* fix(client): 1 defect(s) (OC-0229)

* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful

The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.

The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.

* fix(dm): 1 defect(s) (OC-0194)

Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.

The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.

Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.

* fix(ws): 1 defect(s) (OC-0210)

* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed

Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.

Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.

OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.

* docs: record the dm_channel_open emission on 1:1 DM creation

POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.

The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 20:45:30 +02:00
..

OwnCord Client UX Specification (target state)

Verified against: commit 5630aa1, 2026-08-04 Companion: ../client.md (structural module map) · ../../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, 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 App boot, server profiles, connect/health, login, TOTP, register-by-invite, the connected handshake, reconnect, and cert-TOFU trust prompts
messaging.md Composer + send (optimistic), edit/delete, reactions, attachments, replies, pins, search, read/unread, slow-mode, announcement read-only gating
channels-members-dms.md Channel list/switch/categories, member list + presence + typing, roles, DM open/close, blocking
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 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.tscomponents/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.onStateChangetoConnectionStatussetConnectionStatus), 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)
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.


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).
  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.