Compare commits

...

14 Commits

Author SHA1 Message Date
lebaudantoine 06c4e277db ⚡️(frontend) serve illustrations as WebP with a PNG fallback
The three intro slider illustrations were the heaviest part of the
homepage. On a cold load of the production homepage (cache disabled),
the browser transferred 2.67 MB in total, of which 2.00 MB was these
three PNG files: 75% of the page weight, and about 3.5 times all the
JavaScript combined (579 kB).

HTTP compression cannot help here, PNG being already compressed. The
files are heavy because of the grain texture applied on the
illustrations, which a lossless format has to store pixel by pixel.

Encoded as WebP at quality 90, which preserves the grain, the three
slides go from 2.00 MB to 182 kB (-91%). The homepage cold load drops
from 2.67 MB to about 0.86 MB (-68%), and the illustrations now
account for about 21% of the transfer instead of 75%.

The fourth illustration, used on the recording screens, gets the same
treatment (721 kB to 84 kB).

We checked in PostHog that most of our users are on browser versions
supporting WebP. For the remaining ones, a new Picture primitive
renders a <picture> element with the WebP file as a source and the
original PNG as the <img> fallback, so nobody loses the illustrations.

Add webp to the nginx static assets location, otherwise the new files
would be served without the 30 days cache headers.
2026-11-14 14:10:51 +01:00
lebaudantoine b6b3ce28db ♿️(frontend) name settings tabs on small screens
Below 800px the settings tabs only render their icon, which left
each tab, and the tab panel it labels, without an accessible name.
Screen reader users could not tell which settings section they
were opening.

Set aria-label on each tab with its existing translated label.
On wide screens it matches the visible text, so nothing changes.

Fixes #1855
2026-11-14 01:05:10 +01:00
lebaudantoine f27f2fb7de ♿️(frontend) raise contrast of inactive intro slider dots
Inactive dots of the home intro slider were 1.57:1 on white, below
the 3:1 RGAA 3.3 asks for, so low-vision users could not tell how
many slides exist.

They are now hollow: a primary.100 fill with a primary.500 outline
at 4.22:1 on white. This passes without darkening the dots so much
that they blend with the active one. Dots grow from 6px to 8px to
keep the outline legible.

Fixes #1851
2026-11-14 01:05:10 +01:00
lebaudantoine 061025b928 ♿️(frontend) structure connection test cards as lists
The step cards of each connection test section are a series of
similar items but were rendered as plain divs, so assistive
technologies could not announce them as a list or give the item
count.

Render each series as a ul with one li per card. The heading and
accordion stay inside the li. No visual change.

Fixes #1903
2026-11-14 01:05:10 +01:00
lebaudantoine 100044eeaa ♿️(frontend) warn before language change in settings
Selecting a language in the settings modal translates the whole
interface immediately. Nothing announced this beforehand, so a
screen reader user could end up in a language they don't read,
with the dialog title already announced in it.

Add a visible description under the "Langue de l'application"
field, linked to the select trigger with aria-describedby, stating
that the change applies to the whole interface on selection.

This satisfies RGAA 7.4 (WCAG 3.2.2 On Input) without adding an
extra confirmation step.

Fixes #1859
2026-11-14 01:05:10 +01:00
lebaudantoine e158b4faaf ♿️(frontend) fix grey text contrast on the connection test page
Zero counters, pending and skipped labels and step summaries used
greys below the 4.5:1 minimum of RGAA 3.2. They now use greyscale.600,
which passes on white, hovered and expanded rows.

Fixes #1897
2026-11-14 01:05:10 +01:00
lebaudantoine 3a1f0a342d ♿️(frontend) mark the language of each option in language selects
Language names are listed in their own language ("English",
"Deutsch", "Nederlands"...), but nothing told assistive technologies
so. Screen readers pronounced them with the rules of the interface
language, which can make them hard to recognise.

Select items accept an optional `lang`, rendered on a span around
the label. useLanguageLabels sets it for every language it returns.

RGAA 8.7

Fixes #1861
2026-11-13 23:38:16 +01:00
lebaudantoine 62571cd9fc ♿️(frontend) declare a background color with the body text color
Text colors were set without a matching background on the document.
With custom browser or system colors, the user's background could
end up behind our text color and make it unreadable.

Set both the default background and text colors on body, so they
are always defined as a pair.

RGAA 10.5

Fixes #1863
2026-11-13 23:38:05 +01:00
lebaudantoine 8fccf46429 ♿️(frontend) give the transcript language button an explicit name
In the transcript panel, the button showing the meeting language
opens the transcription settings, but its accessible name was only
the language itself, e.g. "Français (fr)". Screen reader users had no
way to know it changes the meeting language.

Add an aria-label that keeps the language and states the action. The
visible label is unchanged. Translated in all six locales.

RGAA 7.1

Fixes #1868
2026-11-13 23:37:48 +01:00
lebaudantoine 9481350383 ♿️(frontend) use level 2 headings in the recording side panels
The side panel already exposes its title as a level 1 heading. The
transcript and screen recording panels rendered their intro line as a
second h1, so screen reader users navigating by headings could not
tell the panel title from its subtitle.

Render both intro headings as h2 while keeping the h1 visual style,
so the hierarchy is correct and nothing changes on screen.

RGAA 9.1

Fixes #1882
2026-11-13 23:29:39 +01:00
lebaudantoine e9a58d270b 🚸(frontend) bring raised hands into view in the carousel
A raised hand matters as much as someone speaking, but it went
unnoticed when the tile was scrolled out of the carousel. It is now
brought to the top like a new speaker and stays visible while raised.
2026-11-13 23:26:49 +01:00
lebaudantoine e3351075aa 🚸(frontend) bring new speakers to the top of the carousel
In the focus layout, when someone off screen started speaking, their
tile replaced whichever tile was evicted, often at the bottom of the
column. A screen share that started did the same. Users had to scan
the whole column to find who was talking, and the spot changed every
time.

New speakers now always appear at the top, right below the local
camera and the screen shares, and the other tiles slide down by one.
The most relevant tiles are where the eye already is, and the column
moves in a predictable way.

The ordering still relies on LiveKit's sort and stable update; only
the position of the incoming tile changes. This is the enhancement
#1805 was after.
2026-11-13 23:26:48 +01:00
lebaudantoine 68198e9961 🐛(frontend) promote the active speaker in the carousel layout
Regression: in the focus layout, a participant who started speaking
while their tile was outside the visible part of the carousel was no
longer brought into view.

Cause: 81f23aeb moved useTracks in StageLayout to `updateOnlyOn: []`
so the whole stage stops re-rendering on every ActiveSpeakersChanged.
useVisualStableUpdate only promotes speakers when its host renders, so
it needs something to wake it up. The grid layout got that with
useSpeakerPromotionTrigger in the same commit; the carousel did not,
and nothing else re-renders it on speaker events.

Fix: the carousel now uses the same tripwire. Unlike the grid, which
passes its current page, the carousel renders every tile in a scroll
container, so only the first maxVisibleTiles are passed as visible.

The tripwire also takes the full list of tiles the layout shows and
ignores speakers who have none. In the focus layout the pinned camera
is rendered by the focus view, not the carousel: without this, each
speaker event from that participant would force a render that cannot
promote anyone. The grid passes all its tracks, so its behaviour is
unchanged.

Known limit, not addressed here: when a single tile fits, it stays on
the local camera, which useVisualStableUpdate sorts first, so a remote
speaker cannot be promoted and the tripwire keeps firing for them.

This supersedes #1805, which will be closed. On top of this fix, it
pinned the active speaker to a fixed slot next to the local camera,
first with a 2s hold, a reserved slot and an eligibility check
(867a14d0), then simplified to the last remote speaker with no timer
(378e3d01). That is an enhancement rather than part of the fix, and it
is left out here to keep the regression fix small. It can come back in
a dedicated PR; it would also cover the single-tile case above.
2026-11-13 23:26:48 +01:00
lebaudantoine e3140e0300 🐛(frontend) apply the sans font token on every page
Since routes are lazy loaded, the LiveKit stylesheet only ships
with the room chunk. It was the only rule applying `--fonts-sans`
to the document, so pages outside a room fell back to Panda's
preflight system font stack, ignoring custom CSS themes (e.g.
Marianne in the DINUM image) and accessibility fonts.

Point Panda's `--global-font-body` at `--fonts-sans` so the
document font follows the token regardless of which chunks are
loaded.
2026-11-13 23:26:46 +01:00
38 changed files with 559 additions and 75 deletions
+5
View File
@@ -27,6 +27,9 @@ and this project adheres to
- 🔒️(frontend) send the display name in the join request body
- 🔥(backend) drop is_administrable from unregistered room payloads
- 🍱(frontend) add Portuguese glyphs to Marianne subsetted fonts
- 🚸(frontend) bring new speakers to the top of the carousel
- 🚸(frontend) bring raised hands into view in the carousel
- ⚡️(frontend) serve illustrations as WebP with a PNG fallback
### Fixed
@@ -42,6 +45,8 @@ and this project adheres to
- 🔒️(docker) move frontend to nginx/nginx-unprivileged image
- 🐛(frontend) accept meeting links with query params in join dialog
- 🐛(backend) allow muting when everyone_can_mute is stored as null #1942
- 🐛(frontend) apply the sans font token on every page
- 🐛(frontend) promote the active speaker in the carousel layout
## [1.37.0] - 2026-11-01
+1 -1
View File
@@ -33,7 +33,7 @@ server {
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|webp|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
+1 -1
View File
@@ -72,7 +72,7 @@ server {
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|webp|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
+1 -1
View File
@@ -11,7 +11,7 @@ server {
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|webp|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
+3
View File
@@ -36,6 +36,9 @@ const config: Config = {
jsxFramework: 'react',
outdir: 'src/styled-system',
globalFontface: {},
globalVars: {
'--global-font-body': 'var(--fonts-sans)',
},
theme: {
...pandaPreset.theme,
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

@@ -7,7 +7,11 @@ import {
} from 'react-aria-components'
import { RiArrowDownSFill } from '@remixicon/react'
import { css, cx } from '@/styled-system/css'
import type { ConnectionTestStepResult } from '../types'
import {
DIAGNOSTIC_OUTPUT_LANG,
getSummaryLang,
type ConnectionTestStepResult,
} from '../types'
import { StepStatusIndicator } from './StepStatusIndicator'
/** Each step is its own bounded card, collapsed or not. */
@@ -65,7 +69,7 @@ const labelClass = css({
const valueClass = css({
fontFamily: 'mono',
textStyle: 'xs',
color: 'greyscale.500',
color: 'greyscale.600',
overflowWrap: 'anywhere',
})
@@ -110,7 +114,11 @@ const StepRowContent = ({ step }: { step: ConnectionTestStepResult }) => {
<>
<span className={identityClass}>
<span className={labelClass}>{t(`steps.${step.id}`)}</span>
{step.summary && <span className={valueClass}>{step.summary}</span>}
{step.summary && (
<span className={valueClass} lang={getSummaryLang(step)}>
{step.summary}
</span>
)}
</span>
<StepStatusIndicator
status={step.status}
@@ -170,7 +178,7 @@ export const ConnectionTestStepRow = ({
{/* Collapsed panels stay in the DOM for aria-controls, but the log
lines themselves are only mounted when actually visible. */}
{isExpanded && (
<ul className={logListClass}>
<ul className={logListClass} lang={DIAGNOSTIC_OUTPUT_LANG}>
{step.logs?.map((log, index) => (
<li key={`${log.level}-${index}`} className={logItemClass}>
{log.message}
@@ -153,7 +153,7 @@ const counterValueClass = css({
})
/** A zero count is context, not a result: it recedes instead of shouting. */
const emptyCounterClass = css({ color: 'greyscale.400' })
const emptyCounterClass = css({ color: 'greyscale.600' })
const emptySquareClass = css({
backgroundColor: 'transparent!',
border: '1px solid {colors.greyscale.250}',
@@ -25,10 +25,10 @@ export const statusSquareClass: Record<ConnectionTestStepStatus, string> = {
* failure and warning.
*/
export const statusTextClass: Record<ConnectionTestStepStatus, string> = {
pending: css({ color: 'greyscale.500' }),
pending: css({ color: 'greyscale.600' }),
running: css({ color: 'greyscale.700' }),
success: css({ color: 'greyscale.1000' }),
warning: css({ color: 'warning', fontWeight: 'medium' }),
failed: css({ color: 'danger.600', fontWeight: 'medium' }),
skipped: css({ color: 'greyscale.500' }),
skipped: css({ color: 'greyscale.600' }),
}
@@ -136,14 +136,16 @@ const ConnectionTest = () => {
<h2 className={sectionTitleClass}>
{t(`groups.${group.id}`)}
</h2>
<div className={rowsClass}>
<ul className={rowsClass}>
{group.steps.map((id) => {
const step = stepsById.get(id)
return step ? (
<ConnectionTestStepRow key={id} step={step} />
<li key={id}>
<ConnectionTestStepRow step={step} />
</li>
) : null
})}
</div>
</ul>
</section>
))}
@@ -32,6 +32,23 @@ export type ConnectionTestStepResult = {
data?: Record<string, unknown>
}
export const DIAGNOSTIC_OUTPUT_LANG = 'en'
const LANGUAGE_NEUTRAL_STEPS: ReadonlySet<ConnectionTestStepId> = new Set([
'browser',
'microphone',
'camera',
'devices',
])
export const getSummaryLang = ({
id,
status,
}: Pick<ConnectionTestStepResult, 'id' | 'status'>) =>
LANGUAGE_NEUTRAL_STEPS.has(id) && status === 'success'
? undefined
: DIAGNOSTIC_OUTPUT_LANG
export type ConnectionTestGroupId = 'local' | 'network'
/** Display order: everything local first, then everything that leaves the machine. */
@@ -1,6 +1,6 @@
import { styled } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import { Button, Picture } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -30,29 +30,32 @@ const Body = styled('p', {
},
})
const Image = styled('img', {
base: {
maxHeight: '362px',
height: '100%',
width: 'fit-content',
},
const image = css({
maxHeight: '362px',
height: '100%',
width: 'fit-content',
})
const Dot = styled('div', {
base: {
borderRadius: '50%',
borderStyle: 'solid',
borderWidth: '1px',
boxSizing: 'border-box',
display: 'inline-block',
height: '.375rem',
height: '.5rem',
margin: '0 .25rem',
width: '.375rem',
width: '.5rem',
},
variants: {
selected: {
true: {
backgroundColor: 'primary.800',
borderColor: 'primary.800',
},
false: {
backgroundColor: 'primary.300',
backgroundColor: 'primary.100',
borderColor: 'primary.500',
},
},
},
@@ -160,7 +163,6 @@ const carouselNavButton = css({
},
})
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
key: 'slide1',
@@ -246,7 +248,12 @@ export const IntroSlider = () => {
visible={index === slideIndex}
key={index}
>
<Image src={slide.src} alt="" role="presentation" />
<Picture
src={slide.src}
alt=""
role="presentation"
className={image}
/>
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
@@ -1,8 +1,10 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react'
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
import { TrackLoop } from '@livekit/components-react'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useCarouselTiles } from '../hooks/useCarouselTiles'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
const MIN_HEIGHT = 130
const MIN_WIDTH = 140
@@ -23,8 +25,9 @@ export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaEleme
* The `CarouselLayout` component displays a list of tracks in a scroll container.
* It will display as many tiles as possible and overflow the rest.
* @remarks
* To ensure visual stability when tiles are reordered due to track updates,
* the component uses the `useVisualStableUpdate` hook.
* Tiles are ordered by `useCarouselTiles`: screen shares, new speakers and
* raised hands enter at the top, right below the local camera, and the other
* tiles keep their order.
* @example
* ```tsx
* const tracks = useTracks([Track.Source.Camera]);
@@ -66,7 +69,11 @@ export function CarouselLayout({
setPrevTiles(tilesThatFit)
}
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
const sortedTiles = useCarouselTiles(tracks, maxVisibleTiles)
// The carousel scrolls, so only the first tiles are guaranteed on screen.
useSpeakerPromotionTrigger(sortedTiles.slice(0, maxVisibleTiles), tracks, {
raisedHands: true,
})
React.useLayoutEffect(() => {
if (asideEl.current) {
@@ -46,7 +46,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
const { layout } = useGridLayout(gridEl, tracks.length)
const pagination = usePagination(layout.maxTiles, tracks)
useSpeakerPromotionTrigger(pagination.tracks)
useSpeakerPromotionTrigger(pagination.tracks, tracks)
useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage,
@@ -0,0 +1,23 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useRef } from 'react'
import { orderCarouselTiles } from '../utils/orderCarouselTiles'
/**
* Carousel counterpart of LiveKit's `useVisualStableUpdate`: same sort, same
* stable update, but screen shares and new speakers end up at the top. See
* `orderCarouselTiles`.
*
* Like `useVisualStableUpdate`, it only reorders when its host renders.
*/
export function useCarouselTiles(
tracks: TrackReferenceOrPlaceholder[],
maxVisibleTiles: number
) {
const previous = useRef<TrackReferenceOrPlaceholder[]>([])
previous.current = orderCarouselTiles(
previous.current,
tracks,
maxVisibleTiles
)
return previous.current
}
@@ -8,41 +8,65 @@ import { useEffect, useReducer, useRef } from 'react'
* Tripwire for speaker promotion.
*
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
* re-render of the host component only when an active speaker has none of
* their tiles are within the visible span (`maxVisibleTiles`). That render
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
* state and performs the actual swap.
* re-render of the host when a speaker has a tile in `allTiles` but none in
* `visibleTiles`. That render re-runs the host's tile ordering, which reads
* live `participant.isSpeaking` state and performs the actual swap.
*
* Speakers already visible are ignored. Everything else costs zero React work.
* Speakers already visible cost nothing, nor do speakers the layout doesn't
* show at all (e.g. the pinned participant, rendered by the focus view).
*
* Requires the parent to NOT re-render on speaker events itself, i.e.
* With `raisedHands`, it also trips when a hand goes up or down while a
* raised hand is hidden: the hand that just went up, or the next one waiting
* for the slot of a hand that went down. Only for hosts whose ordering
* promotes raised hands; others would render for nothing.
*
* Requires the parent to NOT re-render on these events itself, i.e.
* `useTracks(..., { updateOnlyOn: [] })` upstream.
*/
export function useSpeakerPromotionTrigger(
sortedTiles: TrackReferenceOrPlaceholder[]
/** Tiles inside the span the layout guarantees to speakers. */
visibleTiles: TrackReferenceOrPlaceholder[],
/** Every tile the layout shows, visible or not. */
allTiles: TrackReferenceOrPlaceholder[],
{ raisedHands = false }: { raisedHands?: boolean } = {}
) {
const room = useRoomContext()
const [, forceRender] = useReducer((n: number) => n + 1, 0)
// Refs so the listener reads current values without re-subscribing
// and without itself being a render dependency.
const tilesRef = useRef(sortedTiles)
tilesRef.current = sortedTiles
// Read by the listeners at event time, so they never re-subscribe.
const tilesRef = useRef({ visibleTiles, allTiles })
tilesRef.current = { visibleTiles, allTiles }
useEffect(() => {
const onActiveSpeakersChanged = (speakers: Participant[]) => {
const tiles = tilesRef.current
const hiddenSpeakerExists = speakers.some(
(speaker) =>
!tiles.some((t) => t.participant.identity === speaker.identity)
const renderIfHidden = (participants: Participant[]) => {
const { visibleTiles, allTiles } = tilesRef.current
const visible = new Set(visibleTiles.map((t) => t.participant.identity))
const shown = new Set(allTiles.map((t) => t.participant.identity))
const hidden = participants.some(
({ identity }) => shown.has(identity) && !visible.has(identity)
)
if (hiddenSpeakerExists) {
if (hidden) {
forceRender()
}
}
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
return () => {
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
const onAttributesChanged = (changed: Record<string, string>) => {
if ('handRaisedAt' in changed) {
renderIfHidden(
tilesRef.current.allTiles
.map((t) => t.participant)
.filter((p) => !!p.attributes.handRaisedAt)
)
}
}
}, [room])
room.on(RoomEvent.ActiveSpeakersChanged, renderIfHidden)
if (raisedHands) {
room.on(RoomEvent.ParticipantAttributesChanged, onAttributesChanged)
}
return () => {
room.off(RoomEvent.ActiveSpeakersChanged, renderIfHidden)
room.off(RoomEvent.ParticipantAttributesChanged, onAttributesChanged)
}
}, [room, raisedHands])
}
@@ -0,0 +1,201 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { Track } from 'livekit-client'
import { describe, expect, it } from 'vitest'
import { orderCarouselTiles } from './orderCarouselTiles'
type Tile = TrackReferenceOrPlaceholder
// Placeholder tiles are enough: the real LiveKit sort and update run on them.
const tile = (
identity: string,
joinedAt: number,
{
source = Track.Source.Camera,
isLocal = false,
isSpeaking = false,
hasSpoken = isSpeaking,
handRaisedAt,
}: {
source?: Track.Source
isLocal?: boolean
isSpeaking?: boolean
hasSpoken?: boolean
handRaisedAt?: string
} = {}
) =>
({
participant: {
identity,
isLocal,
isSpeaking,
audioLevel: isSpeaking ? 1 : 0,
lastSpokeAt: hasSpoken ? new Date(joinedAt) : undefined,
joinedAt: new Date(joinedAt),
isCameraEnabled: false,
attributes: handRaisedAt ? { handRaisedAt } : {},
},
source,
}) as unknown as Tile
const names = (tiles: Tile[]) =>
tiles.map((t) =>
t.source === Track.Source.ScreenShare
? `${t.participant.identity}:screen`
: t.participant.identity
)
const me = tile('me', 0, { isLocal: true })
const [a, b, c, d, e] = ['a', 'b', 'c', 'd', 'e'].map((id, i) =>
tile(id, i + 1)
)
const speaking = (t: Tile) =>
tile(t.participant.identity, t.participant.joinedAt!.getTime(), {
isSpeaking: true,
})
const quietAgain = (t: Tile) =>
tile(t.participant.identity, t.participant.joinedAt!.getTime(), {
hasSpoken: true,
})
const handUp = (t: Tile, minute = 0) =>
tile(t.participant.identity, t.participant.joinedAt!.getTime(), {
isLocal: t.participant.isLocal,
handRaisedAt: new Date(Date.UTC(2026, 0, 1, 10, minute)).toISOString(),
})
const screenOf = (t: Tile) =>
tile(t.participant.identity, t.participant.joinedAt!.getTime(), {
source: Track.Source.ScreenShare,
})
describe('orderCarouselTiles', () => {
it('follows the LiveKit sort on the first render', () => {
expect(names(orderCarouselTiles([], [d, c, b, a, me], 3))).toEqual([
'me',
'a',
'b',
'c',
'd',
])
})
it('puts a new speaker right below the local camera', () => {
const previous = orderCarouselTiles([], [me, a, b, c, d, e], 4)
const next = orderCarouselTiles(previous, [me, a, b, c, d, speaking(e)], 4)
// LiveKit alone would give me, a, b, e: `e` takes the slot of `c`.
expect(names(next).slice(0, 4)).toEqual(['me', 'e', 'a', 'b'])
})
it('keeps the order of the tiles that stay visible', () => {
let tiles = orderCarouselTiles([], [me, a, b, c, d, e], 4)
tiles = orderCarouselTiles(tiles, [me, a, b, c, speaking(d), e], 4)
expect(names(tiles).slice(0, 4)).toEqual(['me', 'd', 'a', 'b'])
// `d` went quiet, `e` speaks: `d` stays ahead of `a`, `b` is evicted.
tiles = orderCarouselTiles(
tiles,
[me, a, b, c, quietAgain(d), speaking(e)],
4
)
expect(names(tiles).slice(0, 4)).toEqual(['me', 'e', 'd', 'a'])
})
it('does not move a visible participant who speaks', () => {
const previous = orderCarouselTiles([], [me, a, b, c, d], 4)
const next = orderCarouselTiles(previous, [me, a, b, speaking(c), d], 4)
expect(names(next)).toEqual(['me', 'a', 'b', 'c', 'd'])
})
it('keeps screen shares above a new speaker', () => {
const previous = orderCarouselTiles([], [me, screenOf(a), a, b, c, d], 4)
expect(names(previous).slice(0, 4)).toEqual(['me', 'a:screen', 'a', 'b'])
const next = orderCarouselTiles(
previous,
[me, screenOf(a), a, b, c, speaking(d)],
4
)
expect(names(next).slice(0, 4)).toEqual(['me', 'a:screen', 'd', 'a'])
})
it('puts a screen share that starts below the local camera', () => {
const previous = orderCarouselTiles([], [me, a, b, c, d], 4)
const next = orderCarouselTiles(previous, [me, a, b, c, d, screenOf(d)], 4)
expect(names(next).slice(0, 4)).toEqual(['me', 'd:screen', 'a', 'b'])
})
it('fills the slot of a participant who left without reordering', () => {
const previous = orderCarouselTiles([], [me, a, b, c, d], 4)
const next = orderCarouselTiles(previous, [me, b, c, d], 4)
expect(names(next)).toEqual(['me', 'd', 'b', 'c'])
})
it('brings a raised hand to the top like a speaker', () => {
const previous = orderCarouselTiles([], [me, a, b, c, d, e], 4)
const next = orderCarouselTiles(previous, [me, a, b, c, d, handUp(e)], 4)
expect(names(next).slice(0, 4)).toEqual(['me', 'e', 'a', 'b'])
})
it('keeps a raised hand visible when others speak', () => {
let tiles = orderCarouselTiles([], [me, a, b, c, d, e], 3)
tiles = orderCarouselTiles(tiles, [me, a, b, c, d, handUp(e)], 3)
expect(names(tiles).slice(0, 3)).toEqual(['me', 'e', 'a'])
// `d` speaks: it evicts `a`, not the raised hand.
tiles = orderCarouselTiles(tiles, [me, a, b, c, speaking(d), handUp(e)], 3)
expect(names(tiles).slice(0, 3)).toEqual(['me', 'd', 'e'])
})
it('lets the current speaker in when hands fill the carousel', () => {
let tiles = orderCarouselTiles([], [me, handUp(a, 1), handUp(b, 2), c], 3)
expect(names(tiles).slice(0, 3)).toEqual(['me', 'a', 'b'])
// The last hand raised gives way to the speaker.
tiles = orderCarouselTiles(
tiles,
[me, handUp(a, 1), handUp(b, 2), speaking(c)],
3
)
expect(names(tiles).slice(0, 3)).toEqual(['me', 'c', 'a'])
})
it('gives the slot of a lowered hand to the next one waiting', () => {
let tiles = orderCarouselTiles(
[],
[me, handUp(a, 1), handUp(b, 2), handUp(c, 3)],
3
)
expect(names(tiles).slice(0, 3)).toEqual(['me', 'a', 'b'])
// `a` lowers their hand: `c` was waiting and takes over.
tiles = orderCarouselTiles(tiles, [me, a, handUp(b, 2), handUp(c, 3)], 3)
expect(names(tiles).slice(0, 3)).toEqual(['me', 'c', 'b'])
})
it('shows the first hands raised when there are too many', () => {
const tiles = orderCarouselTiles(
[],
[me, a, handUp(b, 3), handUp(c, 1), handUp(d, 2)],
3
)
expect(names(tiles).slice(0, 3)).toEqual(['me', 'c', 'd'])
})
it('keeps the local camera first when the local hand is raised', () => {
const tiles = orderCarouselTiles([], [a, screenOf(a), b, handUp(me)], 3)
expect(names(tiles)).toEqual(['me', 'a:screen', 'a', 'b'])
})
it('is stable when run twice on the same input', () => {
const tracks = [me, a, b, c, d, speaking(e)]
const previous = orderCarouselTiles([], [me, a, b, c, d, e], 4)
const once = orderCarouselTiles(previous, tracks, 4)
expect(names(orderCarouselTiles(once, tracks, 4))).toEqual(names(once))
})
})
@@ -0,0 +1,88 @@
import {
log,
sortTrackReferences,
updatePages,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track } from 'livekit-client'
// LiveKit's track reference id changes when a placeholder gets its track;
// this key doesn't, so turning a camera on is not seen as a new tile.
const keyOf = (tile: TrackReferenceOrPlaceholder) =>
`${tile.participant.identity}_${tile.source}`
/** When the participant raised their hand, if it is still raised. */
const handRaisedAt = (tile: TrackReferenceOrPlaceholder) => {
if (tile.source !== Track.Source.Camera) return undefined
const time = Date.parse(tile.participant.attributes.handRaisedAt ?? '')
return Number.isNaN(time) ? undefined : time
}
/**
* LiveKit's sort knows nothing about raised hands. This moves them right
* after the tiles that must stay ahead (local camera, screen shares, whoever
* is speaking now), first raised first, so they are among the first tiles
* picked as visible for as long as the hand is up.
*/
const sortWithRaisedHands = (tracks: TrackReferenceOrPlaceholder[]) => {
const rank = (tile: TrackReferenceOrPlaceholder) => {
const { isLocal, isSpeaking } = tile.participant
if (isLocal || isSpeaking || tile.source === Track.Source.ScreenShare) {
return 0
}
return handRaisedAt(tile) === undefined ? 2 : 1
}
return sortTrackReferences(tracks).sort((a, b) => {
if (rank(a) !== rank(b)) return rank(a) - rank(b)
// Equal ranks keep LiveKit's order, except raised hands: first come first.
return rank(a) === 1 ? handRaisedAt(a)! - handRaisedAt(b)! : 0
})
}
/**
* Orders the carousel tiles; the first `maxVisibleTiles` are the visible ones.
*
* This is what LiveKit's `useVisualStableUpdate` does, plus one step:
* 1. `sortTrackReferences` decides which tiles deserve to be visible, with
* raised hands added to it, see `sortWithRaisedHands`;
* 2. `updatePages` applies that to the `previous` order with as few moves as
* possible: an incoming tile is swapped with the outgoing one;
* 3. added here: within the visible tiles, the local camera goes first, then
* the screen shares, then the speakers and raised hands that step 2 just
* brought in. The other tiles keep the order step 2 gave them.
*
* Step 2 alone leaves an incoming tile wherever the evicted one happened to
* be, often at the bottom of the column.
*/
export function orderCarouselTiles(
previous: TrackReferenceOrPlaceholder[],
tracks: TrackReferenceOrPlaceholder[],
maxVisibleTiles: number
): TrackReferenceOrPlaceholder[] {
const sorted = sortWithRaisedHands(tracks)
let stable = sorted
try {
stable = updatePages(previous, sorted, maxVisibleTiles)
} catch (error) {
log.error('Error while running updatePages(): ', error)
}
const previouslyVisible = new Set(
previous.slice(0, maxVisibleTiles).map(keyOf)
)
const rank = (tile: TrackReferenceOrPlaceholder) => {
if (tile.source === Track.Source.ScreenShare) return 1
if (tile.participant.isLocal) return 0
const justCameIn = !previouslyVisible.has(keyOf(tile))
const wantsAttention =
tile.participant.isSpeaking || handRaisedAt(tile) !== undefined
return justCameIn && wantsAttention ? 2 : 3
}
return [
// Array.prototype.sort is stable: tiles of equal rank keep their order.
...stable.slice(0, maxVisibleTiles).sort((a, b) => rank(a) - rank(b)),
...stable.slice(maxVisibleTiles),
]
}
@@ -1,4 +1,4 @@
import { A, Div, H, Text } from '@/primitives'
import { A, Div, H, Picture, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { LoginPrompt } from './LoginPrompt'
@@ -45,7 +45,7 @@ export const NoAccessView = ({
flexDirection="column"
alignItems="center"
>
<img
<Picture
src={imagePath}
alt=""
className={css({
@@ -1,4 +1,4 @@
import { Button, Div, H, Text } from '@/primitives'
import { Button, Div, Picture, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
@@ -169,7 +169,7 @@ export const ScreenRecordingSidePanel = () => {
flexDirection="column"
alignItems="center"
>
<img
<Picture
src="/assets/intro-slider/4.png"
alt=""
className={css({
@@ -189,9 +189,9 @@ export const ScreenRecordingSidePanel = () => {
})}
/>
<VStack gap={0} marginBottom={15}>
<H lvl={1} margin={'sm'} fullWidth>
<Text as="h2" variant="h1" margin="sm" fullWidth>
{t('heading')}
</H>
</Text>
<LimitDescription
keyPrefix={'screenRecording'}
supportArticleLink={data?.support?.help_article_recording}
@@ -1,4 +1,4 @@
import { A, Button, Div, H, Text } from '@/primitives'
import { A, Button, Div, Picture, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
@@ -183,7 +183,7 @@ export const TranscriptSidePanel = () => {
flexDirection="column"
alignItems="center"
>
<img
<Picture
src="/assets/intro-slider/3.png"
alt=""
className={css({
@@ -203,9 +203,9 @@ export const TranscriptSidePanel = () => {
})}
/>
<VStack gap={0} marginBottom={15}>
<H lvl={1} margin={'sm'}>
<Text as="h2" variant="h1" margin="sm" fullWidth>
{t('heading')}
</H>
</Text>
<LimitDescription
keyPrefix={'transcript'}
supportArticleLink={data?.support?.help_article_transcript}
@@ -240,6 +240,9 @@ export const TranscriptSidePanel = () => {
<Button
variant="text"
size="xs"
aria-label={t('details.languageAriaLabel', {
language: selectedLanguageLabel,
})}
onPress={() =>
openSettingsDialog(SettingsDialogExtendedKey.TRANSCRIPTION)
}
@@ -9,7 +9,7 @@ import { mediaUrl } from '@/api/mediaUrl'
import { useUser } from '@/features/auth/api/useUser'
import { UserAware } from '@/features/auth/components/UserAware'
import { Screen } from '@/layout/Screen'
import { H, LinkButton, Text } from '@/primitives'
import { H, LinkButton, Picture, Text } from '@/primitives'
import { formatDate } from '@/utils/formatDate'
import { ErrorScreen } from '@/components/ErrorScreen'
import { LoadingScreen } from '@/components/LoadingScreen'
@@ -118,7 +118,7 @@ const RecordingDownload = () => {
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<img
<Picture
src="/assets/intro-slider/4.png"
alt={''}
className={css({
@@ -102,46 +102,93 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Heading>
)}
<TabList border={false}>
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.ACCOUNT}
aria-label={t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
>
<RiAccountCircleLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.AUDIO}
aria-label={t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
>
<RiSpeakerLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.VIDEO}
aria-label={t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
>
<RiVideoOnLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.GENERAL}
aria-label={t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
>
<RiSettings3Line />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.NOTIFICATIONS}
aria-label={t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
>
<RiNotification3Line />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.SHORTCUTS}
aria-label={t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
>
<RiKeyboardBoxLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.TRANSCRIPTION}
aria-label={t(
`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`
)}
>
<Icon name="speech_to_text" />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
{showRecordingTab && (
<Tab icon highlight id={SettingsDialogExtendedKey.RECORDING}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.RECORDING}
aria-label={t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
>
<RiRecordCircleLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
</Tab>
)}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.ACCESSIBILITY}
aria-label={t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
>
<RiEyeLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
@@ -19,6 +19,7 @@ export const useLanguageLabels = () => {
key: lang,
value: lang,
label: languageLabels[lang],
lang,
}))
return {
languagesList,
+1
View File
@@ -492,6 +492,7 @@
"destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:",
"languageAriaLabel": "{{language}}, Meeting-Sprache ändern",
"recording": "Auch eine Videoaufzeichnung starten"
},
"button": {
+1
View File
@@ -492,6 +492,7 @@
"destination": "A new document will be created on",
"destinationUnknown": "A new document will be created",
"language": "Meeting language:",
"languageAriaLabel": "{{language}}, change the meeting language",
"recording": "Also start a video recording"
},
"button": {
+1
View File
@@ -491,6 +491,7 @@
"destination": "Se creará un nuevo documento en",
"destinationUnknown": "Se creará un nuevo documento",
"language": "Idioma de la reunión:",
"languageAriaLabel": "{{language}}, cambiar el idioma de la reunión",
"recording": "Iniciar también una grabación de vídeo"
},
"button": {
+1
View File
@@ -492,6 +492,7 @@
"destination": "Un nouveau document sera créé sur",
"destinationUnknown": "Un nouveau document sera créé",
"language": "Langue de la réunion :",
"languageAriaLabel": "{{language}}, modifier la langue de la réunion",
"recording": "Démarrer aussi un enregistrement vidéo"
},
"button": {
+1
View File
@@ -492,6 +492,7 @@
"destination": "Er wordt een nieuw document aangemaakt op",
"destinationUnknown": "Een nieuw document wordt aangemaakt",
"language": "Vergadertalen:",
"languageAriaLabel": "{{language}}, vergadertaal wijzigen",
"recording": "Start ook een video-opname"
},
"button": {
+1
View File
@@ -492,6 +492,7 @@
"destination": "Um novo documento será criado em",
"destinationUnknown": "Um novo documento será criado",
"language": "Idioma da reunião:",
"languageAriaLabel": "{{language}}, alterar o idioma da reunião",
"recording": "Também iniciar uma gravação"
},
"button": {
+30
View File
@@ -0,0 +1,30 @@
import { type ComponentPropsWithoutRef } from 'react'
import { css } from '@/styled-system/css'
const WEBP_CONVERTIBLE_EXTENSION = /\.(png|jpe?g)$/i
export type PictureProps = Omit<ComponentPropsWithoutRef<'img'>, 'src'> & {
src: string
}
/**
* Serves the WebP version of an image, and falls back on the original file
* for browsers that do not support WebP.
*
* The `<picture>` wrapper uses `display: contents`, so the `<img>` is laid out
* exactly as if it was rendered alone: style it through the usual props.
*/
export const Picture = ({ src, alt, ...props }: PictureProps) => {
const hasWebpVersion = WEBP_CONVERTIBLE_EXTENSION.test(src)
return (
<picture className={css({ display: 'contents' })}>
{hasWebpVersion && (
<source
srcSet={src.replace(WEBP_CONVERTIBLE_EXTENSION, '.webp')}
type="image/webp"
/>
)}
<img src={src} alt={alt} {...props} />
</picture>
)
}
+6 -2
View File
@@ -95,7 +95,7 @@ export type SelectProps<T> = Omit<
> & {
iconComponent?: RemixiconComponentType
label: ReactNode
items: Array<{ value: T; label: ReactNode }>
items: Array<{ value: T; label: ReactNode; lang?: string }>
errors?: ReactNode
placement?: Placement
variant?: 'light' | 'dark'
@@ -150,7 +150,11 @@ export const Select = <T extends string | number>({
>
{({ isSelected }) => (
<>
{item.label}
{item.lang ? (
<span lang={item.lang}>{item.label}</span>
) : (
item.label
)}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
)}
+1
View File
@@ -23,6 +23,7 @@ export { Link } from './Link'
export { Menu } from './Menu'
export { MenuList } from './MenuList'
export { P } from './P'
export { Picture } from './Picture'
export { Popover } from './Popover'
export { ScreenReaderAnnouncer } from './ScreenReaderAnnouncer'
export { Text } from './Text'
+7
View File
@@ -6,6 +6,13 @@ body,
height: 100%;
}
/* RGAA 10.5: every text color must be paired with a declared background
color, so custom browser or system colors cannot make text unreadable. */
body {
background-color: var(--colors-default-bg);
color: var(--colors-default-text);
}
:root {
--avatar-cap-height: 0.7;
}
@@ -80,7 +80,7 @@ data:
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|webp|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}