From d46cf43c7d336fa7f8b138d45ae6b2ebe4465ea0 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sat, 5 Sep 2026 01:40:15 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20center=20Avatar=20init?= =?UTF-8?q?ials=20with=20a=20font-aware=20cap-height=20ratio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation centered initials by measuring `` 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. --- CHANGELOG.md | 1 + docker/dinum-frontend/dinum-styles.css | 1 + docs/theming.md | 1 + src/frontend/src/components/Avatar.tsx | 48 ++++---------------------- src/frontend/src/styles/index.css | 4 +++ 5 files changed, 13 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1e4417..97e03a49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to - 🐛(backend) allow any printable ASCII characters in user sub field #1673 - 🐛(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 diff --git a/docker/dinum-frontend/dinum-styles.css b/docker/dinum-frontend/dinum-styles.css index d12ae9a3..80ea84e0 100644 --- a/docker/dinum-frontend/dinum-styles.css +++ b/docker/dinum-frontend/dinum-styles.css @@ -1,5 +1,6 @@ :root { --fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif; + --avatar-cap-height: 0.7; } .Header-beforeLogo { diff --git a/docs/theming.md b/docs/theming.md index 4d40658a..4b7858cb 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -34,6 +34,7 @@ Let's say you want to change the font of our application to a custom font. You c :root { --fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif; + --avatar-cap-height: 0.7; } ``` diff --git a/src/frontend/src/components/Avatar.tsx b/src/frontend/src/components/Avatar.tsx index 36bf7257..f3676026 100644 --- a/src/frontend/src/components/Avatar.tsx +++ b/src/frontend/src/components/Avatar.tsx @@ -1,5 +1,5 @@ import { css, cva, RecipeVariantProps } from '@/styled-system/css' -import React, { useLayoutEffect, useMemo } from 'react' +import React, { useMemo } from 'react' const avatar = cva({ base: { @@ -28,24 +28,17 @@ const avatar = cva({ }, }) -// Instantiating a segmenter is expensive; create it once and reuse it. const graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : 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 => { if (!value) return '' if (graphemeSegmenter) { const [first] = graphemeSegmenter.segment(value) return first?.segment ?? '' } - // Fallback: keeps single code points intact (including surrogate pairs). return Array.from(value)[0] ?? '' } @@ -66,36 +59,6 @@ export type AvatarProps = React.HTMLAttributes & { export const Avatar = React.memo( ({ name, bgColor, context, notification, style, ...props }: AvatarProps) => { const initials = useMemo(() => getInitials(name), [name]) - const textRef = React.useRef(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 (
{initials} diff --git a/src/frontend/src/styles/index.css b/src/frontend/src/styles/index.css index 88e67d56..758ed6f3 100644 --- a/src/frontend/src/styles/index.css +++ b/src/frontend/src/styles/index.css @@ -6,6 +6,10 @@ body, height: 100%; } +:root { + --avatar-cap-height: 0.7; +} + html.font-lexend { --fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif; }