Tune chat text sizing (#891)

This commit is contained in:
klopez4212
2026-06-08 15:13:22 +01:00
committed by GitHub
parent 29f6ccf9e9
commit 45f3dfe5ba
10 changed files with 261 additions and 55 deletions

View File

@@ -164,10 +164,16 @@ export const ChannelPane = React.memo(function ChannelPane({
}: ChannelPaneProps) { }: ChannelPaneProps) {
const timelineScrollRef = React.useRef<HTMLDivElement>(null); const timelineScrollRef = React.useRef<HTMLDivElement>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null); const composerWrapperRef = React.useRef<HTMLDivElement>(null);
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
activeChannel.visibility === "open" &&
!activeChannel.archivedAt;
const hasMainComposerOverlay = !isNonMemberView;
useComposerHeightPadding( useComposerHeightPadding(
timelineScrollRef, timelineScrollRef,
composerWrapperRef, composerWrapperRef,
isSinglePanelView, `${isSinglePanelView}:${hasMainComposerOverlay}`,
); );
// Scope the edit target to the correct composer: if the message being edited // Scope the edit target to the correct composer: if the message being edited
@@ -225,12 +231,6 @@ export const ChannelPane = React.memo(function ChannelPane({
return true; return true;
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]); }, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
activeChannel.visibility === "open" &&
!activeChannel.archivedAt;
const isComposerDisabled = const isComposerDisabled =
!activeChannel?.isMember || !activeChannel?.isMember ||
activeChannel.archivedAt !== null || activeChannel.archivedAt !== null ||
@@ -309,6 +309,7 @@ export const ChannelPane = React.memo(function ChannelPane({
currentPubkey={currentPubkey} currentPubkey={currentPubkey}
fetchOlder={fetchOlder} fetchOlder={fetchOlder}
followThreadById={followThreadById} followThreadById={followThreadById}
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages} hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder} isFetchingOlder={isFetchingOlder}
isFollowingThreadById={isFollowingThreadById} isFollowingThreadById={isFollowingThreadById}

View File

@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
formatDayHeading,
formatShortMonthDayOrdinal,
formatThreadSummaryLastReplyTime,
} from "./dateFormatters.ts";
function localUnixSeconds(year, monthIndex, day) {
return new Date(year, monthIndex, day, 12).getTime() / 1_000;
}
function weekday(date) {
return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date);
}
function month(date) {
return new Intl.DateTimeFormat("en-US", { month: "long" }).format(date);
}
test("formatShortMonthDayOrdinal formats month before ordinal day", () => {
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 19)),
"May 19th",
);
});
test("formatShortMonthDayOrdinal handles ordinal suffixes", () => {
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 1)),
"May 1st",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 2)),
"May 2nd",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 3)),
"May 3rd",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 4)),
"May 4th",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 11)),
"May 11th",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 12)),
"May 12th",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 13)),
"May 13th",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 21)),
"May 21st",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 22)),
"May 22nd",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 23)),
"May 23rd",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 31)),
"May 31st",
);
});
test("formatThreadSummaryLastReplyTime expands relative units", () => {
const now = localUnixSeconds(2026, 4, 19);
assert.equal(formatThreadSummaryLastReplyTime(now - 30, now), "just now");
assert.equal(formatThreadSummaryLastReplyTime(now - 60, now), "1 minute ago");
assert.equal(
formatThreadSummaryLastReplyTime(now - 180, now),
"3 minutes ago",
);
assert.equal(
formatThreadSummaryLastReplyTime(now - 3_600, now),
"1 hour ago",
);
assert.equal(
formatThreadSummaryLastReplyTime(now - 10_800, now),
"3 hours ago",
);
assert.equal(
formatThreadSummaryLastReplyTime(now - 86_400, now),
"1 day ago",
);
assert.equal(
formatThreadSummaryLastReplyTime(now - 345_600, now),
"4 days ago",
);
});
test("formatThreadSummaryLastReplyTime uses ordinal dates for older replies", () => {
const now = localUnixSeconds(2026, 5, 15);
const replyAt = localUnixSeconds(2026, 4, 19);
assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19th");
});
test("formatDayHeading omits the year for current-year dates", () => {
const now = new Date();
const date = new Date(now.getFullYear(), (now.getMonth() + 6) % 12, 19, 12);
assert.equal(
formatDayHeading(date.getTime() / 1_000),
`${weekday(date)}, ${month(date)} 19th`,
);
});
test("formatDayHeading includes the year for other years", () => {
const year = new Date().getFullYear() - 1;
const date = new Date(year, 4, 19, 12);
assert.equal(
formatDayHeading(date.getTime() / 1_000),
`${weekday(date)}, May 19th, ${year}`,
);
});

View File

@@ -5,7 +5,7 @@
* - `formatFullDateTime` — verbose string for tooltips * - `formatFullDateTime` — verbose string for tooltips
* ("Wednesday, April 2, 2026 at 2:34 PM"). * ("Wednesday, April 2, 2026 at 2:34 PM").
* - `formatDayHeading` — label for day dividers / sticky headers. * - `formatDayHeading` — label for day dividers / sticky headers.
* Returns "Today", "Yesterday", or a date like "Monday, March 31, 2026". * Returns "Today", "Yesterday", or a date like "Monday, March 31st".
* - `isSameDay` — compare two unix-second timestamps. * - `isSameDay` — compare two unix-second timestamps.
*/ */
@@ -23,11 +23,16 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
minute: "2-digit", minute: "2-digit",
}); });
const DAY_HEADING_FORMATTER = new Intl.DateTimeFormat("en-US", { const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
weekday: "long", weekday: "long",
year: "numeric", });
const LONG_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "long", month: "long",
day: "numeric", });
const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "short",
}); });
/** Short clock time, e.g. "2:34 PM". */ /** Short clock time, e.g. "2:34 PM". */
@@ -42,7 +47,8 @@ export function formatFullDateTime(unixSeconds: number): string {
/** /**
* Human-friendly day label for dividers and sticky headers. * Human-friendly day label for dividers and sticky headers.
* Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026". * Returns "Today", "Yesterday", a current-year date like "Monday, March 31st",
* or a prior-year date like "Monday, March 31st, 2025".
*/ */
export function formatDayHeading(unixSeconds: number): string { export function formatDayHeading(unixSeconds: number): string {
const date = new Date(unixSeconds * 1_000); const date = new Date(unixSeconds * 1_000);
@@ -58,7 +64,13 @@ export function formatDayHeading(unixSeconds: number): string {
return "Yesterday"; return "Yesterday";
} }
return DAY_HEADING_FORMATTER.format(date); const dateLabel = `${WEEKDAY_FORMATTER.format(date)}, ${formatMonthDayOrdinal(
date,
LONG_MONTH_FORMATTER,
)}`;
return date.getFullYear() === now.getFullYear()
? dateLabel
: `${dateLabel}, ${date.getFullYear()}`;
} }
/** True when two unix-second timestamps fall on the same calendar day (local time). */ /** True when two unix-second timestamps fall on the same calendar day (local time). */
@@ -66,6 +78,32 @@ export function isSameDay(a: number, b: number): boolean {
return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000)); return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000));
} }
/** Short month + ordinal day, e.g. "May 19th". */
export function formatShortMonthDayOrdinal(unixSeconds: number): string {
return formatMonthDayOrdinal(
new Date(unixSeconds * 1_000),
SHORT_MONTH_FORMATTER,
);
}
/**
* Relative thread-summary timestamp with expanded units, e.g. "3 hours ago",
* falling back to "on May 19th" for older replies.
*/
export function formatThreadSummaryLastReplyTime(
unixSeconds: number,
nowSeconds = Date.now() / 1_000,
): string {
const diff = Math.max(0, nowSeconds - unixSeconds);
if (diff < 60) return "just now";
if (diff < 3_600) return formatAgo(Math.floor(diff / 60), "minute");
if (diff < 86_400) return formatAgo(Math.floor(diff / 3_600), "hour");
if (diff < 604_800) return formatAgo(Math.floor(diff / 86_400), "day");
return `on ${formatShortMonthDayOrdinal(unixSeconds)}`;
}
function isSameDayDate(a: Date, b: Date): boolean { function isSameDayDate(a: Date, b: Date): boolean {
return ( return (
a.getFullYear() === b.getFullYear() && a.getFullYear() === b.getFullYear() &&
@@ -73,3 +111,34 @@ function isSameDayDate(a: Date, b: Date): boolean {
a.getDate() === b.getDate() a.getDate() === b.getDate()
); );
} }
function formatMonthDayOrdinal(
date: Date,
monthFormatter: Intl.DateTimeFormat,
): string {
return `${monthFormatter.format(date)} ${date.getDate()}${ordinalSuffix(
date.getDate(),
)}`;
}
function formatAgo(value: number, unit: string): string {
return `${value} ${unit}${value === 1 ? "" : "s"} ago`;
}
function ordinalSuffix(day: number): string {
const lastTwoDigits = day % 100;
if (lastTwoDigits >= 11 && lastTwoDigits <= 13) {
return "th";
}
switch (day % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}

View File

@@ -148,7 +148,7 @@ export const MessageRow = React.memo(
return ( return (
<Markdown <Markdown
channelNames={channelNames} channelNames={channelNames}
className="max-w-full" className="max-w-full text-[15px] leading-6"
content={message.body} content={message.body}
customEmoji={customEmoji} customEmoji={customEmoji}
imetaByUrl={imetaByUrl} imetaByUrl={imetaByUrl}
@@ -200,11 +200,11 @@ export const MessageRow = React.memo(
); );
const authorNode = message.pubkey ? ( const authorNode = message.pubkey ? (
<span className="truncate text-sm font-semibold leading-none tracking-tight hover:underline"> <span className="truncate text-[15px] font-semibold leading-none tracking-tight hover:underline">
{message.author} {message.author}
</span> </span>
) : ( ) : (
<h3 className="truncate text-sm font-semibold leading-none tracking-tight"> <h3 className="truncate text-[15px] font-semibold leading-none tracking-tight">
{message.author} {message.author}
</h3> </h3>
); );

View File

@@ -3,28 +3,13 @@ import type {
TimelineThreadSummaryParticipant, TimelineThreadSummaryParticipant,
} from "@/features/messages/lib/threadPanel"; } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types"; import type { TimelineMessage } from "@/features/messages/types";
import { formatThreadSummaryLastReplyTime } from "@/features/messages/lib/dateFormatters";
import { UserAvatar } from "@/shared/ui/UserAvatar"; import { UserAvatar } from "@/shared/ui/UserAvatar";
const MESSAGE_TEXT_OFFSET_PX = 54; const MESSAGE_TEXT_OFFSET_PX = 54;
const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX + 4; const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX + 4;
const NESTED_REPLY_OFFSET_PX = 28; const NESTED_REPLY_OFFSET_PX = 28;
function formatLastReplyTime(unixSeconds: number): string {
const now = Date.now() / 1_000;
const diff = now - unixSeconds;
if (diff < 60) return "just now";
if (diff < 3_600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86_400) return `${Math.floor(diff / 3_600)}h ago`;
if (diff < 604_800) return `${Math.floor(diff / 86_400)}d ago`;
const date = new Date(unixSeconds * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
return `on ${date}`;
}
function ParticipantAvatar({ function ParticipantAvatar({
hasNextParticipant, hasNextParticipant,
participant, participant,
@@ -69,7 +54,7 @@ export function MessageThreadSummaryRow({
const marginLeftPx = indentPx + MESSAGE_BODY_OFFSET_PX; const marginLeftPx = indentPx + MESSAGE_BODY_OFFSET_PX;
const replyLabel = summary.replyCount === 1 ? "reply" : "replies"; const replyLabel = summary.replyCount === 1 ? "reply" : "replies";
const summaryAriaLabel = summary.lastReplyAt const summaryAriaLabel = summary.lastReplyAt
? `View thread with ${summary.replyCount} ${replyLabel}, last reply ${formatLastReplyTime(summary.lastReplyAt)}` ? `View thread with ${summary.replyCount} ${replyLabel}, last reply ${formatThreadSummaryLastReplyTime(summary.lastReplyAt)}`
: `View thread with ${summary.replyCount} ${replyLabel}`; : `View thread with ${summary.replyCount} ${replyLabel}`;
const depthGuideOffsets = const depthGuideOffsets =
visibleDepth === 0 visibleDepth === 0
@@ -137,7 +122,8 @@ export function MessageThreadSummaryRow({
className="col-start-1 row-start-1 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0" className="col-start-1 row-start-1 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"
data-testid="message-thread-summary-last-reply" data-testid="message-thread-summary-last-reply"
> >
last reply {formatLastReplyTime(summary.lastReplyAt)} last reply{" "}
{formatThreadSummaryLastReplyTime(summary.lastReplyAt)}
</span> </span>
<span <span
className="col-start-1 row-start-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" className="col-start-1 row-start-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"

View File

@@ -3,6 +3,7 @@ import { ArrowDown } from "lucide-react";
import type { TimelineMessage } from "@/features/messages/types"; import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator"; import { Separator } from "@/shared/ui/separator";
import { Spinner } from "@/shared/ui/spinner"; import { Spinner } from "@/shared/ui/spinner";
@@ -24,6 +25,8 @@ type MessageTimelineProps = {
/** Optional external ref to the scroll container — used by the parent to /** Optional external ref to the scroll container — used by the parent to
* observe scroll position or adjust padding dynamically. */ * observe scroll position or adjust padding dynamically. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>; scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
/** True when the timeline has the composer overlay below it. */
hasComposerOverlay?: boolean;
isFetchingOlder?: boolean; isFetchingOlder?: boolean;
messageFooters?: Record<string, React.ReactNode>; messageFooters?: Record<string, React.ReactNode>;
/** Map from lowercase pubkey → persona display name for bot members. */ /** Map from lowercase pubkey → persona display name for bot members. */
@@ -59,6 +62,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
emptyDescription = "Send the first message to start the thread.", emptyDescription = "Send the first message to start the thread.",
currentPubkey, currentPubkey,
fetchOlder, fetchOlder,
hasComposerOverlay = true,
hasOlderMessages = true, hasOlderMessages = true,
isFetchingOlder = false, isFetchingOlder = false,
followThreadById, followThreadById,
@@ -141,7 +145,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> <div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div <div
className="absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-24 pt-1 [overflow-anchor:none] sm:px-6" className={cn(
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pt-1 [overflow-anchor:none] sm:px-6",
hasComposerOverlay ? "pb-24" : "pb-4",
)}
data-scroll-restoration-id={scrollRestorationId} data-scroll-restoration-id={scrollRestorationId}
data-testid="message-timeline" data-testid="message-timeline"
onScroll={syncScrollState} onScroll={syncScrollState}
@@ -216,7 +223,12 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div> </div>
{!isAtBottom ? ( {!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-36 z-20 flex justify-center px-4"> <div
className={cn(
"pointer-events-none absolute inset-x-0 z-20 flex justify-center px-4",
hasComposerOverlay ? "bottom-36" : "bottom-4",
)}
>
<Button <Button
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5" className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5"
data-testid="message-scroll-to-latest" data-testid="message-scroll-to-latest"

View File

@@ -10,6 +10,10 @@ import { resolveUserLabel } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import {
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
} from "@/shared/ui/mentionChip";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner"; import { Spinner } from "@/shared/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -78,10 +82,10 @@ function ProfileName({
const node = ( const node = (
<span <span
className={cn( className={cn(
"rounded-xs transition-colors hover:text-foreground",
pubkey && "cursor-pointer", pubkey && "cursor-pointer",
highlight && highlight
"rounded-md bg-primary/15 px-1 py-0.5 font-medium text-primary hover:bg-primary/25 hover:text-primary/90", ? cn(MENTION_CHIP_BASE_CLASSES, MENTION_CHIP_HOVER_CLASSES)
: "rounded-xs transition-colors hover:text-foreground",
)} )}
> >
{highlight ? "@" : null} {highlight ? "@" : null}
@@ -222,12 +226,7 @@ function describeSystemEvent(
} }
return { return {
title: actorName, title: actorName,
action: ( action: <>added {targetName} to the channel</>,
<>
added <span className="font-medium">{targetName}</span> to the
channel
</>
),
}; };
} }
case "member_left": case "member_left":
@@ -238,12 +237,7 @@ function describeSystemEvent(
case "member_removed": case "member_removed":
return { return {
title: actorName, title: actorName,
action: ( action: <>removed {targetName} from the channel</>,
<>
removed <span className="font-medium">{targetName}</span> from the
channel
</>
),
}; };
case "topic_changed": case "topic_changed":
return { return {

View File

@@ -114,11 +114,14 @@
} }
.rich-text-composer .tiptap .mention-highlight { .rich-text-composer .tiptap .mention-highlight {
border-radius: 0.375rem; display: inline-block;
border-radius: 4px;
background: hsl(var(--primary) / 0.15); background: hsl(var(--primary) / 0.15);
padding: 0.125rem 0.375rem; padding: 3px 4px 2px;
font-size: 15px;
line-height: 1;
color: hsl(var(--primary)); color: hsl(var(--primary));
font-weight: 600; font-weight: 500;
} }
.agent-activity-shimmer { .agent-activity-shimmer {

View File

@@ -40,6 +40,10 @@ import remarkCustomEmoji, {
import remarkMentions from "@/shared/lib/remarkMentions"; import remarkMentions from "@/shared/lib/remarkMentions";
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks"; import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import {
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
} from "@/shared/ui/mentionChip";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -908,7 +912,11 @@ function createMarkdownComponents(
const mentionNode = ( const mentionNode = (
<span <span
data-mention="" data-mention=""
className="cursor-pointer rounded-md bg-primary/15 px-1 py-0.5 text-sm font-semibold text-primary transition-colors hover:bg-primary/25 hover:text-primary/90" className={cn(
"cursor-pointer",
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
)}
> >
{children} {children}
</span> </span>

View File

@@ -0,0 +1,5 @@
export const MENTION_CHIP_BASE_CLASSES =
"inline-block rounded-[4px] bg-primary/15 px-1 pt-[3px] pb-[2px] text-[15px] font-medium leading-none text-primary";
export const MENTION_CHIP_HOVER_CLASSES =
"transition-colors hover:bg-primary/25 hover:text-primary/90";