# Lotus Chat — Manual Testing Guide

**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP; added the **Automated coverage map** below — logic now pinned by unit tests, so manual QA can focus on the human-only surface)
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.

> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.

## Environment notes

- You push from your own machine; these commits are local on `lotus` until you do.
- Test the **web** build (LXC 106 / `code.lotusguild.org`) first; re-run the **call** + **poll** sections on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
- Several call features need a **second participant** (second account on another device/browser, or a colleague). Items that need this are marked **👥 2 people**.
- A couple of call items need a **third room/call** in parallel — marked **👥👥**.

---

## Commits covered

| Commit     | Area                                                                         |
| :--------- | :--------------------------------------------------------------------------- |
| `caf6318a` | Poll vote buttons → folds tokens (N4)                                        |
| `c67aed01` | In-call incoming-call banner (#4b)                                           |
| `4a875884` | Selectable ringtone (#4a)                                                    |
| `0394fce9` | EC iframe load watchdog + recovery UI; avatar decorations on call tiles (#3) |
| `d2946c00` | Upload retry/backoff, presence-on-unload, typed m.direct                     |
| `b7e1f89c` | Timeline/composer/emoji perf memoization                                     |
| `c0f98672` | Upstream **Element Call 0.20.1** merge (regression sweep)                    |

---

## Automated coverage map — what the unit tests already pin (2026-07)

**Read this before working the guide.** Much of the _logic_ these manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."

| Guide item                                 | Logic **pinned by a unit test** (trust it)                                                                                                                                                                                                                                            | What still needs **you** (manual)                                                                                          |
| :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------- |
| **A1** ringtone previews                   | `callSounds.test.ts` — each style's synthesized melody (chime/soft/retro), click-free gain ramps, context unlock/reuse, unknown = no-op                                                                                                                                               | that it's actually **audible** + the WebAudio first-gesture caveat                                                         |
| **A2** ringtone persist/fallback           | `settings.test.ts` — unknown `ringtoneId` → default, malformed JSON → defaults, merge-over-defaults (this **is** A2 step 3)                                                                                                                                                           | the dropdown shows the persisted value after reload (trivial glance)                                                       |
| **B2/B3** poll voting                      | `poll.test.ts` (18) — vote tally, latest-per-sender, multi-select, cleared/re-vote, winners, results-visible, single-vs-multi validation                                                                                                                                              | **visual** only: borders, radio-vs-checkbox, progress-bar fill, on each theme                                              |
| **O2/P4-1** thread notifications           | `threadNotifications.test.ts` (32) — the **entire** notify decision (participating default, All/Mentions/Mute, @mention+highlight override, room-mute trumps), mode-map + muted-badge hygiene                                                                                         | live **2-person** delivery + sound + **cross-device** account-data sync                                                    |
| **O3** math / LaTeX                        | `mathParse.test.ts` (14) — inline `$…$`, block `$$…$$`, **currency guard** (`$5 and $10`), escaped/unbalanced stay text, adjacency rules                                                                                                                                              | KaTeX **renders** visually + lazy-chunk load; code-block-literal is the markdown pipeline                                  |
| **O4/P4-8** encrypted search cache         | `searchCache.test.ts` — the pure helpers `mergeSearchResults` (merge/dedupe/sort) + `computeCoverage` (window widening) + resilient-when-IDB-absent. **The IDB round-trip test is `skip`ped under `npm test`** (node has no IndexedDB), so it runs only in a browser-like env, not CI | the actual IndexedDB persist-across-**reload**, Clear button, **logout wipe** (integration — and the round-trip itself)    |
| **M1** `has:image/file/video`              | `useMessageSearch.test.ts` — `filterGroupsByMsgType` union filter, drops empty groups, ignores non-string msgtype                                                                                                                                                                     | the chips render + compose with room/sender/date filters                                                                   |
| **M4** pinned-only filter                  | `useMessageSearch.test.ts` — `filterGroupsByPinned` keeps pinned, drops empty                                                                                                                                                                                                         | chip renders; needs a room with actual pins                                                                                |
| **M2** recent searches                     | `recentSearches.test.ts` (6) — prepend, dedupe+move-to-front, trim, ignore-empty, cap-at-10                                                                                                                                                                                           | chips render/click-re-run; persistence across refresh                                                                      |
| **Retention** (disappearing msgs)          | `retention.test.ts` — `isExpired` window math (strict boundary), disabled = never, preset monotonicity                                                                                                                                                                                | the timeline **hide** + self-**redact** integration; Synapse-side purge                                                    |
| **O5/N97a** session hardening              | `sessions.test.ts` (22) — blob migration, legacy-key coercion, dual-write blob↔legacy sync, corrupt/partial-blob fallback, token-refresh, AND the `subscribeSessionChanges` storage-event logic (fires on session/null, ignores unrelated keys)                                       | the real **cross-tab** logout _behavior_ end-to-end (two live tabs)                                                        |
| **Q1/Q2** embeds (URL→player)              | `videoEmbed.test.ts` (26) — every provider's URL→`{provider, kind, embedUrl, height}` parse (incl. Mixcloud/Deezer, TikTok, reserved-path guards)                                                                                                                                     | the click-to-play **facade**, no-network-until-Play, the **CSP** (esp. desktop), visuals                                   |
| **Seasonal theme resolution** (part of F2) | `seasonSchedule.test.ts` — `resolveSeasonTheme` (off→none, auto→active season, pinned→that) + `getActiveSeason` priority/boundary days. **NB: this pins _which_ theme shows for a date, NOT F2's background↔seasonal mutual exclusion** — that write-side logic is untested           | all of **F2**: the picker actually clearing the _other_ setting live, and the overlay suppression when a background is set |

Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already **verified live** are listed at the very bottom ("Verified working in live testing").

---

## A. Calls — new ringtone + notification work (highest priority)

### A1. Ringtone selection — preview in Settings

**Steps**

1. Open **Settings → General**, scroll to the **Calls** section.
2. Find the new **Ringtone** dropdown (just above **Ringtone Volume**).
3. Select each option in turn: **Classic, Chime, Soft, Retro, Silent**.

**Expected**

- Selecting **Classic** plays the existing `call.ogg` clip (cut off after a few seconds).
- **Chime / Soft / Retro** each play a short, distinct synthesized preview.
- **Silent** plays nothing.
- Changing **Ringtone Volume** then re-selecting a ringtone previews at the new volume.
- No console errors.

> ⚠️ **Known browser limitation:** the synthesized tones use WebAudio. If a preview is ever silent, click anywhere on the page once (a "user gesture") and retry — browsers suspend audio until the page has been interacted with. The Settings preview is _after_ a click so it should always sound; this note matters more for A3.

### A2. Ringtone selection persists

1. Set Ringtone to **Retro**, reload the app.
2. **Expected:** the dropdown still shows **Retro** (setting persisted).
3. Bonus: in devtools, set `localStorage.settings` to a bogus `ringtoneId` and reload → it should fall back to **Classic**, not break.

### A3. Incoming call uses the selected ringtone — 👥 2 people

**Setup:** Account A (you) and Account B in a **DM** or a **private (invite-only) group** room.

1. As A, pick a non-silent ringtone (e.g. **Chime**).
2. From B, **start a call** in that DM/room. Do **not** answer on A.

**Expected on A**

- The full-screen **Incoming Call** dialog appears (caller name, room avatar, Answer / Reject).
- The **selected ringtone loops** until you answer/reject/ignore (at the set volume).
- Answer → joins the call. Reject (DM) / Ignore (group) → dialog dismisses and ring stops.
- Set ringtone to **Silent** and repeat → dialog still appears, **no sound**.

### A4. In-call banner for a second incoming call — 👥👥 (the trickiest one)

**Setup:** You (A) already **in a call** in Room 1. Account B can call you in a **different** Room 2 (a DM or private group you share). Ideally a third account C, or B leaves Room 1's call first.

1. While A is **actively in Room 1's call**, trigger an incoming call to A from **Room 2**.

**Expected on A**

- **No** full-screen takeover. Instead a **compact banner appears in the top-right corner** with the caller's avatar, room name, "Incoming voice/video call", and **Answer / Reject (or Ignore)** buttons.
- It plays a **single soft ping**, _not_ a looping ring (so it doesn't talk over your active call).
- The banner does **not** cover your active call's controls/PiP in a way that blocks them.
- **Answer** → switches you into Room 2's call. **Reject/Ignore** → banner disappears.
- The banner auto-dismisses if the caller hangs up / the call times out.

**Also verify the no-op case:** while in Room 1's call, if a notification for **Room 1 itself** arrives, **nothing** should pop up (no banner, no dialog).

### A5. Camera focus during screenshare (#1) — 👥 2 people

**Setup:** You (A) and B in a call; B (or another participant) **sharing their screen**, and at least one person with **camera on**.

1. As A, open the **participant glance** (the stacked avatars / member list for the call) and click a participant who has their **camera on**.
2. In the menu, click **"Focus camera"**.

**Expected**

- The view switches to **spotlight** and **pins that person's camera tile**, overriding the auto-spotlighted screenshare.
- It **stays** on that camera (doesn't immediately snap back to the screenshare).
- If you pick someone with their camera **off**, it should at worst just toggle spotlight (graceful fallback), not error.

### A6. Avatar decorations on call tiles (#3) — 👥 2 people

**Setup:** A participant in the call has an **avatar decoration** set (Settings → Profile decoration).

1. Join a call with that participant.
2. Look at **our** participant roster / prescreen tiles (not the avatars rendered inside the Element Call video grid — those are EC's and out of scope).

**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.

### A7. EC iframe load watchdog + recovery UI (#EC, N96)

This guards against a permanently-stuck "Loading…" call. Also covers the N96 button-label fix (the old "Retry" and "Leave" buttons were identical — now there is a single **"Back"** button).

1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.

**Expected**

- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with a single "Back" button** (the old "Retry" + "Leave" pair is gone — they did the same thing and "Retry" was misleading).
- Clicking **Back** returns you to the call prescreen, where you can manually click Join to try again.
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call. Worth confirming on a deliberately throttled-but-not-blocked connection.

---

## B. Polls (N4) — render correctly on non-TDS themes

This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.

### B1. Poll renders on a default theme — ✅ PASS

1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.

**Expected**

- Each option is a clearly **bordered** button with visible rounded corners.
- A **radio circle** indicator is visible on the left of each option.
- Text, and (after votes) the percentage, are legible.

### B2. Voting + selected/progress state

1. **Vote** on an option.
   **Expected**

- The selected option shows a **filled accent border + filled radio**, and an **accent progress-bar fill** grows behind it proportional to the vote %.
- The percentage and total vote count update.
- Click again / pick another option → selection moves correctly (single-choice replaces; the bar redraws).

### B3. Multiple-choice poll

1. Create a poll allowing **multiple selections**.
   **Expected**

- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
- You can select **several** options; each shows its own progress fill.

### B4. Lotus Terminal theme regression — ✅ PASS

1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
   **Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.

---

## C. Robustness / background behavior

### C1. Presence updates on tab close

1. Open the app, then **close the tab** (or quit the browser).
2. From another session/device, check your **presence** shortly after.
   **Expected:** you go **offline/away** reliably (the unload now uses `fetch({keepalive})`). Previously this could be missed.

### C2. Upload retry on flaky network (best-effort)

1. In devtools → Network, set a throttle that drops/slows requests, or toggle Offline briefly **during** a file upload.
   **Expected**

- A transient failure **retries** (up to 3×, with backoff) and the upload can still succeed once the network recovers.
- A genuine, permanent rejection (e.g. file too large / 4xx) still **fails fast** with the usual error — it should **not** spin retrying.

### C3. General timeline/composer perf (no functional regression)

The memoization changes are invisible if correct. Just confirm **nothing broke**:

- Open a busy room; scrolling, jump-to-latest, mark-as-read all still work.
- Composer: send a message, upload a file, share a location, pick an emoji and a sticker — all still work.

---

## D. Element Call 0.20.1 merge — regression sweep (👥 2 people)

The upstream bump changed EC's internals and DOM selectors; our call controls drive that iframe, so sweep them. In a live call with 2 people, confirm **each** of our control-bar buttons works:

- [ ] **Mic** mute/unmute (icon + actual audio)
- [ ] **Camera** on/off
- [ ] **Deafen / Sound** toggle (your deafen key too)
- [ ] **Screenshare** start/stop (and the "Share your screen?" confirm)
- [ ] **Screenshare audio** mute toggle
- [ ] **Fullscreen** toggle
- [ ] **⋮ More** menu → **Spotlight/Grid**, **Reactions**, **Settings** each open the right EC panel
- [ ] **End** call leaves cleanly
- [ ] **PTT** (push-to-talk) if enabled: hold key = transmit, release = mute; releasing on blur works
- [ ] **AFK auto-mute** if enabled: goes muted after the timeout
- [ ] **PiP** (picture-in-picture) mini window: drag, resize, fullscreen button, return-to-call; the "You muted" / "All muted" badges show on the right person
- [ ] **Denoise** (if ML noise suppression enabled): call audio still flows, no silence

If any control does nothing, that usually means an EC DOM selector changed — capture the console and tell me which button.

---

## D2. Element Call **fork** — Phase 2 feature sweep (👥 2 people) — `0.20.1-lotus.1`

> The whole EC iframe is now our **self-built fork** (`@lotusguild/element-call-embedded@0.20.1-lotus.1`).
> Five features are **active** (the host sets their flags / sends their actions); two ship **dormant**.
> **Confirm you're on the fork first:** EC iframe console prints `Element Call embedded-v0.20.1-lotus.1`
> (the old build prints `embedded-v0.20.1`). If it says the old version, the web deploy hasn't landed —
> the fork features won't be present, so don't test D2 yet.
> For non-dev testers, each item below also states the plain "✅ good if / ❌ tell us if" outcome.

### D2-1. Denoise **in-source** — survives reconnect (fixes A7) ⭐ highest risk (everyone's mic)

Flag: cinny sets `lotusDenoiseSource=1` when ML denoise is selected (the old build-time getUserMedia
shim is **removed**). This is the single change with the widest blast radius — test deliberately.

- [ ] **Audio flows, no silence** with ML denoise on (baseline, also §D line 204).
- [ ] **Reconnect (the A7 fix):** in a call with ML denoise on, kill network ~10 s (devtools → Offline)
      so EC shows "Connection lost / Reconnect", then restore. **Mic still works AND still denoised**
      afterward, **without** End+rejoin. _(This is the exact bug that was reintroduced then fixed; if it
      regresses, mic dies on every reconnect.)_
- [ ] **Mic device switch mid-call** (Settings → change microphone): audio keeps working (same
      `restart()` path as reconnect).
- [ ] **Mute → unmute** a few times: audio returns each time.
- [ ] **Each model** if the picker offers them: `rnnoise` (default), `speex`, `dtln`, `deepfilternet` —
      each loads + denoises, no silence. (All four are in-source now; DTLN runs at 16 kHz, others 48 kHz.)
- [ ] **No double-processing:** audio isn't over-suppressed/artifacted (would mean the old shim is still
      injected alongside the in-source engine).
- **Rollback if bad for everyone:** revert the cinny deploy commit (restores the shim + `@element-hq` parity).

### D2-2. Speaking + mute indicators from widget **events** (#2)

Flag: `lotusCallState=1`. cinny now reads speaker/mute state from `io.lotus.call_state` events instead of
scraping EC's DOM (DOM fallback retained). Overlaps **G1**.

- [ ] **Speaking glow** lights the **correct** person when they talk (you, then your friend).
- [ ] **PiP "All muted" / "You muted" badge** points at the right person and updates on mute/unmute.

### D2-3. Focus camera **during a screenshare** (#4 / A5)

Action: cinny sends `io.lotus.focus_participant` (the DOM `.click()` hack is gone). Overlaps **A5 / G2**.

- [ ] Person A screenshares; Person B camera on; **MemberGlance → Focus camera** on B → B's camera is
      spotlighted **alongside/over** the shared screen (not ignored).
- [ ] Camera-**off** target = graceful (no error, no kick out of the screenshare).

### D2-4. In-call avatar decorations (#6) — **NEW, beyond A6**

Action: cinny pushes `io.lotus.decorations`. **A6 only covered the lobby roster** and called in-call EC
tiles out of scope — that's now in scope.

- [ ] A participant with a **Profile decoration** joins **camera off** → the decoration ring renders on
      their **in-call video-tile avatar** (inside EC, not just the lobby), correctly sized/positioned.
- [ ] Decoration tracks the right person across grid/spotlight layout changes; disappears when they leave.

### D2-5. Native transparent background (#5)

Flag: `lotusTransparent=1` (native, replacing the injected `background:none !important`).

- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
      see-through, or layout breakage (also covered loosely by §D2 "looks right").

### D2-7. In-Call Soundboard (#3 / P5-15) — 👥 2 people — **NEW**

Flag: `lotusAudioInject=1`. A 🔔 **Soundboard** button now sits in the call controls bar (left group,
next to the chat button). Clips are user-uploadable and sync across your devices like emoji packs.
_Prereq:_ Settings → General → Calls → **Soundboard** must be ON (default on).

- [ ] **Upload:** open the soundboard popout → **Upload** → pick a short audio file (mp3/ogg/wav, ≤ 1 MB).
      It appears as a clip tile. (Too-big / too-many shows an error, doesn't crash.)
- [ ] **Plays into the call:** with a second person in the call, click a clip. **They hear it**, and
      **you hear it locally** too. ✅ good if both hear it; ❌ tell us if only one side does.
- [ ] **Sync:** the uploaded clip shows up on your **other device**/session (account-data sync).
- [ ] **Delete:** the ✕ on a tile removes it (everywhere, after sync).
- [ ] **Off switch:** turn Settings → Calls → **Soundboard** off → the call-bar button disappears.
- [ ] Injecting a clip does **not** mute/interrupt your mic or anyone else's audio.

### D2-8. Call Quality Controls (#7 / P5-31) — 👥 2 people — **NEW**

Action: `io.lotus.set_quality`. User settings in **Settings → General → Calls** (Microphone Bitrate,
Screenshare Bitrate, Screenshare Framerate; all default **Auto**). Admin caps in **Room Settings →
General → Voice → Call Quality Caps**.

- [ ] **No regression at Auto:** with everything on **Auto**, calls/screenshare work exactly as before.
- [ ] **User cap takes effect:** set Microphone Bitrate to **32 kbps**, rejoin/continue a call — audio
      still flows (thinner is fine). Set Screenshare Framerate to **15 fps** and share your screen — it
      still shares. ❌ tell us if any setting kills audio/screenshare.
- [ ] **Applies mid-call:** changing a setting **during** a call takes effect without End+rejoin.
- [ ] **Room-admin cap (admin needed):** as a room admin, set **Max Microphone Bitrate = 64 kbps** in
      Room Settings → Voice. A member whose user setting is higher (e.g. 256) should be **clamped to 64**
      (best-effort/UX — this is client-side; hard server enforcement is a separate follow-up).
- [ ] Resetting a setting back to **Auto** removes the cap for the rest of the call.

> Soundboard + quality are no longer "dormant" — if either does nothing, grab the **EC iframe console**
> and check for `io.lotus.inject_audio` / `io.lotus.set_quality` rejections.

### D2-9. Call Permissions — HARD server-side, cross-client (👥 2 people, admin) — **NEW**

This is enforced by the `voice-limit-guard` on the server (re-signs the LiveKit JWT), so it applies to
**every** client, not just Lotus Chat. Set in **Room Settings → General → Voice → Call Permissions**.
_(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo push.)_

- [ ] **Disable screenshare:** as admin, turn **Allow Screen Sharing** off. In a call, the
      **screenshare button disappears** in Lotus Chat. ✅ good if no one can screenshare.
- [ ] **Cross-client (the important one):** have someone join the **same room from stock Element / Element
      X** and try to screenshare → the server **refuses** the track (it won't publish). This proves it's
      not just our client hiding a button.
- [ ] **Audio-only room:** turn **Allow Camera** off too → the camera button disappears and cameras are
      server-blocked for all clients; **microphones still work**.
- [ ] **⭐ Live kill (mid-call):** while someone is **actively screensharing**, an admin turns **Allow
      Screen Sharing** off. Within a few seconds their screenshare should **stop for everyone** on its own
      (no rejoin needed) — this is the server reconcile loop revoking it live. Works even if the sharer is
      on stock Element. ✅ good if the share drops within ~3–5 s; ❌ tell us if it keeps going.
- [ ] **Turning it back on** restores the ability to screenshare/camera (start a new share).
- [ ] **No policy = no change:** a room with Call Permissions left on defaults behaves exactly as before.

> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.

---

# Backlog of previously-fixed-but-unverified items

> Sections A–D above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** (see the outstanding-verification backlog below / `LOTUS_TODO.md`). They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way A–D are; do them as you have the right device handy.

## E. Mobile / responsive (needs a real phone, or devtools device emulation)

### E1. Composer toolbar touch targets (#7)

On a phone, open a room and the composer toolbar. Tap each button (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
**Expected:** every button is comfortably tappable (≥44×44px), no mis-taps hitting the wrong icon.

### E2. Room Settings — no horizontal overflow (#8)

On a narrow phone screen, open **Room Settings**.
**Expected:** the settings nav panel fills the full width; **no** horizontal scrollbar / sideways scrolling anywhere in the panel.

### E3. Modals go fullscreen on mobile (#9)

On a phone, open several dialogs: Leave Room, Create Room, Create Space, Invite User, Report (room/user/message), Edit History, Forward Message, Remind Me, Schedule Message, Device Verification, Poll Creator.
**Expected:** each opens **fullscreen** (no floating box, no rounded corners / max-width margins). On desktop the same modals should still be the normal centered boxes.

### E4. Composer not hidden by the keyboard (#10) — iOS Safari especially

On a phone (priority: **iOS Safari**), tap into the composer so the on-screen keyboard appears.
**Expected:** the composer input stays **visible above** the keyboard; the layout shrinks rather than the composer sliding under the keyboard.

### E5. Mobile "Saved Messages" access (Mobile Bookmarks)

On a phone, **inside a room**, open the room header **··· More Options** menu.
**Expected:** a **"Saved Messages"** item is present; tapping it opens the bookmarks panel. (This was the only in-room access point missing on mobile.)

---

## F. Visual / theming

### F1. Animated chat background — no flicker (#2)

Settings → set an **animated** chat background (e.g. anim-rain / anim-aurora / anim-stars). Watch the message text and composer while it animates.
**Expected:** smooth animation, **no flickering / shimmering** on message text or the composer, especially after scrolling. Note your GPU/browser if you see artifacts.

### F2. Background vs. Seasonal theme are mutually exclusive (#6)

In Settings → Appearance:

1. Pick a **chat background** → confirm any **seasonal theme** auto-switches off.
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none.
3. (Edge) If you have old data with both set, after reload only one should visibly apply (no double-overlay clutter).

### F3. Background / seasonal picker grid layout (N81)

In Settings → Appearance, look at the **Chat Background** and **Seasonal Theme** swatch grids; resize the window narrow→wide.
**Expected:** swatches reflow to fill each row evenly (responsive grid), with no lopsided/orphaned last row at any width.

---

## G. Calls — additional unverified (👥 2 people)

### G1. PiP mute badges point at the right person (#12)

In a call with at least one other person, pop out the **Picture-in-Picture** mini window.

- **You** mute your own mic → a **"You"/muted badge appears bottom-left** (your status).
- A **remote** participant (or all of them) mutes → an **"All muted"** badge appears **top-right** (clearly about other people).
  **Expected:** the bottom-left badge is **never** triggered by someone else muting — that was the original bug (it looked like your own mic was muted when it wasn't).

### G2. Full-screen camera broadcasts

1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare).
2. Use **MemberGlance → Focus camera** to full-screen/spotlight a specific person's camera. (Overlaps **A5**; if you've done A5 you can skip.)

### G3. PTT badge renders on all themes (N53)

Enable **Push-to-talk** (Settings → Calls) and join a call. Hold the PTT key.
**Expected:** the floating PTT badge above the controls shows "PTT — Hold KEY" when idle and "● Live" (green) while held — on **both** a default theme and Lotus Terminal (it's now a single folds Chip; the old terminal-only variant was removed).

---

## H. Media / performance (needs a room with many images)

### H1. Lazy image decryption (P5-5 / MediaGallery)

Open a room / media gallery with **many images** (ideally encrypted). Scroll down through them.
**Expected:** images decrypt/load as they **approach the viewport**, not all at once on open; scrolling stays smooth and memory doesn't balloon. Off-screen images shouldn't all decode up front.

### H2. Thumbnail framing (P5-6)

Look at **tall portrait** images in the timeline and in the media gallery.
**Expected:** thumbnails are framed **center-top** (so faces/subjects at the top aren't cropped out); no awkward stretching. Opening the full-size viewer still shows the **whole** image (contain, not cropped).

---

## I. Accessibility (needs a screen reader: VoiceOver / NVDA / TalkBack)

With a screen reader on, navigate message hover-actions and content and confirm each control **announces a meaningful label** (not "button" / blank):

- [ ] **Reaction** buttons announce the emoji + count (e.g. "thumbsup reaction, 3 people").
- [ ] **Edit history** button announces "View edit history".
- [ ] **Thread indicator** announces "View thread".
- [ ] **Reply** (jump to original) announces "Jump to original message".

---

## J. Desktop / Tauri build only

### J1. Proactive update notifications (P5-40)

In the **desktop (Tauri)** build, with an update available, launch the app (and/or leave it running ~12h).
**Expected:** an in-app toast/badge alerts you that an update is available, without manually checking Settings. (Needs an actual newer release to point at.)

### J2. DTLN noise suppression sanity

In Settings → Calls, enable **ML noise suppression** with the **DTLN** model, then join a call.
**Expected:** your mic audio still flows (no silence/robotic dropouts) and background noise is reduced. Confirmed working earlier but flagged for a final real-call check; verify on **both** web and desktop.

---

## K. Features — end-to-end unverified

### K1. Remind Me Later

On a message, **··· → Remind Me**, pick a short preset (the 20-min one, or wait one out).
**Expected:** when due, a Lotus toast fires linking to that message; the reminder then clears itself. Survives a reload while pending (stored in account data).

### K2. Advanced search filters (P4-9)

In message search: use the **sender picker** (instead of typing `from:@user`), the **date-range** quick presets (Today / Last week / Last month / Last year), and the **Has link** toggle.
**Expected:** each narrows results correctly and reflects in the search.

### K3. Notification content + click target (P5-20 partial)

Trigger a desktop/browser notification for a new message.
**Expected:** it shows the **real message body** (`username: message`, not "New inbox notification from…"); **clicking it** brings the window to front and navigates **directly to that message** (not just the inbox).

---

## L. Fixed — verify

### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call

**Context (now FIXED):** `useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.

**To verify:**

1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
3. **Unmute** → the indicator should re-appear (capture re-acquired).
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.

### L2. Maskable PWA icon (N108) — Android install

1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
2. Look at the **home-screen icon**.

**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).

---

## M. New features (this round)

### M1. Search: `has:image` / `has:file` / `has:video` filters

1. Open message search (in a room with shared images/files/videos in history).
2. Run a broad search, then toggle the **Images**, **Files**, **Video** chips (in the filter bar, next to "Has link").

**Expected:**

- Each chip narrows the visible results to that message type; multiple active chips = union (any of them).
- Toggling them off restores the full results. The existing room/sender/date/has-link filters still work alongside.
- **Known limitation (by design):** filtering is client-side over already-fetched results, so the visible count can be lower than the server's total for that query — paginating/loading more pulls in more to filter. Confirm this reads acceptably.

### M2. Search: recent searches

1. Run a few different searches, then **clear the search box** and focus it.

**Expected:** your last (up to 10) distinct searches appear as clickable chips; clicking one re-runs it. A **Clear** affordance wipes the list. The list **persists across a page refresh** (localStorage).

### M3. Custom accent color (non-TDS themes) — ⚠️ needs your visual judgment

1. Make sure **Lotus Terminal (TDS)** is **off**. Settings → Appearance → **Custom Accent Color** → pick a color.

**Expected:**

- The app's accent (buttons, selected/active states, links, primary chips) recolors to your choice **live**.
- **Look critically at quality** (this is the part I can't verify): button **text legibility** (OnMain contrast) on the accent buttons; **hover/active** shades; and **selected-row / chip** backgrounds (the translucent "Container" tints). Try a **light** color and a **dark** color and a **saturated** one.
  - If a dark accent makes selected-row text (OnContainer) hard to read, tell me — that's the one spot in the auto-derived palette most likely to need tuning.
- **Reset** clears it back to the theme default.
- Turn **Lotus Terminal ON** → the custom accent should be **ignored** (TDS fixed palette wins) and the picker shows a "non-TDS only" note; turn it back off → custom accent returns.
- Reload → the chosen accent **persists**.

---

### M4. Search: "Pinned only" filter

In message search, toggle the **Pinned** chip.
**Expected:** results narrow to messages currently pinned in their room; composes with the Images/Files/Video chips and room/sender/date filters; toggling off restores results. It also narrows the **encrypted/local-cache** results section (not just server results). Needs a room with actually pinned messages.

### M5. New theme presets (Cyberpunk / Ocean / Blood Red / Classic Matrix / Midnight) — ⚠️ visual judgment

Settings → Appearance → theme picker → try each of the 5 new themes.
**Expected:** each applies a complete, legible dark palette. Code review computed WCAG contrast and all pass AA, but **eyeball these specifically**: **Midnight** (lowest-contrast accent `#6b7ca8` — selected/focus states), **Classic Matrix** (green accents, light-green body text on near-black), **Blood Red** (white-ish text on bright-red buttons). Confirm Success/Warning/Critical (save/leave/delete) still look correctly green/amber/red, not recolored. Switching back to a stock theme should fully revert.

---

## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6

The Lotus client can now sign into OIDC-native homeservers (ones that delegate
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
delta + a `config.json` override) OR against **mozilla.org** with a real account.

### N1. OIDC login flow (the core test) — needs a MAS homeserver

1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
2. **Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.

**Expected:** no console CSP violations; you reach the room list as the OIDC user.

### N2. Session persists across reload (token storage)

After N1, hard-refresh the page.
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).

### N3. Token refresh (long-lived session)

Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).

### N4. Logout revokes at the issuer

Log out from Settings.
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.

### N5. Account-management deep-link

Settings → Account.
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.

### N6. Non-OIDC regression — password login unchanged

Log into **matrix.lotusguild.org** (password) and **matrix.org**.
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.

---

## O. July 2026 batch — threads, notifications, math, search cache, audit wave

Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the outstanding-verification backlog below (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).

### O1. Thread Panel (P3-8) — 👥 2 people help for live replies

1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
3. Reply to a reply _inside_ the panel.

**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").

### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people

1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.

**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.

### O3. Math / LaTeX (P4-4)

Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.

### O4. Encrypted search cache (P4-8) — opt-in

In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).

### O5. Session hardening (N97a) — cross-tab

1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.

### O6. Audit-wave correctness fixes (AW-1)

- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.

### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only

The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:

1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.

### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call

We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report; the KE-1..4 diagnosis + capture guidance is in `LOTUS_TODO.md` (Encryption / E2EE), with the full original runbook in git history.

---

## P. Accessibility (P3-4) — needs a browser + a screen reader

The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).

### P1. Keyboard-only golden path (no mouse)

Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.

### P2. `?` shortcuts dialog

Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).

### P3. Screen-reader: reading messages

With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.

### P4. Screen-reader: live announcements

- **New message** arrives while you're reading → announced (polite).
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
- **Editing a message** → the edit box announces "Editing message from X".

### P5. Focus return from dialogs

Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.

### P6. axe / Lighthouse scan

Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).

---

## Q. Inline Media Embeds — video / audio / post players (needs the web deploy live)

The whole feature is behind **Settings → General → "Inline Media Players"** (default **on**). Everything loads from the homeserver's cached thumbnail first; the third-party player only mounts on **Play**. Test on the **web** build first, then re-check the video ones on **desktop (Tauri)** since the CSP differs. On any failure, grab the **browser console** (F12) — a blocked embed shows as a CSP `frame-src` violation naming the host.

### Q1. Facade + one of each kind plays in place

Paste each of these into a room and confirm a media tile (not a plain link) with a thumbnail + play button, and that clicking Play mounts the player **inline**:

- **16:9 video:** a YouTube `watch` link, a Vimeo link, a Dailymotion link, a Streamable link, a Twitch VOD/clip, a Loom `share` link.
- **9:16 portrait:** a YouTube **Shorts** link (renders tall, not letterboxed).
- **Audio player:** a Spotify track, a SoundCloud track, an Apple Music album, a Tidal album/track.
- **Post embed:** an X/Twitter post, an Instagram post, a Reddit post.

**Expected:** ✅ tile shows the thumbnail; **no** request to the third party until you press Play (check DevTools → Network); the player then plays inline. ❌ tell me any that stay a plain link, show a blank frame, or hit the network before you click.

### Q2. TikTok (the tricky one) + portrait fill

1. Paste a **full** TikTok URL and a **short** copy-link (`vm.tiktok.com/…` or `tiktok.com/t/…`).
2. Press Play on each.

**Expected:** both resolve to a clean **9:16** player that **fills the box** (no big empty band on the right). The short link shows a brief spinner while it resolves via oEmbed, then plays. ❌ tell me if a short link shows only the TikTok logo/♫ and never a play button, or if the player has dead space beside it.

### Q3. Post self-resize + Close / Fullscreen controls

1. Play a **Reddit**, **Instagram**, and **X/Twitter** post embed.
2. Watch the card height as the embed loads.

**Expected:** the card **grows to fit** the post (no clipped/scrollbarless content, no giant empty box). A **Close** button (✕) collapses the player back to the thumbnail; video players also show a **⛶ Fullscreen** control that works. Keyboard: Tab to the play button → it shows a visible **focus ring**.

### Q4. New providers (unverified) + the toggle + the cap

- **Bluesky / Loom / Kick** — these are freshly added and unverified live. Paste a `bsky.app/profile/…/post/…`, a `loom.com/share/…`, and a live `kick.com/{channel}` link. ✅ good if each plays/renders inline; ❌ if any is a broken frame (for **Bluesky** especially, note whether a **handle** URL resolves or only a DID one does — grab the console).
- **Toggle off:** Settings → General → **Inline Media Players** off → every media link reverts to a plain link tile (no player).
- **Cap:** paste a message with **8+** media links → at most **6** preview cards render (the rest are suppressed), and the page stays responsive.

---

## R. Discovery-pass fixes (DP1–DP18, 2026-07)

Agent-surveyed + TPVR-verified low/med issues, now fixed (commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`). Behavioral items have concrete checks; the refactors just need a "still works" regression pass.

### Correctness

- [ ] **DP1 — slash-command errors are visible.** Run a slash command that must fail — e.g. `/kick @nobody:server` in a room where you lack permission, or `/join` a bad alias. **Expected:** an error toast appears (not a silent no-op); a successful command still clears the composer normally.
- [ ] **DP2 — no invite re-notify on reload.** With ≥1 pending invite, hard-reload (Ctrl+F5). **Expected:** NO "you have N new invitation" toast/sound on load. Then have someone invite you while the app is open → you DO get one notification for the new invite. (👥 2 accounts)
- [ ] **DP3 — status clear syncs across devices.** Set a status message on device A, confirm it shows on B; clear it on A. **Expected:** B clears too and does NOT re-publish the old status on its next presence heartbeat. Toggling Invisible must not wipe a real saved status. (👥 2 sessions)
- [ ] **DP4 — tag-toggle failure surfaced once.** With the network offline, toggle a room's Favourite/Low-priority. **Expected:** a single error toast (not two) on failure; on success the tag updates as before.
- [ ] **DP5 — soundboard packs update on room switch.** Open a soundboard in room A, then switch to room B (different pack) in the same mounted view. **Expected:** B's packs show without needing an unrelated event.
- [ ] **DP6 — declining a call still dismisses.** Decline an incoming call. **Expected:** the ringing UI dismisses even if the decline send fails (best-effort). (👥 2 accounts)

### a11y / UX (needs a screen reader + a narrow viewport)

- [ ] **DP7 / DP8 — call-control buttons announce correctly.** In a call with a screen reader: the deafen button announces "Deafen" when sound is on (not "Undeafen"); Sound / Video / Screenshare announce a consistent pressed/unpressed state like Mic.
- [ ] **DP9 — GIF picker focus + width.** Open the GIF picker, close it (Esc / click-out) → focus returns to the GIF button. On a ~320px viewport the picker doesn't overflow the page.
- [ ] **DP10 — search-filter clears by keyboard.** In message search, toggle a filter chip (Has link / msg-type / pinned) off with Enter; the date-range clears via its menu's Clear. No mouse-only clear needed.
- [ ] **DP11 — voice recorder fits + announces.** On a ~360px viewport, start a voice message → the recorder row doesn't overflow the composer; a screen reader can query the duration (role="timer") without being spammed.
- [ ] **DP12 — live-call count announced.** With a screen reader, when someone joins/leaves an active call, the "{n} Live" change is announced (polite status region).

### TDS colors (Lotus Terminal theme)

- [ ] **DP14 — send-status + receipt colors follow the theme.** In **TDS light** mode: the message send-status "failed" icon and the read-receipt pill use the theme's darker red/blue (from `--lt-*` tokens), NOT bright dark-mode cyan/red. TDS dark still looks right; non-TDS themes unchanged.

### Refactor regression pass (no behavior change intended)

- [ ] **DP13 — bookmarks / reminders / notes still work** (they now share one store engine). Add/remove a bookmark, set/clear a reminder, write/clear a user note; each persists across reload; rapid consecutive writes don't clobber each other.
- [ ] **DP15 / DP16 — state-event + account-data writes still work.** Edit room name/topic/avatar, join-rules, power levels, an emoji/soundboard pack (state events); toggle a setting stored in account data. All save + reflect correctly.
- [ ] **DP17 — link previews render.** Open a TikTok / Spotify / Steam / Reddit link preview; provider icons render (folds icons, not raw glyphs) and brand colors look right.
- [ ] **DP18 — member avatars/names live-update.** Read receipts + the "seen by" reader list show correct avatars/names and update live when a member changes their avatar or display name (no reload).

---

## Priority if you're short on time

1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
5. **D** (EC control sweep) — guards against the fork breaking calls.
6. Everything else.

---

## Outstanding verification backlog

**Room Widgets (MSC1236, 2026-07 — needs the CSP `frame-src` widening + `nginx -s reload` first):** In a room, the header **Widgets** button (grid icon, desktop) opens a right-side panel. As an admin (PL to modify widgets): **Add Widget** with a name + an https URL (e.g. an Etherpad `https://…` or any embeddable page) → it appears in the list; click it → it renders in a sandboxed iframe in the panel; **Remove** clears it. A non-admin sees the list + can open widgets but has no Add/Remove. Check: a non-https or same-origin URL is rejected on Add with a clear message; the panel is a full-screen overlay on mobile and is mutually exclusive with the Thread/Gallery/Members panels; if a widget stays blank, the prod CSP `frame-src` still needs widening. Widgets get only benign display capabilities (they can't send/read room events in v1).

**QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set).

**Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured.

**Mark as Unread + Low Priority (MSC2867 / m.lowpriority, 2026-07):** Right-click a room in the sidebar → **Mark as Unread** puts a dot on the row (bold name) even with no new messages; opening/reading the room clears it, and it syncs to another device. **Mark as Read** on a marked room clears it too. Right-click → **Add to Low Priority** moves the room into a collapsed "Low Priority" category at the bottom of the room list (and removes it from Favorites if it was there, and vice-versa); **Remove from Low Priority** returns it to Rooms.

**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.

**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)

**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**

- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.

**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.

_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._

Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.

| ID   | Item                                                                                                                                    | File / area                                                                | Test                                                                                                                                                                                      |
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| #2   | Chat-background animation flicker (`contain:paint`)                                                                                     | `lotus/chatBackground.ts`                                                  | F1                                                                                                                                                                                        |
| #4   | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live)                                                                 | `CallEmbedProvider.tsx`, `ringtones.ts`                                    | A1,A3,A4                                                                                                                                                                                  |
| #6   | Background vs. seasonal theme mutual exclusion                                                                                          | `state/settings.ts`, `General.tsx`                                         | F2                                                                                                                                                                                        |
| #7   | Composer toolbar touch targets (≥44px)                                                                                                  | `room/RoomInput.tsx`                                                       | E1                                                                                                                                                                                        |
| #8   | Room Settings horizontal overflow (mobile)                                                                                              | `components/page/style.css.ts`                                             | E2                                                                                                                                                                                        |
| #9   | Modal fullscreen on mobile (`useModalStyle`)                                                                                            | 22+ modal files                                                            | E3                                                                                                                                                                                        |
| #10  | Composer not hidden by keyboard (`100dvh`)                                                                                              | `src/index.css`                                                            | E4                                                                                                                                                                                        |
| #12  | PiP "All muted" badge re-fixed (was firing on any single mute)                                                                          | `hooks/useCallSpeakers.ts`                                                 | G1                                                                                                                                                                                        |
| N96  | Call-recovery overlay single "Back" button                                                                                              | `call/CallView.tsx`                                                        | A7                                                                                                                                                                                        |
| N95  | AFK-monitor mic released on mute (OS indicator clears)                                                                                  | `hooks/useAfkAutoMute.ts`                                                  | L1                                                                                                                                                                                        |
| N108 | Maskable PWA icons (Android adaptive)                                                                                                   | `public/manifest.json` + `res/android/maskable-*`                          | L2                                                                                                                                                                                        |
| EC   | EC iframe load watchdog + self-heal + recovery UI                                                                                       | `plugins/call/CallEmbed.ts`, `CallView.tsx`                                | A7                                                                                                                                                                                        |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`)                                                  | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx`                         | get a msg notif, close the tab, click it → app focuses/opens + routes to the room                                                                                                         |
| Gal  | MediaGallery lazy-decrypt (true virtualization deferred)                                                                                | `room/MediaGallery.tsx`                                                    | H1                                                                                                                                                                                        |
| a11y | aria-labels: edit-history / reaction / thread / reply                                                                                   | `message/*` (`FallbackContent`, `Reaction`, `Reply`)                       | I                                                                                                                                                                                         |
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer)                                                                   | `features/room/thread/*`, `RoomTimeline/RoomInput`                         | 6-step checklist in LOTUS_TODO §P3-8                                                                                                                                                      |
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk)                                                                                  | `utils/mathParse.ts`, `components/math/`                                   | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text)                                                                                                |
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe)                                                                       | `utils/searchCache.ts`, message-search                                     | enable in search panel → search → reload → coverage persists; logout wipes                                                                                                                |
| N97a | Session blob migration + cross-tab logout sync                                                                                          | `state/sessions.ts`, `useSessionSync`                                      | login on old build → new build migrates; logout in tab A → tab B drops to auth                                                                                                            |
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math)                                                 | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread`      | 6-step checklist in LOTUS_TODO §P4-1                                                                                                                                                      |
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure)                                                                   | `ScheduledMessagesTray.tsx`                                                | schedule → cancel with network cut → item stays + error; retry works                                                                                                                      |
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast)                                                                 | `plugins/emoji.ts` + consumers                                             | first emoji-board open of a session: grid+search populate; reactions still label                                                                                                          |
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately)                                                            | `sw.ts`, `vite.config.js`                                                  | load app twice (2nd = cached assets); deploy → reload picks new version                                                                                                                   |
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest                                                                        | `tauri.conf.json`, Room/ThreadPanel                                        | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work                                                                               |
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path                                                                                                                           |
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb                                         | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx`                  | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity                                       |
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack)                                    | fork `lotusDeafen.ts`, cinny `CallControl.ts`                              | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback)       | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx`       | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot                               |
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples)                                                                            | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy`                | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work                                 |

**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.

---
