The board opens with focus in its search field, and its FocusTrap used
the shared stopPropagation helper, which ignores Escape while an input
is focused, so Escape never closed it (Chromium, Firefox, WebKit).
Always close on Escape here, still stopping the key from closing the
room or modal underneath. The GIF picker already uses the default.
Fixes#304
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
matrix.to can't send anyone to this deployment (its Cinny adapter is
app.cinny.in, web-instance[] is Element-only), so a friend scanning a
room QR or clicking an invite link was offered Element or app.cinny.in.
Share Room (link + QR) and the Invite dialog (link + QR) now use the
Lotus permalink (correct on desktop via webAppUrl). The space menus
keep both "Copy Link" (matrix.to, for other clients) and "Copy Lotus Link".
Fixes#285
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
PollContent read mEvent.getId() once at render. For your own new poll
that is the local echo id (~!room:txn), and nothing re-rendered it when
the SDK swapped in the real $id, so the vote related to the local id
and the SDK threw synchronously (getPendingEvents under chronological
ordering): no vote was ever sent. Track the id via LocalEventIdReplaced
and Status, block voting while it is still local, and catch synchronous
throws. Adds the Playwright regression the issue asks for.
Fixes#284
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The tray only knew what this browser had scheduled (localStorage), but
Synapse lists every pending one via MSC4140 GET /delayed_events. Load it
when the tray mounts and on window focus (localStorage stays as a first-
paint cache), keeping only non-state m.room.message events. Logout now
says "N scheduled messages still send after you log out" and offers to
cancel them.
Fixes#322
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
createRoom granted org.matrix.msc3401.call.member at user level only for
voice rooms. Every other room fell back to Synapse's state_default (50),
so in a private group room (where Lotus shows the call button and rings)
only admins could start a call; members saw "1 Live · Join" and then
"You don't have permission to join!". All new non-space rooms now get
call.member: 0 (spaces keep their own override).
Verified headless through Create Room → Private: before, no call.member
entry (state_default 50); now 0. Existing rooms are unchanged; an admin
can set it in room permissions.
Fixes#328
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
handleReject related the MSC4310 decline to the caller's call.member event
(the notification's reference), but MSC4310 and Element Call match a
decline against the rtc.notification event itself, so the caller kept
"Calling…" for the full 90 s after being told "Declined", and declines
from other clients (correctly referencing the notification) didn't stop
Lotus ringing on other devices.
- The incoming call keeps the notification's own event id; Reject relates
the decline to it.
- The "declined on another device" dismissal and declinedRing accept
either id (older Lotus builds referenced the membership).
- The missed-call line is skipped for a call we declined.
Verified headless (DM, Bob rings, Reject): the decline used to reference
the membership; it now references the notification. Unit test added.
Fixes#313
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Several dialogs are <Box as="form"> with the header ✕ (folds IconButton,
no type attribute, so a submit button) as the form's first button. On
implicit submission, Enter in a text field "clicked" ✕, closing the dialog
and throwing away what was typed (a poll's question and answers, a
schedule, a report reason).
The header close/cancel IconButtons now have type="button": Create Poll,
Schedule Message, Report User, Report Room, Account Data editor, State
Event editor, device verification setup, and the message delete/report
dialogs.
Verified headless: in Create Poll, typing a question and pressing Enter
used to close the dialog; now it stays open with the question kept.
Fixes#315
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The media gallery and activity log register filtered timeline sets on the
room (getOrCreateFilteredTimelineSet); the room re-emits their events as
live RoomEvent.Timeline, so every matching event reached "new event in
the room" listeners twice for the rest of the session. The timeline
window shifted by an extra message per event (loaded messages vanished),
and notifications, unread counts, widget feeds and ring handling saw
duplicates.
isFromFilteredTimelineSet() (unit-tested) identifies emits from a filtered
set; the room timeline, roomToUnread, message notifications, clock-skew
sampling, call ring/decline handling and widget event feeds ignore them.
Verified headless (40 messages, every 5th a file; gallery opened; Bob
sends 3 files + 1 text): before, the first rendered message drifted from
the room start to "file15"; now it stays put.
Fixes#259
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
After a reload/restart within a ring's lifetime, the catch-up sync replays
the ring together with the caller's hang-up and our own decline, and the
MatrixRTC session can keep the stale join. The client rang again for up to
90 s and showed "1 Live" (call screen instead of chat) for minutes.
- Before ringing, check the room STATE (utils/callRing.ts, unit-tested):
no ring if we already declined that ring or are in the call; if the
caller has no active membership yet, re-check after 4 s (a real call's
membership can land just after its ring) and ring only if they joined.
- The "caller gone" dismissal also consults room state, not only the
possibly stale session.
- When sync becomes live (startup or reconnect), recompute every active
RTC session from the current state, clearing a stale "Live".
Verified headless (Bob's membership + ring in an invite-only room):
live ring rings; hang-up stops it; reload within the window: before
rang again + "1 Live", now no ring and no "Live"; a ring that arrives
just before the membership still rings.
Fixes#312
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Joining a voice channel alone sends a call notification (m.rtc.notification)
to the room, and the timeline rendered every unanswered one as "Missed call
from X", so everyone in the channel saw a missed call from someone who had
just joined it. Ringing already excluded these rooms; the missed-call line
didn't.
utils/callRing.ts (unit-tested) holds the one rule for both: only DMs and
invite-only groups outside any space ring or show "Missed call"; voice
rooms, space channels, restricted and public rooms don't.
Verified headless with the same two notifications from Bob: the Voice
Lounge chat showed 2 "Missed call" lines before, 0 after; a small
invite-only group still shows its missed call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
- Tab into a link in the hidden part used to scroll the overflow:hidden box
itself (top cut off, and it stayed that way); focus inside now expands
the message, and collapsing resets the scroll.
- "Read more" / "Show less" is announced as a toggle (aria-expanded,
aria-controls); the arrows are aria-hidden.
- The fade is a mask on the content instead of a gradient in a fixed
surface colour, so there's no band in bubble layout, on highlights or
over chat backgrounds.
- The cap is 20rem instead of 320px, so a larger font shows as many lines.
Verified headless on a 41-line message with a link on the last line:
aria-expanded false → focusing the link expands it (aria-expanded true,
no clipping); bubble layout fades into the bubble with no band.
Fixes#343
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The composer's location button sent the exact position (6 decimals) from a
single click once the browser permission had been granted, discarding the
fix's accuracy, so a ±3.5 km Wi-Fi/IP fix was shown as exact.
- ShareLocationDialog: "Share your location?" with the coordinates and
±accuracy, a "Check on OpenStreetMap" link, and Cancel / Send
approximate (~1 km) / Send. Focus starts on Cancel; Esc cancels.
- utils/geoShare.ts (unit-tested): RFC 5870 geo URI with `;u=` accuracy;
approximate shares round to 2 decimals and never claim better than 1 km.
- Location cards show the ± when the event carries `u=`.
- The toolbar icon is a pin instead of the globe.
Verified headless (fix 40.748817,-73.985428 ±3500 m): the click opens the
dialog and sends nothing; Cancel/Esc send nothing; Send approximate →
geo:40.75,-73.99;u=3500; Send → geo:40.748817,-73.985428;u=3500; the card
reads "40.74882, -73.98543 (±3.5 km)".
Fixes#340
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Giphy and Tenor link previews forced every GIF into the same box — full
card width, max 200px tall, object-fit: cover — whatever its shape: a
square GIF lost half its height, a portrait one three quarters (only the
middle band showed, so captions and faces at the top/bottom were cut), and
a 100×80 GIF was blown up 4×.
utils/gifPreviewSize.ts (unit-tested) fits the GIF inside 400×320 from
og:image:width/height, keeping its aspect ratio and enlarging small GIFs
at most 2×. The card centres the GIF on the surface colour with
object-fit: contain, reserves the box before it loads, and the GIF badge
sits on the GIF's corner. Without dimensions it shows at natural size,
capped by the card. The >10 MB thumbnail fallback is requested at 2× the
box (800×640) so it isn't upscaled.
Verified headless (fixture previews, desktop / Pixel 7 / 320px phone):
480×200 → 398×166, 300×300 → 320×320 (292×292 on 320px), 240×480 →
160×320, 100×80 → 200×160, all 100% visible; the no-dimension fallback
and narrow cards keep the ratio. tsc clean, 1325 unit tests pass, eslint
0 errors (36 warnings, unchanged), prettier clean.
Fixes#341
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Rendering only; the vote logic (tally, optimistic votes, end poll, keyboard
radiogroup, voters) is unchanged.
- Every row shows "N votes · P%". Multiple choice looked broken as
"100% / 100%" with no counts; the footer now says "voters" there.
- Results are a thin progress bar under each answer: accent for your pick,
success for the winner, neutral otherwise. The old full-row fill was the
same grey as the row, so a 100% answer just looked disabled.
- Radio and checkbox indicators are 18px with a 2px border in a colour mixed
from the theme's text colour. Primary.ContainerLine was nearly invisible,
especially in dark themes.
- The winner shows a star and "Winner" instead of a second check mark.
- Bordered card, 460px wide (max 100%, same width for every poll). The header
is now "Poll · Single choice | Pick up to N | Results hidden until the end |
Final results", replacing the letter-spaced "◉ POLL · …" line.
- Footer: plain muted text that wraps, and chips ("Who voted", "End poll")
that never wrap. Previously the chip label broke onto two lines inside a
one-line chip and spilled out of it. Reproduced before/after in Lotus
Terminal at 150% zoom, a 360px phone, and dark at 125%.
- Lotus Terminal theme: data-winner gets its own green border rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52
has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc
backend around 2.56), so calls can't work there. Until now the call button
just disappeared, the call room said "Your browser does not support WebRTC"
with Join disabled, and an incoming call couldn't be answered.
In the desktop app (isTauri) without WebRTC:
- call rooms: "Calls aren't available in the desktop app on Linux yet: its
web engine has no WebRTC" + an "Open in browser" button;
- incoming-call overlay: the same, with "Answer in browser";
- room header: the call button stays, and opens the room in the browser.
The link is the room in the web app (config.json `webAppUrl`, https only,
new key); the user presses Join there. Deliberately not an auto-join link:
a crafted URL must not be able to join a call and open someone's mic. It
opens through the desktop's new-window handler (web/mail schemes only → the
system browser). Without `webAppUrl` the explanation shows with no button;
browsers without WebRTC keep the old message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
With a fork that reports `screensharePrompt` (element-call lotus-screenshare-
prompt), the call bar and status bar keep their screenshare button on every
engine. Where the click can't be delegated, starting asks the fork to show
"Share your screen?" inside the call frame (io.lotus.prompt_screenshare)
instead of our own confirm; its Share click starts the share. Stopping works
from the bar as before (no click needed in the frame). Chromium is unchanged.
- useScreenshareMode: hidden (older fork's corner button) | prompt | direct.
- The room's call policy is also pushed to the fork in prompt mode, so the
prompt never opens where sharing is forbidden.
- Picture-in-picture: the "Return to call" overlay covers the frame; while
the fork reports the prompt open it lets clicks through to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Firefox, Safari and the WebKitGTK desktop app can't hand the user's click to
the call frame (no Capability Delegation), and getDisplayMedia needs it. The
host used to click EC's hidden footer button through the DOM instead, which
dies with same-origin. Now (pins element-call-embedded 0.25.0-lotus.20):
- those engines get `lotusFrameScreenshare`, the fork shows EC's own
screenshare button in the frame, and the call bar and status bar hide
theirs once controls_state reports `frameScreenshare`; the
screenshare-audio mute stays;
- the room's call policy is pushed with io.lotus.set_frame_screenshare, so
the frame button hides where the server would refuse a share, like ours;
- Chromium keeps the delegated io.lotus.set_screenshare from the host bar.
Removed the fallbacks for forks older than lotus.14, which read or clicked
EC's DOM: the screenshare/layout/settings/reactions/leave button lookups
and their MutationObservers, the frame-window hotkey binding, and the
speaking/muted tile scrape in useCallSpeakers (io.lotus.call_state is the
only source now). getCallDocument is gone; the host's only handle on the
frame is postMessage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Probed how real providers fail before picking signals:
- X renders an EMPTY frame for a deleted/private/suspended post and says
so only via postMessage `twttr.private.no_results`. The post embed now
swaps to "This post isn't available…" with an "Open on X" link.
- A hung frame never fires `load`. After 20 s every player (media, rich
posts, TikTok, Steam widget, X) overlays "This embed is taking too long
to load" with Retry (remounts the iframe) and "Open on <site>". A late
`load` clears it.
- Instagram and Bluesky show their own "removed / not found" page, and a
refused request still fires `load` (browser error page), so neither needs
or can use a guess. A missing height message is not treated as failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The reaction button's aria-label used getShortcodeFor(), which returns
undefined until the lazily loaded emoji data arrives. The same button read
"🎉 reaction, 1 person" on first render and "tada reaction, 2 people" after
any later re-render. It now always uses the emoji itself (screen readers
speak it by its proper name, e.g. "party popper").
Custom (mxc) emoji were labelled just "custom emoji"; they now use the
shortcode carried on the reaction event (":lotus_blob: reaction").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
d2503332 made Enter pick the mention suggestion, which also caught
"/kick @alice:server" + Enter (the e2e "/kick failure is reported" test):
typing a complete ID opens the mention list too, and Enter picked it instead
of running the command. Enter now picks only while a partial name is being
typed (no ':' in the query); a complete @user:server or #alias:server sends
as typed, like before.
Chromium e2e 19 passed; "@bo" + Enter still picks, ":smi" + Enter still
sends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
With the mention list open, pressing Enter sent the half-typed text
("hey @bo") instead of inserting the highlighted person — found while
investigating the "composer hit a snag" report; only Tab picked. Enter now
picks the top suggestion for people, rooms and commands, as in Discord,
Slack and Element.
useAutocompleteEnter listens in the window capture phase (so it runs before
the composer's submit handler) and only while the list has suggestions —
with no command match Enter still sends. Emoji suggestions are deliberately
left on Tab only, so "lol :p" + Enter still sends rather than inserting an
emoji. IME composition and Shift/Ctrl+Enter are untouched. Tab now inserts
the member's display name, same as a click.
Verified: "@bo" + Enter inserts the mention and sends nothing; the next
Enter sends "hey @bob hi" with m.mentions; ":smi" + Enter still sends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Jared felt the first redesign was too sparse for a full theme: most of the
time only two webs and a spider were on screen.
- Will-o'-the-wisps: six soft green/orange glows wandering and pulsing,
always present while animating (not in the still scene, where static glows
would sit on text).
- A witch on a broom crosses just below the header about once a minute
(redrawn so she reads as a seated figure, not a blob).
- Bats every 20 s (was 30), plus a faster swarm of eight every 60 s.
- The big spider now climbs up its thread and drops back with a bounce.
- A second, small spider on the left web; a tiny jack-o'-lantern with a
flickering candle hangs by the right web (desktop).
Rendered and inspected at the witch/swarm moments in light, dark and phone,
plus reduced; still 60 fps on vs off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Draft for human review (#239 must be approved and closed by a human).
- Removed the full-screen purple wash, fog and the blurred green "moon" that
sat over the member list and made it unreadable.
- Corner cobwebs (generated radial threads + sagging rings), thin and
readable in both themes, smaller on phones.
- A spider on a thread in the empty space under the member list (near the
top on phones), bobbing and swaying.
- Every 30 s a group of four bats flaps across the top of the screen (a real
bat silhouette; wing beat without flattening them into dashes).
- Every 50 s a small, see-through ghost rises and fades (outlined so it
shows on light themes; text stays readable through it).
- Theme-aware ink via --hw-ink/--hw-web: dark silhouettes on light themes,
pale dusk-violet on dark ones.
- Reduced motion: webs and the spider at rest. Settings swatch: night sky,
crescent moon with a bat across it, a corner web.
Rendered and inspected: light/dark/phone at the bat and ghost moments,
reduced on desktop and phone, the Settings swatch; 60 fps on vs off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The main chunk had crept to 356.9 kB gzip against a 349.9 kB budget (a
hard gate on pull requests, only a warning on pushes, so it went unnoticed
until PR #241). jsQR (camera scanning) and qrcode (drawing the QR) were
statically imported by the device-verification components, so every
startup loaded them. Both are now dynamic imports: jsQR when the scanner
opens (fetched in parallel with the camera permission), qrcode when a QR is
drawn.
Main chunk 356.9 → 302.5 kB gzip (largest chunk is now matrix-sdk at
304.7 kB, within budget); chromium e2e 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Draft for human review (#237 must be approved and closed by a human).
- Removed the full-screen amber wash, sun-shaft bands and vignette: they
turned the user's theme beige (muddy brown on dark) and lowered contrast
everywhere. The leaves carry the season on their own. Also removed the
dust motes (invisible specks) and the now-unused keyframes.
- Gust: every 42 s (first ~8 s after load) five leaves blow across the screen
together, rising and spinning, in ~5 s. Linear timing — an eased curve is
applied per keyframe segment and made them hang mid-air for a second.
- Softer flutter (leaves flattened into slivers mid-flip), stronger leaf
colour (opacity 0.58–0.73, less pale gold tip), brighter on dark themes.
- Reduced motion on phones: no icon rail there, so the resting leaves tuck
further off-edge and smaller instead of landing on the timeline icons.
- Settings swatch: new optional `preview` prop on seasonal overlays; Autumn
draws a warm backdrop with arranged leaves (the reduced scene rendered in
the tile as a black square with two edge fragments).
Rendered and inspected: light/dark/phone at 10/15/20 s, reduced on desktop
and phone, gust tracked frame by frame, the Settings swatch; 60 fps with the
theme on vs off (p95 frame 16.7 ms both).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Draft for human review (#237 must be approved and closed by a human).
- Bug: every falling leaf sat in a leaf-sized wrapper pinned at the top of
the screen with `contain: layout paint style`. Paint containment clips
children to that box, so a leaf stopped being drawn as soon as it fell out
of its first ~40 px: the animated theme showed almost nothing while the DOM
reported 10–16 leaves on screen. Now `contain: layout style`.
- Leaf shapes: a flag-style maple (bold lobes, stem) that stays recognisable
at 30 px (the old outline read as a star/splat), an oak with a stem, and an
aspen; gradient body with darker veins.
- Palette: crimson, pumpkin orange, gold, russet, burnt orange (was muted
tans that read as beige on the light theme).
- 12 leaves at 28–52 px, opacity 0.5–0.65 so text under a passing leaf stays
readable; brightened on the dark themes, where they sank into the wash.
- Reduced motion: resting leaves moved into empty layout areas (middle of
the left rail, under the member list), partly off-edge, instead of on the
sidebar, members, composer and corner controls.
Rendered and inspected at 10/15/20 s in light, dark, phone and reduced
motion (screenshots on #237).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
When a multi-line paste looks like code, a chip above the composer asks
"That looks like code. Format it as a code block (js)?" with Format as code /
No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and
Settings → Editor → "Offer to Format Pasted Code" turns it off.
- utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation
with depth changes, statement terminators, operators + brackets, keywords,
monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted
replies, URL lists, long sentence-punctuated lines, plain-word lines,
markdown lists. Language guess only when fairly sure (js/ts/python/sql/
rust/c/php).
- Accept rebuilds the pasted paragraphs as one code block from their plain
text (code lines hold text only) and leaves the caret after it.
- Code blocks carry an optional lang → <code class="language-js"> (whitelisted
identifier only).
Verified in Chromium: pasting a JS function shows the chip; Format as code →
sent formatted_body is <pre><code class="language-js"> with indentation intact;
a three-line prose paste shows no chip; typing after a paste dismisses it.
Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Timeline images were focusable (tabIndex 0) but only opened on click, so a
keyboard user could Tab to one and do nothing with it, and screen readers
heard a plain image. They are now role="button" labelled "View image: <name>"
and open the viewer on Enter or Space. Focus already returns to the image when
the viewer closes (checked for mouse and keyboard).
Verified in Chromium: Tab → Enter opens the viewer, Escape returns focus to
"View image: red.png", Space opens it again; chromium e2e suite 19 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- sendRoomMessage (composer's fire-and-forget sends: text, location, voice,
files, GIFs) and sendRoomEvent (polls, poll responses/ends, forwards,
reactions, edits) in utils/room.ts carry the one cast each needs
(`keyof TimelineEvents` / `RoomMessageEventContent`, no `any`).
- sendRoomMessage also swallows the rejected promise: a failed send already
shows on the local echo (Failed to send + Retry, or the consent prompt), so
it no longer surfaces as an unhandled error in the console.
- getAccountData narrows to `keyof AccountDataEvents`; ForwardMessageDialog's
guard now narrows `contentToSend` itself (same behaviour).
- `as any` 39 → 23; eslint warnings 46 → 36, ratchet tightened to 36.
Verified in Chromium on a local Synapse: a text message, a quick reaction and
an edit all reach the server with the right content; a consent-blocked send
no longer logs an unhandled MatrixError. 1219 unit tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A homeserver with a consent requirement (Synapse user_consent) rejects sends
with 403 M_CONSENT_NOT_GIVEN until the user accepts its current terms. The
message just showed "Failed to send" with no reason.
Listen for the SDK's HttpApiEvent.NoConsent and show a dialog naming the
user's own homeserver (the client works with any server, so no Lotus-specific
wording), with "Review and accept" opening the server's consent_uri (http(s)
only; anything else is dropped) and "I've accepted — retry sending" resending
every event that failed for this reason. "Later" snoozes it for 10 s so
background retries don't re-open it immediately.
Verified in Chromium against a local Synapse with the send endpoint answering
M_CONSENT_NOT_GIVEN: dialog shows, link opens, retry delivers the message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Follow-up to ce8ed89f for the modals that have no visible heading to
point at: the three "Seen by" reader lists (read-receipt pill, "is
following", message menu), View source, the text/PDF file viewers, the
room-card join error and the user-profile modal get role="dialog",
aria-modal and an aria-label; their traps move focus in (fallbackFocus
on the dialog) where the trap is local.
Verified: the receipt pill opens a "Seen by" dialog with focus on
Close; Escape closes it and focus returns to the pill.
The remaining unnamed Modal/Dialog uses are startup/loading and error
screens (config, feature check, spec versions, client root, password
reset) and wrappers around components that carry their own role (image
viewer).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Most modals rendered a folds Dialog/Modal with no role and no name, and
their focus traps used `initialFocus: false`, so focus stayed behind the
modal and a screen reader never announced it.
- 32 dialogs with a visible heading: role="dialog", aria-modal,
aria-labelledby → the heading (given an id), tabIndex=-1.
- 4 dialogs that already had a name (Leave Room, room topic viewer,
server ACL, room-nav prompt): role + aria-modal.
- Their focus traps drop `initialFocus: false` for focus-trap's default
(keep an already-focused autoFocus field, else the first tabbable
element) with the dialog itself as fallbackFocus, so a dialog without
a tabbable node can't crash the trap. Traps that live in a parent
(UIA stages, Logout, Forward, Invite) get the semantics only.
- The file drop overlay is deliberately left alone (not a dialog).
Checked at runtime: Join with Address, Delete Message, Report Message,
Leave Room and Logout open as named dialogs with focus inside and close
with Escape (Tab first when a text field has focus — the shared
stopPropagation keeps Escape from discarding typed text, by design).
The axe e2e spec (6 tests) passes; eslint warnings unchanged (46).
17 modals with no heading (image/file viewers, loading screens) remain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The first time the window is closed, a dialog asks "Keep Lotus Chat
running?" — Keep running in the tray (default, focused) / Quit when I
close the window — with an opt-in "Start Lotus Chat when I sign in"
checkbox in the same moment (per the approved design: one dialog, no
wizard). The choice is saved natively; Settings → General → "When I
close the window" changes it later (tray / quit / ask me).
The dialog is role="dialog" aria-modal, labelled and described, with
focus on the default button. Web-side flow verified with a stubbed
native side: event → dialog → checkbox + Quit sends autostart enable +
resolve_close_request("quit"); the Settings select saves "tray".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The full-screen incoming-call overlay (shown when you're not already in a
call — the common case) had no role or accessible name on any ancestor of
its buttons, so a screen reader landed on an unlabelled button with no
hint that a call was ringing. The in-call banner variant already had
role="alert" + a label. The overlay's Dialog is now role="alertdialog",
aria-modal, labelled "Incoming voice|video call from <caller>".
Found while verifying #187 DP6 (decline still dismisses when the decline
send fails — it does).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Steam double-encodes its meta tags (`&quot;`), so after Synapse's
single decode the card showed "The "Perpetual Testing
Initiative" …". The preview's og:title / og:description /
og:site_name are now decoded once where the preview is fetched, so every
card (about 20 read those fields directly) gets clean text. Rendered as
React text only, so decoding can't inject markup; exactly one level is
decoded.
Verified on the Portal 2 store link: "The \"Perpetual Testing
Initiative\" has been expanded…", no literal " left on the page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Removed every `as any` in src/, then restored only the ones tsc still
needs: 163 → 36. 112 were in lotus-terminal.css.ts (`'… !important' as
any` etc. — vanilla-extract's style types accept these strings as-is),
so its file-wide eslint-disable goes too. The casts were type-only, so
emitted code and generated CSS are unchanged.
eslint warnings 49 → 46; the ratchet is tightened to match.
tsc clean; 1202 unit tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Upload atoms now also report into an app-wide `uploadProgressAtom`, and
`aggregateUploadProgress` turns every in-flight upload into one taskbar
state: byte-weighted percentage while uploading, indeterminate until a
size is known, red after a failure (held 4 s, then cleared), none when
done. Cancelled uploads just disappear. `useTauriTaskbarProgress` sends it
to the native `set_taskbar_progress`, at most ~4 times a second, always
ending on the latest state.
Verified with a throttled 6 MB upload: 0% → 99% at 250 ms steps, then
cleared; with the upload request aborted: 0% → error → cleared after 4 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Notification sounds called `audio.play()` without handling the promise,
so every message that arrived before the user interacted with the page
(e.g. right after launch) logged an uncaught NotAllowedError — 15 in a
short test run. Same pattern in the video thumbnail loader, the voice
preview (which now also resets its Play button) and useMediaPlay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Two sizing bugs, both visible the moment a video starts:
1. The video/TikTok embed columns had no width. UrlPreview is a flex ROW,
so they shrink-to-fit: the facade's <img> supplied the width, but the
player <iframe> is absolutely positioned and supplies none, so pressing
play collapsed the whole embed to the iframe's ~200px intrinsic size
(measured 606x341 -> 204x115 in a 608px card). Both columns are now
width: 100%.
2. EmbedMediaPortrait's 9:16 came from a 177.78% padding-top, but a
padding percentage resolves against the CONTAINING BLOCK's width, not
the element's — so inside a wide card a Short/TikTok rendered
300x1077 instead of 300x533. Capped with min() so it is exact at
>= 300px and still correct on narrower phones.
Audited every provider before/after play at 1300 px, 500 px and Pixel 7:
YouTube, Vimeo, Twitch 16:9 (0.56); Shorts, TikTok 9:16 (1.78); Spotify
152 px and SoundCloud 166 px fixed-height — all stable across play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA