Compare commits

..

4 Commits

Author SHA1 Message Date
lebaudantoine e7d47afb49 🚸(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 19:23:23 +01:00
lebaudantoine c80e9ef3ef 🚸(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 18:43:20 +01:00
lebaudantoine e80dfc3305 🐛(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 18:10:28 +01:00
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
20 changed files with 30 additions and 135 deletions
-2
View File
@@ -61,8 +61,6 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
⭐ **Find La Suite Meet useful?** Click the Star button at the top right of this page. It helps other people and public organisations discover the project.
### 🚀 Major roll out to all French public servants
On the 29th of January 2026, Prime Minister Sébastien Lecornu, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in English](https://www.nytimes.com/2026/01/29/world/europe/france-zoom-alternative-visio.html))
-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
@@ -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. */
@@ -41,23 +41,18 @@ const Image = styled('img', {
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',
},
},
},
@@ -1,4 +1,4 @@
import { Button, Div, Text } from '@/primitives'
import { Button, Div, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
@@ -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, 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'
@@ -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)
}
@@ -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": {
+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>
)}
-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;
}