Compare commits

...

2 Commits

Author SHA1 Message Date
lebaudantoine d46cf43c7d 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
The previous implementation centered initials by measuring `<text>`
with `getBBox()`, which returns the font's advance-width by
ascent-to-descent band, not the ink of the glyphs. On fonts with an
asymmetric band, initials rendered off-center. Marianne is a
particularly clear case: ascent 1131 / descent 256 puts the band
center 87.5/1000 above the caps' optical center, so every avatar
sat ~4.6 viewBox units (~1.5–1.8 px) too low. A follow-up rewrite
using canvas `actualBoundingBox*` metrics fixed it but pulled in a
runtime measurement rig (shared canvas, ref, state, layout effect,
`fonts.load` + `loadingdone`, plus combining-mark stripping) just
to place two uppercase letters.

Since initials are always uppercased, optical centering has a
closed form: `baseline = center + capHeight/2`. Real-world text
fonts have cap heights in a narrow ~0.66–0.73 em band (Marianne is
0.70), so:

    translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))

is exact for the stock font and within ~0.4 px for any plausible
replacement. No JS, SSR-safe, no first-paint jump, no font-loading
race. Accents float above the cap box instead of dragging the
letter down.

The single font-dependent number remaining (cap height) is exposed
as a CSS variable, so self-hosters overriding the font can override
it next to the font itself, or leave the default. Once
`text-box: trim-both cap alphabetic` ships broadly, even the
variable can go.
2026-09-05 01:44:27 +02:00
lebaudantoine 78ba03a52b 🐛(frontend) restore automatic lower-hand on speaking
Following the re-rendering optimization refactoring, the automatic
lower-hand feature broke: the way `isSpeaking` was read no longer
made sense once we limited how often components in the app
re-render.

Fix the detection so the raised hand is again lowered automatically
when the participant starts speaking, without relying on frequent
re-renders.
2026-09-05 00:29:50 +02:00
7 changed files with 42 additions and 48 deletions
+2
View File
@@ -22,6 +22,8 @@ and this project adheres to
- 🐛(backend) allow any printable ASCII characters in user sub field #1673 - 🐛(backend) allow any printable ASCII characters in user sub field #1673
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667 - 🐛(frontend) keep the sending resolution picked while the camera is off #1667
- 🐛(frontend) restore automatic lower-hand on speaking
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
## [1.30.0] - 2026-09-01 ## [1.30.0] - 2026-09-01
+1
View File
@@ -1,5 +1,6 @@
:root { :root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif; --fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
} }
.Header-beforeLogo { .Header-beforeLogo {
+1
View File
@@ -34,6 +34,7 @@ Let's say you want to change the font of our application to a custom font. You c
:root { :root {
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif; --fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
} }
``` ```
+6 -42
View File
@@ -1,5 +1,5 @@
import { css, cva, RecipeVariantProps } from '@/styled-system/css' import { css, cva, RecipeVariantProps } from '@/styled-system/css'
import React, { useLayoutEffect, useMemo } from 'react' import React, { useMemo } from 'react'
const avatar = cva({ const avatar = cva({
base: { base: {
@@ -28,24 +28,17 @@ const avatar = cva({
}, },
}) })
// Instantiating a segmenter is expensive; create it once and reuse it.
const graphemeSegmenter = const graphemeSegmenter =
typeof Intl !== 'undefined' && 'Segmenter' in Intl typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: undefined : undefined
/**
* Returns the first user-perceived character. Some Unicode characters span
* multiple UTF-16 code units, so a naive index into the string can split them
* and yield a broken glyph.
*/
const getFirstGrapheme = (value: string): string => { const getFirstGrapheme = (value: string): string => {
if (!value) return '' if (!value) return ''
if (graphemeSegmenter) { if (graphemeSegmenter) {
const [first] = graphemeSegmenter.segment(value) const [first] = graphemeSegmenter.segment(value)
return first?.segment ?? '' return first?.segment ?? ''
} }
// Fallback: keeps single code points intact (including surrogate pairs).
return Array.from(value)[0] ?? '' return Array.from(value)[0] ?? ''
} }
@@ -66,36 +59,6 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
export const Avatar = React.memo( export const Avatar = React.memo(
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => { ({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
const initials = useMemo(() => getInitials(name), [name]) const initials = useMemo(() => getInitials(name), [name])
const textRef = React.useRef<SVGTextElement>(null)
const [offsetY, setOffsetY] = React.useState(0)
// Optically center the initials: measure the ink bounding box of the
// rendered glyphs and shift them so the box's center sits at the middle
// of the viewBox. Works for any font, weight or glyph shape, unlike a
// hand-tuned dy offset. getBBox() is in local (pre-transform)
// coordinates, so applying the translation never changes the measure.
useLayoutEffect(() => {
const text = textRef.current
if (!text) return
const center = () => {
const box = text.getBBox()
// A hidden element measures as an empty box; keep the default then.
if (box.height === 0) return
setOffsetY(50 - (box.y + box.height / 2))
}
center()
// Glyph metrics can change once webfonts finish loading.
let cancelled = false
document.fonts?.ready.then(() => {
if (!cancelled) center()
})
return () => {
cancelled = true
}
}, [initials])
return ( return (
<div <div
style={{ backgroundColor: bgColor, ...style }} style={{ backgroundColor: bgColor, ...style }}
@@ -108,15 +71,16 @@ export const Avatar = React.memo(
className={css({ width: '100%', height: '100%', display: 'block' })} className={css({ width: '100%', height: '100%', display: 'block' })}
> >
<text <text
ref={textRef}
x="50" x="50"
y="50" y={50}
transform={`translate(0 ${offsetY})`}
textAnchor="middle" textAnchor="middle"
dominantBaseline="central"
fontSize="52" fontSize="52"
fontWeight="500" fontWeight="500"
fill="currentColor" fill="currentColor"
className={css({
transform:
'translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))',
})}
> >
{initials} {initials}
</text> </text>
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react' import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react' import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand' import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
@@ -25,11 +25,11 @@ export const HandToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext() const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({ const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
participant: room.localParticipant, participant: room.localParticipant,
}) })
const isSpeaking = room.localParticipant.isSpeaking const isSpeaking = useIsSpeaking(room.localParticipant)
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [hasShownToast, setHasShownToast] = useState(false) const [hasShownToast, setHasShownToast] = useState(false)
@@ -57,9 +57,10 @@ export const HandToggle = ({
if (shouldShowToast && !speakingTimerRef.current) { if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => { speakingTimerRef.current = setTimeout(() => {
speakingTimerRef.current = null
setHasShownToast(true) setHasShownToast(true)
const onClose = () => { const onClose = () => {
if (isHandRaised) toggleRaisedHand() lowerHand()
resetToastState() resetToastState()
} }
showLowerHandToast(room.localParticipant, onClose) showLowerHandToast(room.localParticipant, onClose)
@@ -70,7 +71,17 @@ export const HandToggle = ({
speakingTimerRef.current = null speakingTimerRef.current = null
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, toggleRaisedHand]) }, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
// Clear any pending timer on unmount
useEffect(() => {
return () => {
if (speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
}
}, [])
const tooltipLabel = isHandRaised ? 'lower' : 'raise' const tooltipLabel = isHandRaised ? 'lower' : 'raise'
@@ -86,5 +86,16 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
} }
} }
return { isHandRaised, toggleRaisedHand } const lowerHand = async () => {
if (!isLocal(participant)) return
try {
await raiseHand(false)
} catch (e) {
reportError('generic_failure', e, {
context: 'lower_raised_hand',
})
}
}
return { isHandRaised, toggleRaisedHand, lowerHand }
} }
+4
View File
@@ -6,6 +6,10 @@ body,
height: 100%; height: 100%;
} }
:root {
--avatar-cap-height: 0.7;
}
html.font-lexend { html.font-lexend {
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif; --fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
} }