- RouteError: raw <div>/<h2>/<p>/<button> (sans-serif, raw px) -> folds
Box/Text/Button with config tokens.
- CallEmbedProvider PiP fullscreen control: raw <button> with ⊡/⛶ glyphs ->
folds IconButton reusing the exported FullscreenIcon/ExitFullscreenIcon SVGs
from Controls (consistent with the main fullscreen button). The intentional
dark over-video scrim is kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
We no longer use Sentry. Removed:
- @sentry/react + @sentry/vite-plugin (package.json + lockfile)
- Sentry.init in index.tsx and the VITE_SENTRY_DSN env (.env.production)
- @sentry/vite-plugin + the SENTRY_AUTH_TOKEN sourcemap-upload path in
vite.config.js (sourcemap now always false) and the CI env var
- Sentry.ErrorBoundary in App.tsx -> react-error-boundary's ErrorBoundary with a
folds-native fallback (Box/Text/Button + config tokens), which also resolves
the native-cinny audit's raw-#hex/#5865f2 fallback finding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LotusToastContainer was styled entirely with --lt-* CSS vars but rendered
unconditionally (not gated on lotusTerminal). Those vars only exist inside the
Lotus Terminal theme's scoped block with no global fallback, so in-app toast
notifications rendered with undefined background/border/colors on every stock
Cinny theme. Now the card uses folds tokens (color.Surface.*/Primary.*,
config.radii/space/borderWidth, color.Other.Shadow) by default, keeping the TDS
--lt-* glow/accents only when lotusTerminal is active. The raw <button> dismiss
control is now a folds IconButton.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useAfkAutoMute opened its own getUserMedia capture for the whole call and only
stopped it on unmount, so the OS recording indicator stayed lit even when the
user was muted. The capture is now gated on the reactive mic-on state: it runs
only while unmuted (there's nothing to auto-mute when already muted), so muting
tears down the stream and clears the indicator, and unmuting re-acquires it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five complete vanilla-extract themes registered in useTheme (useThemes +
useThemeNames), each spreading darkThemeData so Success/Warning/Critical keep
their semantic colors and only Background/Surface/Primary/Secondary are
recolored. A code-review pass computed WCAG contrast for every theme; all body
and accent pairs clear AA except Midnight's Primary.OnMain which was 4.49:1 —
fixed by changing OnMain #0d1320 -> #000000 (5.07:1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "Pinned" toggle chip that narrows results to messages currently in
their room's m.room.pinned_events. Client-side post-filter mirroring the
has:image/file/video pattern: a pure filterGroupsByPinned(groups, enabled,
isPinned) helper consumes a predicate; MessageSearch builds a per-room
Map<roomId, Set<eventId>> from StateEvent.RoomPinnedEvents.
Review fix: the msgtype + pinned filters are now applied to BOTH the server
results AND the encrypted/local-cache results (via a shared applyResultFilters
useCallback), so the chips narrow the whole UI consistently — previously the
local/E2EE section bypassed them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a customAccentColor setting + a HexColorPickerPopOut in Settings →
Appearance. When set (and Lotus Terminal/TDS is OFF), it derives a full folds
Primary palette (Main/hover/active/line, contrasting OnMain, alpha-tiered
Container set, OnContainer) from the chosen color and overrides the folds
Primary CSS variables on document.body — resolving each var name from the
imported folds color.Primary.* token strings (e.g. "var(--oq6d07f)"), the
same body-level injection pattern used for mentionHighlightColor. The theme
class is on document.body, so an inline override on body wins over it.
Reverts to theme defaults when unset or when Lotus Terminal is enabled (TDS
keeps its fixed palette); the picker is disabled with a note in TDS mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add three msgtype toggle chips (Images/Files/Video) to the search filter
bar, mirroring the existing "Has link" chip. The Matrix search API can't
filter by msgtype server-side, so results are post-filtered client-side
(union match on event.content.msgtype, dropping now-empty groups); the
server request is unchanged. Visible count may be lower than the server
total — inherent to client-side filtering.
- Recent searches: last 10 distinct terms persisted via a new
state/recentSearches.ts (atomWithStorage, error-safe, mirrors
scheduledMessages). Shown as clickable chips when the search input is
focused + empty, with a Clear affordance; clicking re-runs the search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N100: restrict <pre> classes to language-* in sanitize-html allowedClasses;
previously `class` was allowed on <pre> with no allowedClasses entry, so a
remote sender could inject arbitrary class names that activate site CSS.
- N106: OS notifications for E2EE rooms no longer carry decrypted plaintext
(which persists in the OS notification center / lock screen). Encrypted rooms
show only the sender; the in-page toast still previews while focused.
- N109: OS notification icon/badge use the static app logo instead of an
authenticated-media avatar URL the OS can't fetch (was 401 / no icon). The
in-app toast keeps the real room avatar (it can fetch via the SW).
- N119: syncDecorations.mjs distinguishes a confirmed 404 (remove) from a
network/5xx failure (abort) so a transient CDN outage can't silently wipe the
whole decoration catalog from source control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N98: logoutClient and handleLogout now call removeFallbackSession() (removes
only the 4 session credential keys) instead of window.localStorage.clear(),
so settings, unsent drafts, PiP position, and status are preserved across a
normal logout. localStorage.clear() stays reserved for clearLoginData() (the
explicit factory-reset path).
- N99: the useSyncState callback now handles ERROR/STOPPED. A sync failure
before the first PREPARED (offline at startup, homeserver unreachable) shows
a dedicated error splash with a Retry button (startMatrix) instead of an
endless "Heating up" spinner alongside a contradictory "Connection Lost!"
banner. Guarded by a hasPreparedRef so post-PREPARED transient errors still
go through <SyncStatus>; PREPARED self-heals the splash on recovery, and the
redundant banner is suppressed while the splash is shown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N124: denoise shim cleanup() now disconnects the noise gate AudioWorkletNode
(var-scoped, guarded), releasing the gate processor thread instead of leaking
it on every getUserMedia within a session.
- N125: denoise-status postMessage now targets the parent origin (derived from
the parentUrl widget param via new URL(...).origin, falling back to this
frame's origin) instead of broadcasting with '*'.
- N128: patch-folds.mjs fails hard (process.exit(1)) when the patch target is
missing, so an unpatched folds can't silently ship. The idempotent
"already applied" path still exits 0 (verified by re-run).
- N120: the avatar-decoration CDN URL is now single-sourced in
avatarDecorations.ts (DECORATION_CDN); syncDecorations.mjs extracts it by
regex (can't import across the build/app boundary) and fails hard if renamed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useCallSpeakers rebuilt the speaker Set from only the mutated tiles in each
batch (so a still-speaking participant whose tile didn't mutate was dropped),
and observed a static querySelectorAll NodeList (so tiles for participants who
joined mid-call were never watched). Rewritten to mirror useRemoteAllMuted in
the same file: a single body-level MutationObserver (subtree+childList+attrs)
re-scans ALL [data-video-fit] tiles on each relevant mutation. The speaking
criterion (::before background-image !== 'none') and the id (aria-label +
isUserId) are unchanged, so behavior on real EC DOM is a strict superset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N113: mutations compute from a local ref kept in sync with server echoes, and
writes serialize through a promise queue, so rapid add/remove no longer reads
a stale baseline and clobbers a prior write.
- N114: ReminderMonitor shows each toast once (firedRef) but retries the
account-data removal on later ticks if it fails (removingRef released on
error) — a failed removal no longer permanently swallows the reminder.
- N115: the 30s poll interval reads reminders/mDirects via refs and drops them
from the effect deps, so it's created once instead of resetting its countdown
on every reminder sync (which could indefinitely defer a near-due reminder).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N122: setMediaState resolves on EC's transport ACK instead of waiting for a
DeviceMute state-echo that EC may elide or skip during teardown — which
previously stranded the promise forever and silently skipped the initial
deafen state + first StateUpdate on join. Dropped the single-slot
mediaStatePromiseResolver; onMediaState remains the authoritative sync path.
- N123: focusCameraParticipant now waits for a spotlight videoTile to mount via
a MutationObserver (with a 600ms hard-timeout fallback) instead of a fixed
2-frame delay that EC's React commit can exceed on slower devices.
- N126: PiP position restored from localStorage is shape+finiteness validated,
so corrupt data can't feed NaN into the position math (invalid 'NaNpx' CSS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add grow="Yes" to ChatBgGrid and SeasonalBgGrid containers so they
expand to fill their flex parent — without it the Box shrank to one
column (~76px wide) because folds Box defaults to display:flex and
the wrapper is a flex-row with no explicit width.
- Mark N4 (PollContent) FIXED ✅ VERIFIED in LOTUS_BUGS.md after
confirmed pass on default Cinny themes and Lotus TDS.
- Mark B1 and B4 PASS in LOTUS_TESTING.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add sticky?: boolean to ToastNotif — sticky toasts skip the 4s auto-dismiss
timer entirely, staying until the user clicks or manually dismisses. Sticky
toasts also use cyan accent/glow (vs orange for messages) and allow the body
to wrap rather than truncate, so longer action-oriented copy is fully readable.
Update the Tauri update toast to: sticky: true, ⬆ prefix on the title,
"Click to install and restart" as explicit call to action.
Fixes: auto-dismiss before user noticed it, no visual distinction from
a regular message notification.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both Retry and Leave called the same dismiss function; Retry implied a
reconnect attempt that never happened. Collapsed to a single Back button
that honestly describes returning to the prescreen.
docs: correct Gemini audit entries — sanitize-html not DOMPurify (Claim A),
retract inaccurate LiveKit replaceTrack soundboard approach (Claim B,
contradicts confirmed cross-origin iframe constraint), expand N95 fix note
to clarify track-stop vs AudioContext-suspend distinction.
docs(testing): add L1 N95 reproduction guide; update A7 to reflect single Back button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review flagged that wrapping only the buttons left the dialog body copy
hardcoded (mixed-language dialogs once a non-en locale ships). Wrap the
remaining body/waiting strings ("Please accept…", "Confirm the emoji…",
"Do not Match", "Your device is verified.", etc.) and the PasswordStage
prompt, adding hooks to the sub-components that lacked one. Keys added to
en.json; all t() keys verified to resolve.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CallEmbed watchdog now SELF-HEALS: a genuine ready/joined signal arriving
after the 25s timeout clears the error and notifies subscribers with
undefined, so a slow-but-successful EC load no longer strands the user on
the recovery screen over a live call. Listener dispatch wrapped in try/catch.
- ringtones: synth notes route through a per-session master gain; stop() ramps
it to 0 so the ring is silenced instantly on answer instead of letting the
last scheduled phrase ring out over call audio.
- IncomingCallBanner: ping fires exactly once per incoming call (guarded by
refEventId) instead of re-pinging when ringtone settings change mid-banner.
- focusCameraParticipant: try multiple tile selectors (EC labels vary by
version), defer the tile click past EC's async spotlight layout switch
(rAF x2), and dev-warn when no tile matches so testers get signal.
- uploadContent: a cancelled upload (mx.cancelUpload -> AbortError) is no
longer treated as retryable — previously the retry loop could resurrect an
upload the user just cancelled. Also retry on 408.
- addRoomIdToMDirect/removeRoomIdFromMDirect: guard against a corrupt m.direct
whose values aren't arrays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wraps the hardcoded strings flagged in LOTUS_BUGS.md (Localization rows)
in t() via react-i18next, and adds the keys to public/locales/en.json
under the existing Organisms.* namespace. de.json intentionally left to
fall back to en for now (fallbackLng: 'en') rather than fabricate
translations.
Files: CreateRoomTypeSelector, ImageViewer, MsgTypeRenderers (MLocation),
Reply (ThreadIndicator), ImageContent, DeviceVerification (5 subcomponents),
UrlPreviewCard (DiscordCard), InviteUserPrompt, UploadBoard, PasswordStage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
N53: removed the duplicate lotusTerminal PTT-badge branch (raw <Box> with
--lt-* vars + bespoke rem/animation styling). The standard folds <Chip>
path now renders in all modes; TDS theming still flows through the CSS var
layer. Dropped the now-unused lotusTerminal read.
N81: ChatBgGrid / SeasonalBgGrid containers switched from flex-wrap with
fixed-width cells to a responsive CSS grid (repeat(auto-fill, minmax(76px,
1fr))), so swatches fill the row evenly instead of orphaning a lopsided
last row at arbitrary widths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Poll answer buttons referenced undefined CSS vars (--accent-cyan,
--accent-cyan-dim, --accent-cyan-border, --border-color) plus hardcoded
rgba()/#fff and raw rem font sizes, so they rendered unstyled on every
non-TDS theme (invisible borders, no selected/progress state).
Replace all colors with always-defined folds tokens (Primary.* for the
selected/indicator state, SurfaceVariant.* for the resting surface +
progress fill), size/spacing/radii with config.* tokens, and the
checkbox/radio glyphs + percentage/label text with folds <Text>. The
progress-bar-behind-text affordance is preserved (folds Button has no
equivalent), now theme-reactive. Merged the duplicate checkbox/radio
indicator spans into one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously a second incoming call was dropped from the UI entirely when the
user was already in a call (`!joined && callInfo`). Now, when joined to a
different call, a compact corner banner (caller avatar + name + Answer/Reject)
is shown instead of the full-screen IncomingCall overlay, with a single soft
ping (one-shot ringtone) rather than the looping ring so it doesn't talk over
the active call. The full overlay still shows when not in any call; being in
the ringing room's own call still shows nothing.
Built with folds primitives + TDS tokens (no hardcoded colors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a ringtoneId setting (classic | chime | soft | retro | none) so the
incoming-call ring is no longer hardcoded to call.ogg. The three synth
styles are generated in-browser via a new utils/ringtones.ts module
(mirroring the existing callSounds.ts WebAudio pattern), so no new binary
assets are bundled; 'classic' keeps the existing call.ogg clip and 'none'
is a silent, visual-only incoming-call UI.
- ringtones.ts: startRingtone() loops until stopped; previewRingtone()
plays a single non-looping preview and auto-cancels the prior preview.
- IncomingCall: ring driven by the setting; <audio> element removed.
- Settings > Calls: Ringtone selector with on-select preview, beside the
existing Ringtone Volume slider.
- settings.ts: persisted value whitelisted back to a known id.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
msgContent media load/thumbnail failures now log only the error name+message,
not the full error/event object that may carry content data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CallEmbed: 25s load watchdog that fails fast on iframe error / preparing-error /
timeout instead of hanging on a permanent spinner; additive onLoadError API,
cleared on ready/capabilities/joined.
- CallView: user-visible "call failed to load" overlay with Retry/Leave (folds +
tokens) via a new useCallLoadError hook.
- CallMemberCard: wrap the participant avatar in AvatarDecoration so decorations
render in the call roster (the tile rendered UserAvatar bare while member lists
already wrapped it).
Addresses LOTUS_BUGS item 3 (avatar decorations in calls) and EC iframe failure monitoring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- uploadContent: bounded retry (max 3) reusing rateLimitedActions' capped
exponential backoff; retries only transient failures (network/429/5xx), never 4xx.
- Robust MatrixError construction from UploadResponse / unknown error shapes.
- addRoomIdToMDirect/removeRoomIdFromMDirect: drop `as any`, use typed
EventType.Direct + MDirectContent.
- usePresenceUpdater: keep fetch({keepalive}) for the unload offline-presence
update (sendBeacon can't set the auth header) and log redacted warnings instead
of silently swallowing presence errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- RoomTimeline: wrap jump-to-latest/unread + mark-as-read handlers in useCallback
(the handlers passed to memoized message children were already memoized).
- RoomInput: wrap file/upload/emoji/sticker/location callbacks in useCallback so
the editor and toolbar don't re-render needlessly.
- EmojiBoard: hoist repeated mx.getRoom() pack-label lookups into a useMemo'd map
in the emoji and sticker sidebars (previously called per-render in map loops).
Behavior unchanged. (RoomTimeline/RoomInput already have ErrorBoundary wrappers
in RoomView, so no boundary added.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N69: @mention highlight color now uses HexColorPickerPopOut + react-colorful
HexColorPicker behind a folds Button (color swatch); built-in onRemove
replaces the separate Reset, dropping the OS-native <input type="color">
- N10: mentionPulseKeyframes animates only box-shadow (dropped the imperceptible
scale(1.003)) so it no longer fights MsgAppearClass over `transform` on
self-sent @mention messages
- N22: Direct.tsx virtualizer estimateSize 38 -> 52 (two-line DM row height) to
avoid the initial-render jump before measureElement corrects each row
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract a shared ReportCategorySelect: folds Button trigger + PopOut +
FocusTrap + Menu + MenuItem (escape + arrow-key nav, like OrderButton),
replacing the OS-styled native <select> in both ReportRoomModal and
ReportUserModal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selecting a join/leave sound auto-plays a preview, but nothing communicated
that. Add it to the SettingTile description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both rendered as <Box as="form" role="dialog"> with manually assembled
background/borderRadius(R400)/boxShadow. Switch to <Dialog as="form"
variant="Surface"> so the surface comes from the design system (R300 radius),
matching the other message-action dialogs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReportRoomModal/ReportUserModal rendered as <Box as="form" role="dialog">
with inline background/borderRadius(R400)/boxShadow. Switch both to
<Dialog as="form" variant="Surface"> so the surface (background, R300 radius,
shadow) comes from the design system, matching MessageReportItem and every
other message-action modal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N14 ForwardMessageDialog: add folds <Header> with title + close IconButton
(was closeable only by clicking outside)
- N20 Notification presets: bare <button> with undefined --border-interactive-
normal / --bg-surface-low vars -> folds <Button variant="Secondary" fill="Soft">
- N68 syntaxHighlight tokenStyle: use the theme-aware --prism-* variable family
(keyword/selector/boolean/atrule/comment) instead of TDS-only --lt-accent-*
vars with dark-only Monokai fallbacks; comment uses --prism-comment not opacity
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure formatting reflows (multi-line wrapping of long lines/imports/tables);
no behavior change. Clears the working tree of pending prettier diffs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The real reason the gallery didn't look or function like the Members drawer
or Saved Messages: it was a position:fixed overlay floating over the timeline,
mounted from RoomViewHeader. Now it docks into the room layout row exactly like
MembersDrawer.
- new mediaGalleryAtom (mirrors bookmarksPanelAtom) holds the open state
- RoomViewHeader toggles the atom instead of local useState and no longer
renders the panel
- Room.tsx renders <MediaGallery> as a flex sibling of the timeline with a
vertical Line separator on desktop and key={room.roomId} to reset per room
- MediaGallery.css: static width on desktop, position:fixed inset:0 full-screen
only on mobile (identical strategy to MembersDrawer.css); root Box shrink="No"
The panel now shares the row with the timeline instead of overlapping it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Media Gallery panel worked but didn't look like a first-party Cinny
drawer. Redesign the chrome to match MembersDrawer / Saved Messages and the
PolicyListViewer tab precedent:
- panel + header: Surface -> Background variant; header uses Text size="H4"
and a plain close IconButton (dropped the bespoke tooltip-wrapped button)
- tabs: moved into a bordered toolbar strip; adopt the repo's
variant={active?'Primary':'Secondary'} fill={active?'Solid':'Soft'} pattern
and show per-tab counts (Images (N) / Videos (N) / Files (N))
- month grouping: replaced the centered "lines + label" divider with a
left-aligned group label (the Cinny group-label pattern)
- thumbnail tiles: hover/focus border + caption overlay are now CSS-driven
(:hover / :focus-visible) instead of React state, and live in
MediaGallery.css.ts; grid + file rows tokenized
- caption overlay also reveals on keyboard focus (a11y)
All styling consolidated into MediaGallery.css.ts; no inline grid/tile styles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avatar decorations: useAvatarDecoration cached ALL profile-field fetch
failures as "no decoration" permanently for the session. The member list
and timeline mount many avatars at once, so one rate-limited (429) burst
would wipe everyone's decoration until a full reload. Now only a genuine
404 (field unset) is cached; transient errors retry on the next mount.
Saved Messages panel — full redesign to match the canonical MembersDrawer:
- co-located BookmarksPanel.css.ts: toRem(266) + max-width:750px full-screen
media query, replacing the old position:absolute/zIndex:100 mobile "modal"
that had no backdrop or escape
- variant="Background" header; room avatars on each item (was a generic hash)
- priority tokens replace all raw opacity hacks; 3px borderLeft accent removed
- Escape-to-close; multi-line preview is now a proper folds Button (N38)
Media Gallery (N12): moved fixed positioning + width into MediaGallery.css.ts
using toRem(320) + a full-screen media query; border/header use config tokens;
added Escape-to-close on the panel (previously only the lightbox handled it).
Presence (SettingsTab / useUserPresence):
- N16: wrap presence-dot trigger in TooltipProvider; replace undefined
--bg-surface with color.Background.Container
- N17: add escapeDeactivates + isKeyForward/isKeyBackward to the FocusTrap
- N19: align reader labels (usePresenceLabel) to the setter vocabulary
(Online/Idle/Offline) so a chosen status matches the tooltip others see
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four changes to match screenshare full-screen UX for camera feeds:
1. Fullscreen button always visible
CallControls.tsx: remove `screenshare &&` gate — the ⛶ fullscreen
button now appears in camera-only calls, not just during screenshare.
2. Per-participant camera focus (CallControl.focusCameraParticipant)
Finds the target's video tile in the EC iframe DOM via:
[data-testid="videoTile"] / [data-video-fit]
closest ancestor of [aria-label="${userId}"]
Enables spotlight mode if not already active, then clicks the tile
so EC's internal focus handler runs. Falls back gracefully if the
tile is not in the DOM (camera off).
3. MemberGlance participant popup
Clicking a participant avatar in the call status bar now shows a
small menu: "Focus camera" (calls focusCameraParticipant) and
"View profile" (existing behaviour). Previously it opened the
profile immediately with no way to focus the camera.
4. PiP fullscreen button
A ⛶/⊡ icon button appears in the PiP overlay top-right area,
letting users go fullscreen directly from PiP mode without
navigating back to the call room first.
UNTESTED — requires a real multi-participant call to verify tile
clicking behaviour and fullscreen transitions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>