monal-rebuild/UX-AUDIT-REPORT.md

426 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Mobile UX audit results — 2026-10-07
The extended direct-chat audit makes new-device message reliability the
first priority: a cached OMEMO device list can omit a newly added browser,
leaving new messages unreadable while the sender sees a read tick. Encrypted
direct attachments are also offered but cannot be sent. Group information,
drafts and reading-position preservation remain frequent-task gaps. Core
text delivery works with a current device list; the successful initial file
checks were group uploads, not encrypted direct attachments.
This is an agent-led exploratory audit and documentation-based comparison,
not a user study or a completed real-iPhone PWA certification. No application
fix or production deployment was performed.
## Environment and evidence
- User's main device: iPhone 17 Pro, iOS 26.6, installed PWA.
- Automated engines: Chromium 152.0.7977.82 and Linux Playwright WebKit 26.5.
The latter is not installed Safari/iOS 26.6. Touch-enabled contexts used
a 402 × 874 viewport; WebKit used Playwright's iPhone 13 user-agent.
- Additional viewport checks: 320 × 700, 874 × 402 and 1280 × 800. They
exercise responsive layout, not actual desktop/mobile OS integration.
- Target: `https://chat.0ff.space`, bundle `assets/index-DLDjjWaX.js`, SHA-256
`f153a8723ae24c14db115f725c24bd7cab3a0355695d4430efef2ffc19978f23`.
Both engines fetched that same bundle. Its bytes match the active nginx
root `/nix/store/lqny4y6afnm1mvq234995x53xcd8lwnw-0ff-chat-0.1.0`.
The package source's `src/` and `public/` match the local checkout at
`4eb726340a6448fba81fc91ec10bbeee65863d6b`. The deployed Git revision
itself was not independently established; the bundle hash identifies it.
- Dedicated accounts A/B and test room are named in [UX-AUDIT.md](UX-AUDIT.md).
All sent messages and uploads were synthetic, within those accounts.
- Chromium full report: `/tmp/chat-ux-runner/results/chromium-1791380418245/report.json`.
- Corrected Chromium reconnect report:
`/tmp/chat-ux-runner/results/chromium-1791380751412/report.json`.
- WebKit full report: `/tmp/chat-ux-runner/results/webkit-1791380800982/report.json`.
- Extended Chromium direct/PWA report:
`/tmp/chat-ux-runner/results/direct-pwa-chromium-1791385881194/report.json`.
- Extended WebKit direct/PWA report:
`/tmp/chat-ux-runner/results/direct-pwa-webkit-1791386884704/report.json`.
- Extended runs use the same bundle/hash above and private persistent A
profiles. Standalone media/navigator indicators are injected; this is not
an actual Home Screen installation. Real browser storage, workers and
the live app are exercised. Chromium-only freeze/push/voice tests are
labeled separately below. Profile data includes authentication/key
material; keep the entire runtime directory private and outside Git.
- Each full-report directory contains screenshots of group headers,
message actions, four group layouts, settings, attachments and the
fingerprint dialog. Runtime evidence is outside Git and subject to /tmp
cleanup; the findings below preserve the observations in the repository.
- Protocol evidence: `/tmp/chat-ux-runner/room-info.json`, queries made as
both owner and joined ordinary member of the dedicated test room.
## Prioritized findings
### P1 — new direct-chat devices are missed; read ticks can mislead (D00)
Reproduce in both engines: open A's conversation with B, caching B's
existing devices. Then log B into a new browser profile and send a new
message from A. A's cached list omits B's current device ID. B displays
"Could not decrypt this message." for the new live message, while A can
show `read`. This is distinct from the expected undecryptable backlog on
a device that did not exist when old messages were sent.
Diagnostic evidence: each report compares the cached public device IDs
with B's current ID. Deleting only the B device-list entry in A's private
test profile and reloading allows new messages to decrypt. Subsequent
successful delivery/read/link checks explicitly depend on that test
intervention; it is not a production fix or normal user workflow.
`cachedDeviceList` returns an IndexedDB entry indefinitely, without a
freshness check; `markDisplayed` can mark an undecryptable incoming item
as displayed. These source observations support the reproduced behavior.
Proposal: refresh device lists when peers add/remove devices, with a
bounded freshness policy/fallback and clear key/device status. Decide
what a read marker means when the content cannot be decrypted; avoid
implying successful reading. Never fall back to plaintext to hide failures.
Acceptance: add a device after an existing conversation is open, send in
both directions without clearing storage, and verify the new device can
read messages; remove a device and verify encryption targets update.
Undecryptable content must have accurate status and a useful recovery path.
### P1 — encrypted direct media is offered but rejected (D06/D08)
Both engines: select a synthetic PNG and text file in the direct composer.
Each fails with "Encrypted file attachments aren't supported yet."
Chromium's synthetic microphone also supports recording and discard, but
sending a recording fails with the same message. Voice recording was not
repeated in WebKit or with real iPhone permissions/hardware.
This is a known missing capability with misleading availability. Source
`sendFile` uploads the blob before `send` rejects its attachment URL for an
OMEMO-capable peer. Do not describe the successful group-file audit as
evidence that encrypted direct media works.
Proposal: communicate unsupported media before file selection/recording
and before uploading. Full encrypted attachment support needs a compatible
protocol/client design; never remove the rejection by sending plaintext.
Acceptance: supported flows send/play back; unsupported flows explain
the limitation without recording/uploading data that cannot be sent.
### P1 — offline cold start hides local history and needs retry (P07)
In the extended Chromium run, close the browser, reopen its persistent
profile with networking offline, and navigate to the direct chat. The
cached shell returns HTTP 200, but the app displays the login form instead
of locally stored messages. After connectivity is restored, the form
remains for the ten-second observation window. Reload restores the saved
session and chat without entering a password.
Source `App.svelte` mounts Main only with a session JID and non-offline
status, and attempts saved-token resume once on mount. The successful
in-session reconnect check is a different case from cold startup.
Proposal: show locally stored conversations in a clearly offline state,
keep sending unavailable, and retry the stored session on restored
connectivity or through an explicit Retry action. Acceptance: offline
startup exposes saved history, explains connectivity, retains drafts and
recovers without requiring an unexplained browser reload.
### P1 — group information and people actions are missing (G01/G03)
Reproduce: open a group and tap its name/count. Nothing opens. Tap the
nickname above an incoming group message: no person/contact action opens.
Both engines reproduce these results. `Chat.svelte` renders the group title
as a static div and sender nickname as a span; the group menu only contains
Invite and Leave.
Impact: a user cannot answer "Who is in this group?" or find a person from
the conversation through familiar navigation. This is a feature gap.
Proposed behavior: make the name/avatar/count an accessible button opening
group info. Show group name, description, people and available actions.
Selecting a person opens supported actions such as a room-private chat;
show contact actions only when the real identity is available and permitted.
Keep nickname, account identity and membership roles distinct.
Acceptance: one tap reaches group info; participants are individually
selectable; back restores the chat, draft and reading position; owner,
admin and ordinary-member views are tested independently.
### P1 — unsubmitted text is lost on navigation (N02)
Reproduce: type a draft, use Back to chats, reopen the same room. The
composer is empty in both engines. The component owns `draft` in memory
and is remounted during navigation.
Impact: switching conversations can discard text without notice.
The extended direct-chat checks reproduce the loss through both the chat
list and settings. An unsent direct draft is also lost after a persistent
browser restart, even though automatic login and stored messages return.
Proposed behavior: store a draft per account/conversation and restore it
when opening that conversation. Clear it only after successful sending or
explicit deletion. Show a draft indicator in the chat list. Cross-device
draft synchronization is a separate extension, not required for this fix.
Acceptance: switching chat, settings and browser back preserves text and
multiline content; chats never share drafts; failed sends retain text.
Decide and document reload persistence and logout deletion explicitly.
### P1 — reopening a chat jumps away from the reading position (N01)
Reproduce: scroll up in a room with synthetic history, return to the list,
reopen it. Both engines jump to the bottom. Chromium's measured remaining
scroll distance changes from 2,014 px to 0; WebKit's from 4,441 px to 0.
The component's first load always calls `scrollToBottom`.
Proposed behavior: retain a per-conversation message anchor and offset;
preserve it across navigation. Offer an explicit jump-to-latest control
when reading older messages and new messages arrive.
Acceptance: return to the same visible message after switching views;
incoming messages, older-history loading and image resizing do not
unexpectedly move the reader. First-time opening/unread positioning needs
a deliberate policy rather than applying restoration to every situation.
### P1 candidate — no quoted reply action (M02)
Browser observation: tapping and invoking a context-menu gesture on an
incoming message exposes no application reply action. Source inspection
finds no quoted-reply/swipe-reply implementation or message action menu.
Native iOS long press and native text selection were not exercised.
Impact: group replies cannot visibly identify which earlier message they
refer to. This is a feature gap, not a failed delivery mechanism.
Proposal: a discoverable Reply action and optional swipe gesture, quoted
composer preview with cancel, and a tap on the sent quote to reach the
original message. First establish compatible XMPP representation/fallback
before implementing cross-client behavior.
### P2 — menu and modal keyboard handling (A02)
Both engines: Escape does not close More actions; opening the fingerprint
dialog leaves focus outside it; Tab can reach controls behind the dialog;
Escape does not close it either.
Proposal: move focus into the modal, keep it there while open, close with
Escape and return focus to the trigger. Implement the appropriate menu
focus/navigation behavior. This also matters to assistive input users.
### P2 candidate — touch areas below the preferred phone size (A01)
Measured in the group view: More actions 34 × 34 CSS px; back, attachment
and record 36 × 36. They exceed the 24 px WCAG 2.2 AA size threshold by
size alone; these measurements do not justify claiming an AA violation.
The stronger target-size criterion uses 44 × 44 CSS px. The proposal is
to aim for that larger target for frequent phone actions without enlarging
every icon. [W3C minimum](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html),
[W3C enhanced](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html).
Real-phone comfort and spacing still require validation. No horizontal
page overflow was measured in the tested group/settings layouts.
### P2 candidates — discovery, search and per-chat controls
Source/UI inventory shows New chat as a raw account/room-address input,
without a contact picker or guided group creation; no conversation/history
search; no per-chat mute option in the examined menus/settings. Assess
frequency with the operator before implementing these extensions.
Suggested order: contact picker and a clear Join/Create group distinction,
then per-chat mute and local history search. Avoid a full messenger-feature
catalog before fixing navigation and group information.
## Membership constraint: a frontend list needs correct semantics
The current `participants` number comes from a set of occupants observed
through MUC presence, not a full membership query. In this audit the count
stayed at 2 even after one browser left; earlier hibernated sessions can
retain the same account's nickname. It is unsuitable as proof that all
members are online or that there are only two members.
Live XMPP queries after joining the test room showed:
| Requester | Member list | Admin list | Owner list |
| --- | --- | --- | --- |
| Owner A | B returned as member | Empty | A returned as owner |
| Ordinary member B | forbidden | forbidden | forbidden |
Therefore implement available occupant information first, and query all
membership affiliations only when allowed. A member-only query would omit
owners/admins. If the operator wants every ordinary member to see absent
members too, review the room's anonymity/list permissions as an explicit
product/privacy decision; do not silently expose identities or pretend a
permission error means an empty group. XEP-0045 makes member-list access
dependent on room configuration. [XEP-0045 member lists](https://xmpp.org/extensions/xep-0045.html#modifymember).
## Comparator research
Checked official sources on 2026-10-07. No comparator app was logged into;
installed app versions and hands-on gesture behavior are not verified.
These are documented patterns supporting proposals, not visual parity
claims about current releases.
| Task | Officially documented pattern | Implication for 0ff chat |
| --- | --- | --- |
| Inspect group | Signal mobile opens group settings from the group name; members and admin labels are available there. [Signal](https://support.signal.org/hc/en-us/articles/360050427692-Manage-a-group) | Make header an entry to group info and people, with roles. |
| Preserve unfinished text | Telegram documents peer-specific drafts and cross-device synchronization. [Telegram](https://core.telegram.org/api/drafts) | At least preserve local per-chat drafts and show their existence. |
| Reply to a message | Telegram documents swipe-to-reply and navigating from a quote to its original. [Telegram](https://telegram.org/tour/groups) | Add quoted replies and a return path to the original message. |
| Reply or contact a group sender | Signal documents swipe-to-reply and avatar → Message for a private conversation. [Signal](https://support.signal.org/hc/en-us/articles/6851465208986-Reply-to-a-specific-message) | Give message/person actions discoverable entry points. |
| Manage interruptions on iOS | Signal exposes timed per-chat muting through the chat header. [Signal](https://support.signal.org/hc/en-us/articles/360043273491-In-App-Notification-Options) | Consider per-chat mute alongside global notification controls. |
| WhatsApp group/reply workflows | Official articles exist for adding/removing group members and replying to messages; search-indexed group instructions mention opening group info via the subject. [Members](https://faq.whatsapp.com/841426356990637/), [Reply](https://faq.whatsapp.com/3240283752856917/) | Useful secondary references; full article bodies did not render in the research fetch, so exact iPhone steps remain unverified. |
## Successful checks and limits
- Both accounts logged in; group messages arrived; synthetic text-file
uploads appeared for the recipient; Leave room followed by rejoining
worked in both engines. Invitations were already verified in the initial
trial, not independently repeated for new membership in this round.
- Direct messages arrived with the OMEMO lock visible in both engines.
This is a UI flow check, not a new cryptographic or Monal interop audit.
A newly created WebKit profile showed the expected undecryptable backlog
placeholder for messages sent before that device existed.
- The corrected interruption test injects a WebSocket close and blocks
reconnect attempts in the test transport, with browser HTTP offline
emulation too. Draft survives, Send is disabled, reconnect restores the
composer in Chromium and WebKit. The first Chromium offline-only attempt
did not terminate the existing WebSocket and is recorded as test setup
failure, not an application bug.
- Dark settings rendered correctly; no page JavaScript errors were caught
during the successful full runs. Large system text/VoiceOver, real mobile
keyboard and OS-level media permissions remain untested.
- Browser sessions were sequential. Minimum sampled available RAM:
1,638 MiB in Chromium, 1,748 MiB in WebKit. WebKit one-minute load reached
3.73 on two CPUs; swap also varied slightly. Keep one worker and avoid
simultaneous builds. Long-run or production-latency guarantees were not
established. After tests nginx, Prosody, LLDAP and Traefik were active,
chat returned HTTPS 200, and no audit browser processes remained.
WebKit originally failed creating its WPE EGL display; pointing it at
Nix Mesa software-rendering libraries fixed the run without a NixOS switch.
The runner and runtime packages are documented below.
## Implementation batches
The detailed follow-up is [UX implementation plan](ux-implementation-plan.md):
ordered packages, source areas, compatibility decisions, regression checks
and release boundaries. The plan is proposed; no package is implemented yet.
1. **Direct-message reliability:** fresh device lists and truthful status
when decrypting fails; explain unsupported encrypted media before
selection/upload, then plan compatible encrypted attachments.
2. **Keep user state:** local per-conversation drafts and reading anchors;
cached-history access and session retry on offline startup.
Regression: navigating away and back retains both; new messages and
attachments do not pull a reader away from older content.
3. **Group info:** accessible header entry, available people/roles,
person actions, invite/leave and explicit full-list permission handling.
Test owner/member and an absent member, not only two active owners.
4. **Phone interaction:** quoted replies, larger touch targets, menu/modal
focus and dismissal; validate on the actual iPhone during implementation.
5. **Daily-use extensions:** contact picker, search and per-chat mute after
operator prioritization and checking protocol/client compatibility.
## Extended direct-chat and PWA simulation results
The operator requested simulating PWA use and expanding direct chats.
These checks were actually run; their level of simulation is explicit:
| Check | Chromium | Linux WebKit | What the result establishes |
| --- | --- | --- | --- |
| Contact request / roster | Request accepted in first extended run; restored later | Existing contact restored | Test accounts can become contacts; enabled call controls appeared. No audio/video call was placed. |
| Direct text, receipts, unread | Delivered → read; unread row/title cleared on opening | Same | Healthy path after the documented device-cache intervention. |
| Typing / multiline / links | Passed | Passed | Typing starts/ends, touch Enter inserts a newline, Unicode/link text arrives and links use a new tab with appropriate rel attributes. |
| Newly added device | Unreadable new live message; sender read tick | Same | Reproducible stale-device-list failure, distinct from old backlog. |
| Direct drafts / reading position | Drafts lost; reading distance 3,006 → 0 px | Drafts lost; extended position probe inconclusive (0 → 0) | Direct position gap confirmed in Chromium; earlier group-position checks passed reproduction in both engines. |
| Direct image/file/voice | Image/file rejected; synthetic voice recording/discard works, send rejected | Image/file rejected; voice not run | Missing encrypted attachment capability. |
| Standalone indicators | Injected, shell controlled, assets cached | Injected, shell controlled; page cache inventory empty | App renders with the simulated indicators. This does not install a PWA. WebKit cache result needs engine/device validation. |
| Keyboard-size viewport | Composer reachable at 402 × 500 and 874 × 300 | Same | Resizing the layout viewport works; native keyboard overlays and safe-area values are not reproduced. |
| Background-like freeze/resume | Draft retained; one incoming message after brief freeze | Not run | Chromium lifecycle freeze works in this short scenario, not iOS process eviction or long background suspension. |
| Worker push / notification click | Notification created; route opens correct chat; focus denied for untrusted click | Not run | Real worker executes injected push and click handlers. It does not prove OS delivery, OS focus or opening a closed iPhone PWA. |
| Worker update check | Passed, unchanged worker | Same | Update API works; no changed production version or old/new release migration was tested. |
| Persistent-profile restart | Saved login, browser/OMEMO device IDs and message retained; draft lost | Same | Real browser shutdown/relaunch preserves those stored states. |
| Offline cold start | Cached shell, history hidden, no automatic recovery in 10 seconds; reload restores session | Internal navigation error | Chromium application gap above; WebKit result is an execution/environment limitation, not a proven Safari bug. |
Chromium push uses CDP `ServiceWorker.deliverPushMessage`. The click is an
untrusted `NotificationEvent` dispatched to the real worker with its
`waitUntil` completion captured. Posting the chat route succeeds; `focus()`
rejects with "Not allowed to focus a window." That expected synthetic-click
restriction is not claimed as an application defect. Playwright persistent
contexts preserve storage across actual browser restarts.
[Chromium ServiceWorker protocol](https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker/),
[Playwright persistent contexts](https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context).
The first extended Chromium attempt used arrival assumptions that became
invalid because of the new-device failure, and later aborted before the
restart checks. A second run established the explicit cache intervention,
but its offline test treated failed automatic recovery as a timeout. The
final report linked above records offline state, the ten-second recovery
window and successful reload separately. Those earlier execution failures
are retained as evidence, not converted into successful checks.
No page JavaScript errors were reported in the final extended runs.
Minimum sampled available memory was 1,970 MiB in Chromium and 1,781 MiB
in WebKit. All runs were sequential, with a single browser process tree
and isolated account contexts. This remains a short-run resource result.
Maximum sampled one-minute load was 2.13 in Chromium and 2.36 in WebKit.
After the extended runs, nginx/Prosody/LLDAP/Traefik were active, chat HTTPS
returned 200 in 0.094 seconds, and the host process inventory contained no
remaining audit browser processes. This is an end-of-run health check,
not continuous production-latency monitoring.
Credentials, caches and keys are in the private runtime profiles, not Git.
Remaining device-only validation is narrower: actual iOS keyboard/visual
viewport and safe areas, Home Screen installation, OS background/eviction,
real push delivery and trusted notification opening, media permissions and
hardware, large text/VoiceOver. The simulation results above already cover
the app behavior that could be tested autonomously on blunix.
## Real iPhone PWA result sheet — pending
Use the existing installed PWA on iPhone 17 Pro / iOS 26.6. Do not clear its
data or log out just to run the audit. Record pass/fail and short notes:
| ID | Device task | What to observe | Result |
| --- | --- | --- | --- |
| PWA01 | Open a chat, type harmless unsent multiline text, open/close keyboard | Composer/latest message reachable; no hidden buttons or jump | Pending |
| PWA02 | Navigate between chat/list with a harmless draft and while reading old messages | Confirm known draft/reading-position gaps on real PWA | Pending |
| PWA03 | Switch apps and lock/unlock, then reopen chat | Session and composer recover predictably | Pending |
| PWA04 | Toggle connectivity, then restore it | Clear reconnect state; draft kept; no duplicate sending | Pending |
| PWA05 | Receive a message from an arranged tester while PWA is backgrounded/closed; open notification | Correct chat, unread count and badge | Pending; requires arranged sender |
| PWA06 | Photo/file selection and voice recording, using arranged test chat | Permission prompt, cancel/send and playback work | Pending; requires test data |
| PWA07 | Large text, VoiceOver, safe areas and orientation | Readable/reachable controls; no clipping; focus behavior | Pending |
Notification delivery here cannot be certified by Linux WebKit. iOS web
push applies to Home Screen web apps; actual permission, background and
notification-open behavior needs the device. [Apple/WebKit](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/).
## Re-running the audit
`ux-audit-runner.cjs` records gaps and measurements without treating an
observed UX gap as a script failure; execution failures are explicit.
It uses the existing dedicated test room and requires its accounts to have
membership. Passwords remain in the mode-0600 runtime accounts file.
```bash
npm install --prefix /tmp/chat-ux-runner --no-audit --no-fund playwright
node ux-audit-runner.cjs chromium
node ux-audit-runner.cjs chromium --reconnect-only
node ux-pwa-direct-runner.cjs chromium
npm install --prefix /tmp/chat-ux-webkit --no-audit --no-fund playwright@1.61.1
env UX_PLAYWRIGHT_MODULE=/tmp/chat-ux-webkit/node_modules/playwright \
UX_WEBKIT_PATH=/nix/store/kvbchbp55jd09wh2h1bv3xx9cz26k6vg-playwright-webkit/pw_run.sh \
LIBGL_ALWAYS_SOFTWARE=1 EGL_PLATFORM=surfaceless \
LIBGL_DRIVERS_PATH=/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/lib/dri \
__EGL_VENDOR_LIBRARY_FILENAMES=/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/share/glvnd/egl_vendor.d/50_mesa.json \
node ux-audit-runner.cjs webkit
```
For extended WebKit checks, use the same environment command with
`node ux-pwa-direct-runner.cjs webkit` instead. That runner creates a private
profile in its per-run results directory; do not export/commit that profile.
It changes only the synthetic accounts' contact relationship and sends
synthetic messages/uploads. Its cache invalidation is restricted to the
private A profile and is always recorded as `TEST-CACHE-REFRESH`.
Nix browser/Mesa paths refer to the packages fetched for this audit; rebuild
the corresponding package attributes if they were garbage-collected.
The app package and its package-lock were not changed for browser tooling.
## Implementation follow-up — 2026-10-07
Packages 0–3 are implemented and released. See [implementation results](UX-IMPLEMENTATION-RESULTS.md) for candidate/deployment evidence, the corrected WebKit scroll setup and native validation limits. The findings above describe the original audited build and remain historical evidence.