From abfaff16c2437275286857d01d500778f0b0a55f Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 8 Oct 2026 20:05:09 +0200 Subject: [PATCH] fix(guild): fit text icon initials to the icon by measured width (#3310) --- .../components/layout/GuildsLayout.module.css | 12 ++--- .../sidebar_nav/GuildListItemPresentation.tsx | 14 ++--- .../modals/AddGuildModal.module.css | 12 ++--- .../add_guild_modal/GuildCreateForm.tsx | 14 +++-- .../add_guild_modal/TemplateImportForm.tsx | 14 +++-- .../components/popouts/GuildIcon.module.css | 12 ++--- .../guild/components/popouts/GuildIcon.tsx | 17 ++++-- .../guild/utils/GuildInitialsUtils.ts | 53 ++++++++++++++++--- 8 files changed, 100 insertions(+), 48 deletions(-) diff --git a/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css b/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css index f4ae1fdc4..1f2fb81c8 100644 --- a/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css +++ b/fluxer_app/src/features/app/components/layout/GuildsLayout.module.css @@ -206,6 +206,7 @@ color 70ms ease-out; container-type: size; --initials-font-size: clamp(0.85rem, 45cqi, 1.35rem); + --initials-fit-inline-size: 92cqi; --initials-letter-spacing: 0px; --initials-scale: 1; } @@ -259,7 +260,10 @@ line-height: 1; color: inherit; font-weight: 600; - font-size: var(--initials-font-size); + font-size: min( + var(--initials-font-size), + calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale))) + ); letter-spacing: var(--initials-letter-spacing); transform: scale(var(--initials-scale)); transform-origin: center; @@ -271,12 +275,6 @@ --initials-scale: 0.92; } -.guildIcon[data-initials-length='long'] .guildIconInitials { - --initials-font-size: clamp(0.85rem, 32cqi, 0.87rem); - --initials-letter-spacing: 0px; - --initials-scale: 0.78; -} - .guildIndicator { position: absolute; left: var(--guild-list-indicator-inset); diff --git a/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx b/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx index 136a2f52e..19b4eb7ed 100644 --- a/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx +++ b/fluxer_app/src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx @@ -14,6 +14,7 @@ import Guilds from '@app/features/guild/state/Guilds'; import { type GuildInitialsLength, getGuildIconDisplayInitials, + getGuildInitialsFitStyle, getInitialsFromName, getInitialsLength, truncateInitials, @@ -57,11 +58,11 @@ function resolveGuildListItemMotion(useReducedMotion: boolean): GuildListItemMot return STANDARD_GUILD_LIST_ITEM_MOTION; } -function resolveInitialsLength(rawInitials: string): GuildInitialsLength | null { - if (rawInitials.length === 0) { +function resolveInitialsLength(displayInitials: string): GuildInitialsLength | null { + if (displayInitials.length === 0) { return null; } - return getInitialsLength(rawInitials); + return getInitialsLength(displayInitials); } function resolveDraggingCursor(dragAndDrop: GuildListItemDragAndDrop | null): 'grabbing' | null { @@ -303,8 +304,8 @@ export const GuildListItemPresentation = forwardRef< forwardedRef, ) { const rootRef = useMergeRefs([surfaceRef, forwardedRef]); - const rawInitials = getInitialsFromName(guild.name); - const initialsLength = resolveInitialsLength(rawInitials); + const displayInitials = getGuildIconDisplayInitials(getInitialsFromName(guild.name)); + const initialsLength = resolveInitialsLength(displayInitials); const dropIndicator = resolveDropIndicator(dragAndDrop); const motion = resolveGuildListItemMotion(Accessibility.useReducedMotion); const draggingCursor = resolveDraggingCursor(dragAndDrop); @@ -378,9 +379,10 @@ export const GuildListItemPresentation = forwardRef< {!hasPaintedIcon && ( - {getGuildIconDisplayInitials(rawInitials)} + {displayInitials} )} diff --git a/fluxer_app/src/features/guild/components/modals/AddGuildModal.module.css b/fluxer_app/src/features/guild/components/modals/AddGuildModal.module.css index d73e3d7ca..fed53f749 100644 --- a/fluxer_app/src/features/guild/components/modals/AddGuildModal.module.css +++ b/fluxer_app/src/features/guild/components/modals/AddGuildModal.module.css @@ -133,6 +133,7 @@ background: var(--background-tertiary); container-type: size; --initials-font-size: clamp(0.85rem, 45cqi, 1.35rem); + --initials-fit-inline-size: 92cqi; --initials-letter-spacing: 0px; --initials-scale: 1; } @@ -146,7 +147,10 @@ white-space: nowrap; display: inline-block; text-align: center; - font-size: var(--initials-font-size); + font-size: min( + var(--initials-font-size), + calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale))) + ); letter-spacing: var(--initials-letter-spacing); transform: scale(var(--initials-scale)); transform-origin: center; @@ -158,12 +162,6 @@ --initials-scale: 0.92; } -.iconPlaceholder[data-initials-length='long'] .iconInitials { - --initials-font-size: clamp(0.85rem, 32cqi, 0.87rem); - --initials-letter-spacing: 0px; - --initials-scale: 0.78; -} - .iconActions { display: flex; flex: 1; diff --git a/fluxer_app/src/features/guild/components/modals/add_guild_modal/GuildCreateForm.tsx b/fluxer_app/src/features/guild/components/modals/add_guild_modal/GuildCreateForm.tsx index 9b18e3ac8..efb1f3a58 100644 --- a/fluxer_app/src/features/guild/components/modals/add_guild_modal/GuildCreateForm.tsx +++ b/fluxer_app/src/features/guild/components/modals/add_guild_modal/GuildCreateForm.tsx @@ -30,7 +30,11 @@ import { ICON_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, ModalFooterContext, } from '@app/features/guild/components/modals/add_guild_modal/shared'; -import {getGuildIconDisplayInitials, getInitialsLength} from '@app/features/guild/utils/GuildInitialsUtils'; +import { + getGuildIconDisplayInitials, + getGuildInitialsFitStyle, + getInitialsLength, +} from '@app/features/guild/utils/GuildInitialsUtils'; import { CREATE_COMMUNITY_DESCRIPTOR, FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR, @@ -75,13 +79,12 @@ export const GuildCreateForm = observer(() => { const currentUser = Users.currentUser; const shouldRequireClaimedAccount = currentUser != null && !currentUser.isClaimed(); const nameValue = form.watch('name'); - const rawInitials = useMemo(() => { + const initials = useMemo(() => { const raw = (nameValue || '').trim(); if (!raw) return ''; - return StringUtils.getInitialsFromName(raw); + return getGuildIconDisplayInitials(StringUtils.getInitialsFromName(raw)); }, [nameValue]); - const initials = useMemo(() => getGuildIconDisplayInitials(rawInitials), [rawInitials]); - const initialsLength = useMemo(() => (rawInitials ? getInitialsLength(rawInitials) : null), [rawInitials]); + const initialsLength = initials ? getInitialsLength(initials) : null; const showIconUploadErrorModal = useCallback( (message: string) => { showGuildErrorModal({ @@ -289,6 +292,7 @@ export const GuildCreateForm = observer(() => { {initials ? ( {initials} diff --git a/fluxer_app/src/features/guild/components/modals/add_guild_modal/TemplateImportForm.tsx b/fluxer_app/src/features/guild/components/modals/add_guild_modal/TemplateImportForm.tsx index 6ff49ace1..32a69211a 100644 --- a/fluxer_app/src/features/guild/components/modals/add_guild_modal/TemplateImportForm.tsx +++ b/fluxer_app/src/features/guild/components/modals/add_guild_modal/TemplateImportForm.tsx @@ -44,7 +44,11 @@ import { type TemplateJsonFormInputs, THIS_DOESN_T_LOOK_LIKE_A_VALID_TEMPLATE_DESCRIPTOR, } from '@app/features/guild/components/modals/add_guild_modal/shared'; -import {getGuildIconDisplayInitials, getInitialsLength} from '@app/features/guild/utils/GuildInitialsUtils'; +import { + getGuildIconDisplayInitials, + getGuildInitialsFitStyle, + getInitialsLength, +} from '@app/features/guild/utils/GuildInitialsUtils'; import { CREATE_COMMUNITY_DESCRIPTOR, FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR, @@ -245,13 +249,12 @@ export const TemplateImportForm = observer(() => { const nameValue = createForm.watch('name'); const urlValue = urlForm.watch('url'); const jsonValue = jsonForm.watch('json'); - const rawInitials = useMemo(() => { + const initials = useMemo(() => { const raw = (nameValue || '').trim(); if (!raw) return ''; - return StringUtils.getInitialsFromName(raw); + return getGuildIconDisplayInitials(StringUtils.getInitialsFromName(raw)); }, [nameValue]); - const initials = useMemo(() => getGuildIconDisplayInitials(rawInitials), [rawInitials]); - const initialsLength = useMemo(() => (rawInitials ? getInitialsLength(rawInitials) : null), [rawInitials]); + const initialsLength = initials ? getInitialsLength(initials) : null; const showIconUploadErrorModal = useCallback( (message: string) => { showGuildErrorModal({ @@ -612,6 +615,7 @@ export const TemplateImportForm = observer(() => { {initials ? ( {initials} diff --git a/fluxer_app/src/features/guild/components/popouts/GuildIcon.module.css b/fluxer_app/src/features/guild/components/popouts/GuildIcon.module.css index 24a462dec..5ecd9d6cb 100644 --- a/fluxer_app/src/features/guild/components/popouts/GuildIcon.module.css +++ b/fluxer_app/src/features/guild/components/popouts/GuildIcon.module.css @@ -19,6 +19,7 @@ background-size: cover; container-type: inline-size; --initials-font-size: clamp(0.85rem, 45cqi, 1.35rem); + --initials-fit-inline-size: 92cqi; --initials-letter-spacing: 0px; --initials-scale: 1; --initials-scale-cap: 1; @@ -35,7 +36,10 @@ white-space: nowrap; display: inline-block; text-align: center; - font-size: var(--initials-font-size); + font-size: min( + var(--initials-font-size), + calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale))) + ); letter-spacing: var(--initials-letter-spacing); transform: scale(calc(var(--initials-scale) * var(--initials-scale-cap))); transform-origin: center; @@ -47,12 +51,6 @@ --initials-letter-spacing: 0px; } -.container[data-initials-length='long'] .initials { - --initials-font-size: clamp(0.85rem, 32cqi, 0.87rem); - --initials-scale: 0.78; - --initials-letter-spacing: 0px; -} - @container (max-width: 32px) { .initials { --initials-scale-cap: 0.88; diff --git a/fluxer_app/src/features/guild/components/popouts/GuildIcon.tsx b/fluxer_app/src/features/guild/components/popouts/GuildIcon.tsx index f964fc966..8e7539808 100644 --- a/fluxer_app/src/features/guild/components/popouts/GuildIcon.tsx +++ b/fluxer_app/src/features/guild/components/popouts/GuildIcon.tsx @@ -3,7 +3,11 @@ import {useHover} from '@app/features/app/hooks/useHover'; import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import styles from '@app/features/guild/components/popouts/GuildIcon.module.css'; -import {getGuildIconDisplayInitials, getInitialsLength} from '@app/features/guild/utils/GuildInitialsUtils'; +import { + getGuildIconDisplayInitials, + getGuildInitialsFitStyle, + getInitialsLength, +} from '@app/features/guild/utils/GuildInitialsUtils'; import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery'; import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; @@ -39,9 +43,8 @@ export const GuildIcon = observer(function GuildIcon({ sizePx, containerProps, }: GuildIconProps) { - const rawInitials = useMemo(() => StringUtils.getInitialsFromName(name), [name]); - const initials = useMemo(() => getGuildIconDisplayInitials(rawInitials), [rawInitials]); - const initialsLength = useMemo(() => getInitialsLength(rawInitials), [rawInitials]); + const initials = useMemo(() => getGuildIconDisplayInitials(StringUtils.getInitialsFromName(name)), [name]); + const initialsLength = getInitialsLength(initials); const [hoverRef, isHovering] = useHover(); const iconUrl = icon ? AvatarUtils.getGuildIconURL({id, icon}) : null; const hoverIconUrl = icon ? AvatarUtils.getGuildIconURL({id, icon}, true) : null; @@ -73,7 +76,11 @@ export const GuildIcon = observer(function GuildIcon({ style={styleVars} > {paintedUrl == null && ( - + {initials} )} diff --git a/fluxer_app/src/features/guild/utils/GuildInitialsUtils.ts b/fluxer_app/src/features/guild/utils/GuildInitialsUtils.ts index 09128aed6..6e25e20c3 100644 --- a/fluxer_app/src/features/guild/utils/GuildInitialsUtils.ts +++ b/fluxer_app/src/features/guild/utils/GuildInitialsUtils.ts @@ -1,15 +1,25 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import type React from 'react'; + const GUILD_ICON_INITIALS_MAX_LENGTH = 4; +const MEASURE_FONT_SIZES_PX = [10, 12, 14, 16, 20, 24]; +const MEASURE_FONT_WEIGHT = 600; export const GuildInitialsLength = Object.freeze({ SHORT: 'short', MEDIUM: 'medium', - LONG: 'long', } as const); export type GuildInitialsLength = (typeof GuildInitialsLength)[keyof typeof GuildInitialsLength]; +export type GuildInitialsFitStyle = React.CSSProperties & { + '--initials-width-em'?: string; +}; + +const measuredWidthsEm = new Map(); +let measureContext: OffscreenCanvasRenderingContext2D | null | undefined; + export function getInitialsFromName(name: string): string { const words = name.split(/\s+/u).filter(Boolean); return words @@ -27,9 +37,40 @@ export function getGuildIconDisplayInitials(initials: string): string { return truncateInitials(initials, GUILD_ICON_INITIALS_MAX_LENGTH); } -export function getInitialsLength(initials: string): GuildInitialsLength { - const length = Array.from(initials).length; - if (length <= 2) return GuildInitialsLength.SHORT; - if (length <= 4) return GuildInitialsLength.MEDIUM; - return GuildInitialsLength.LONG; +export function getInitialsLength(displayInitials: string): GuildInitialsLength { + return Array.from(displayInitials).length <= 2 ? GuildInitialsLength.SHORT : GuildInitialsLength.MEDIUM; +} + +function getMeasureContext(): OffscreenCanvasRenderingContext2D | null { + if (measureContext !== undefined) return measureContext; + if (typeof document === 'undefined' || typeof OffscreenCanvas === 'undefined') { + measureContext = null; + return measureContext; + } + measureContext = new OffscreenCanvas(1, 1).getContext('2d'); + document.fonts?.addEventListener('loadingdone', () => measuredWidthsEm.clear()); + return measureContext; +} + +function measureInitialsWidthEm(initials: string): number | null { + const context = getMeasureContext(); + if (context == null || document.body == null) return null; + const fontFamily = getComputedStyle(document.body).fontFamily; + const key = `${fontFamily}\u0000${initials}`; + const cached = measuredWidthsEm.get(key); + if (cached !== undefined) return cached; + let widthEm = 0; + for (const fontSize of MEASURE_FONT_SIZES_PX) { + context.font = `${MEASURE_FONT_WEIGHT} ${fontSize}px ${fontFamily}`; + widthEm = Math.max(widthEm, context.measureText(initials).width / fontSize); + } + measuredWidthsEm.set(key, widthEm); + return widthEm; +} + +export function getGuildInitialsFitStyle(displayInitials: string): GuildInitialsFitStyle | undefined { + if (!displayInitials) return undefined; + const widthEm = measureInitialsWidthEm(displayInitials); + if (widthEm == null || widthEm <= 0) return undefined; + return {'--initials-width-em': widthEm.toFixed(3)}; }