Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 2eea8e6c18 🐛(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 15:06:03 +01:00
38 changed files with 74 additions and 557 deletions
-4
View File
@@ -27,9 +27,6 @@ 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
@@ -46,7 +43,6 @@ and this project adheres to
- 🐛(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|webp|ico|svg|woff|woff2|ttf|eot)$ {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|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|webp|ico|svg|woff|woff2|ttf|eot)$ {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|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|webp|ico|svg|woff|woff2|ttf|eot)$ {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
-3
View File
@@ -36,9 +36,6 @@ 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.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

@@ -7,11 +7,7 @@ import {
} from 'react-aria-components'
import { RiArrowDownSFill } from '@remixicon/react'
import { css, cx } from '@/styled-system/css'
import {
DIAGNOSTIC_OUTPUT_LANG,
getSummaryLang,
type ConnectionTestStepResult,
} from '../types'
import type { ConnectionTestStepResult } from '../types'
import { StepStatusIndicator } from './StepStatusIndicator'
/** Each step is its own bounded card, collapsed or not. */
@@ -69,7 +65,7 @@ const labelClass = css({
const valueClass = css({
fontFamily: 'mono',
textStyle: 'xs',
color: 'greyscale.600',
color: 'greyscale.500',
overflowWrap: 'anywhere',
})
@@ -114,11 +110,7 @@ const StepRowContent = ({ step }: { step: ConnectionTestStepResult }) => {
<>
<span className={identityClass}>
<span className={labelClass}>{t(`steps.${step.id}`)}</span>
{step.summary && (
<span className={valueClass} lang={getSummaryLang(step)}>
{step.summary}
</span>
)}
{step.summary && <span className={valueClass}>{step.summary}</span>}
</span>
<StepStatusIndicator
status={step.status}
@@ -178,7 +170,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} lang={DIAGNOSTIC_OUTPUT_LANG}>
<ul className={logListClass}>
{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.600' })
const emptyCounterClass = css({ color: 'greyscale.400' })
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.600' }),
pending: css({ color: 'greyscale.500' }),
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.600' }),
skipped: css({ color: 'greyscale.500' }),
}
@@ -136,16 +136,14 @@ const ConnectionTest = () => {
<h2 className={sectionTitleClass}>
{t(`groups.${group.id}`)}
</h2>
<ul className={rowsClass}>
<div className={rowsClass}>
{group.steps.map((id) => {
const step = stepsById.get(id)
return step ? (
<li key={id}>
<ConnectionTestStepRow step={step} />
</li>
<ConnectionTestStepRow key={id} step={step} />
) : null
})}
</ul>
</div>
</section>
))}
@@ -32,23 +32,6 @@ 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, Picture } from '@/primitives'
import { Button } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -30,32 +30,29 @@ const Body = styled('p', {
},
})
const image = css({
maxHeight: '362px',
height: '100%',
width: 'fit-content',
const Image = styled('img', {
base: {
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: '.5rem',
height: '.375rem',
margin: '0 .25rem',
width: '.5rem',
width: '.375rem',
},
variants: {
selected: {
true: {
backgroundColor: 'primary.800',
borderColor: 'primary.800',
},
false: {
backgroundColor: 'primary.100',
borderColor: 'primary.500',
backgroundColor: 'primary.300',
},
},
},
@@ -163,6 +160,7 @@ const carouselNavButton = css({
},
})
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
key: 'slide1',
@@ -248,12 +246,7 @@ export const IntroSlider = () => {
visible={index === slideIndex}
key={index}
>
<Picture
src={slide.src}
alt=""
role="presentation"
className={image}
/>
<Image src={slide.src} alt="" role="presentation" />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
@@ -1,10 +1,8 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react'
import { TrackLoop } from '@livekit/components-react'
import { TrackLoop, useVisualStableUpdate } 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
@@ -25,9 +23,8 @@ 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
* 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.
* To ensure visual stability when tiles are reordered due to track updates,
* the component uses the `useVisualStableUpdate` hook.
* @example
* ```tsx
* const tracks = useTracks([Track.Source.Camera]);
@@ -69,11 +66,7 @@ export function CarouselLayout({
setPrevTiles(tilesThatFit)
}
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,
})
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
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, tracks)
useSpeakerPromotionTrigger(pagination.tracks)
useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage,
@@ -1,23 +0,0 @@
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,65 +8,41 @@ import { useEffect, useReducer, useRef } from 'react'
* Tripwire for speaker promotion.
*
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
* 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.
* 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.
*
* 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).
* Speakers already visible are ignored. Everything else costs zero React work.
*
* 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.
* Requires the parent to NOT re-render on speaker events itself, i.e.
* `useTracks(..., { updateOnlyOn: [] })` upstream.
*/
export function useSpeakerPromotionTrigger(
/** 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 } = {}
sortedTiles: TrackReferenceOrPlaceholder[]
) {
const room = useRoomContext()
const [, forceRender] = useReducer((n: number) => n + 1, 0)
// Read by the listeners at event time, so they never re-subscribe.
const tilesRef = useRef({ visibleTiles, allTiles })
tilesRef.current = { visibleTiles, allTiles }
// Refs so the listener reads current values without re-subscribing
// and without itself being a render dependency.
const tilesRef = useRef(sortedTiles)
tilesRef.current = sortedTiles
useEffect(() => {
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)
const onActiveSpeakersChanged = (speakers: Participant[]) => {
const tiles = tilesRef.current
const hiddenSpeakerExists = speakers.some(
(speaker) =>
!tiles.some((t) => t.participant.identity === speaker.identity)
)
if (hidden) {
if (hiddenSpeakerExists) {
forceRender()
}
}
const onAttributesChanged = (changed: Record<string, string>) => {
if ('handRaisedAt' in changed) {
renderIfHidden(
tilesRef.current.allTiles
.map((t) => t.participant)
.filter((p) => !!p.attributes.handRaisedAt)
)
}
}
room.on(RoomEvent.ActiveSpeakersChanged, renderIfHidden)
if (raisedHands) {
room.on(RoomEvent.ParticipantAttributesChanged, onAttributesChanged)
}
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
return () => {
room.off(RoomEvent.ActiveSpeakersChanged, renderIfHidden)
room.off(RoomEvent.ParticipantAttributesChanged, onAttributesChanged)
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
}
}, [room, raisedHands])
}, [room])
}
@@ -1,201 +0,0 @@
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))
})
})
@@ -1,88 +0,0 @@
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, Picture, Text } from '@/primitives'
import { A, Div, H, 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"
>
<Picture
<img
src={imagePath}
alt=""
className={css({
@@ -1,4 +1,4 @@
import { Button, Div, Picture, Text } from '@/primitives'
import { Button, Div, H, 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"
>
<Picture
<img
src="/assets/intro-slider/4.png"
alt=""
className={css({
@@ -189,9 +189,9 @@ export const ScreenRecordingSidePanel = () => {
})}
/>
<VStack gap={0} marginBottom={15}>
<Text as="h2" variant="h1" margin="sm" fullWidth>
<H lvl={1} margin={'sm'} fullWidth>
{t('heading')}
</Text>
</H>
<LimitDescription
keyPrefix={'screenRecording'}
supportArticleLink={data?.support?.help_article_recording}
@@ -1,4 +1,4 @@
import { A, Button, Div, Picture, Text } from '@/primitives'
import { A, Button, Div, H, 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"
>
<Picture
<img
src="/assets/intro-slider/3.png"
alt=""
className={css({
@@ -203,9 +203,9 @@ export const TranscriptSidePanel = () => {
})}
/>
<VStack gap={0} marginBottom={15}>
<Text as="h2" variant="h1" margin="sm" fullWidth>
<H lvl={1} margin={'sm'}>
{t('heading')}
</Text>
</H>
<LimitDescription
keyPrefix={'transcript'}
supportArticleLink={data?.support?.help_article_transcript}
@@ -240,9 +240,6 @@ 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, Picture, Text } from '@/primitives'
import { H, LinkButton, 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>
<Picture
<img
src="/assets/intro-slider/4.png"
alt={''}
className={css({
@@ -102,93 +102,46 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Heading>
)}
<TabList border={false}>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.ACCOUNT}
aria-label={t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
>
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
<RiAccountCircleLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
</Tab>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.AUDIO}
aria-label={t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
>
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
<RiSpeakerLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
</Tab>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.VIDEO}
aria-label={t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
>
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
<RiVideoOnLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
</Tab>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.GENERAL}
aria-label={t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
>
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
<RiSettings3Line />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
</Tab>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.NOTIFICATIONS}
aria-label={t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
>
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
<RiNotification3Line />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab>
<Tab
icon
highlight
id={SettingsDialogExtendedKey.SHORTCUTS}
aria-label={t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
>
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
<RiKeyboardBoxLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
</Tab>
{isAdminOrOwner && (
<Tab
icon
highlight
id={SettingsDialogExtendedKey.TRANSCRIPTION}
aria-label={t(
`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`
)}
>
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon name="speech_to_text" />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
{showRecordingTab && (
<Tab
icon
highlight
id={SettingsDialogExtendedKey.RECORDING}
aria-label={t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
>
<Tab icon highlight id={SettingsDialogExtendedKey.RECORDING}>
<RiRecordCircleLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
</Tab>
)}
<Tab
icon
highlight
id={SettingsDialogExtendedKey.ACCESSIBILITY}
aria-label={t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
>
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
@@ -19,7 +19,6 @@ export const useLanguageLabels = () => {
key: lang,
value: lang,
label: languageLabels[lang],
lang,
}))
return {
languagesList,
-1
View File
@@ -492,7 +492,6 @@
"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,7 +492,6 @@
"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,7 +491,6 @@
"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,7 +492,6 @@
"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,7 +492,6 @@
"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,7 +492,6 @@
"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
@@ -1,30 +0,0 @@
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>
)
}
+2 -6
View File
@@ -95,7 +95,7 @@ export type SelectProps<T> = Omit<
> & {
iconComponent?: RemixiconComponentType
label: ReactNode
items: Array<{ value: T; label: ReactNode; lang?: string }>
items: Array<{ value: T; label: ReactNode }>
errors?: ReactNode
placement?: Placement
variant?: 'light' | 'dark'
@@ -150,11 +150,7 @@ export const Select = <T extends string | number>({
>
{({ isSelected }) => (
<>
{item.lang ? (
<span lang={item.lang}>{item.label}</span>
) : (
item.label
)}
{item.label}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
)}
-1
View File
@@ -23,7 +23,6 @@ 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,13 +6,6 @@ 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|webp|ico|svg|woff|woff2|ttf|eot)$ {
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}