fix: keep widget text and counts inside small and translated widgets #52

Checked every widget at its smallest size with long names, big counts
and German labels. What spilled over:

- the Inbox header at two cells: the count pushed the compose button
  off the edge (search now goes at that width, the title truncates
  first, and only the unread count stays);
- a long sender pushed the triage card's time and position out;
- German button labels were cut ("Archivie", "Lösc"); the triage
  buttons now drop to icons, Archive and Delete first, when the labels
  do not fit;
- five-digit counts overflowed Unread count, Tag and Mail and next, and
  "/ 23000" wrapped a character per line; counts from 10,000 up are
  shortened (10K, 12,3K) and the total goes where it does not fit;
- "ungelesen" was cut beside the number in Mail and next and moves
  under it instead.

Every widget header now lets its title give way before the buttons on
its right.
This commit is contained in:
Linus Rath
2026-10-02 13:01:05 +02:00
parent c9a0718acf
commit c520e0a125
6 changed files with 96 additions and 31 deletions
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { makeFmt } from '../format';
describe('count', () => {
it('keeps counts as they are below 10,000 and shortens bigger ones', () => {
const f = makeFmt('en', false);
expect(f.count(0)).toBe('0');
expect(f.count(9999)).toBe('9999');
expect(f.count(10007)).toBe('10K');
expect(f.count(12345)).toBe('12.3K');
expect(f.count(234567)).toBe('235K');
expect(f.count(2_500_000)).toBe('2.5M');
expect(makeFmt('de', false).count(12345)).toBe('12,3K');
});
});
+16
View File
@@ -26,6 +26,8 @@ export interface Fmt {
/** "in 25 min", "in 2 h", "now". */
relative: (ms: number, now: number) => string;
bytes: (n: number) => string;
/** A count for a big number: as is below 10,000, then compact ("12K"). */
count: (n: number) => string;
}
function safeFormat(locale: string, options: Intl.DateTimeFormatOptions): (ms: number) => string {
@@ -128,5 +130,19 @@ export function makeFmt(locale: string, hour12: boolean): Fmt {
}
return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
},
count: (n) => {
if (n < 10000) return String(n);
// Not Intl's compact notation: German leaves thousands unshortened
// ("10.007"), which is what has to fit.
const [value, unit] = n >= 1_000_000 ? [n / 1_000_000, 'M'] : [n / 1000, 'K'];
const digits = value < 100 ? 1 : 0;
let text: string;
try {
text = new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value);
} catch {
text = value.toFixed(digits).replace(/\.0$/, '');
}
return `${text}${unit}`;
},
};
}
+12 -10
View File
@@ -287,14 +287,18 @@ export const TodayHubLayout: Layout = ({ s, p, f, now, height }) => {
/* Mail and next event */
/* ------------------------------------------------------------------------ */
export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => {
export const MailAndNextLayout: Layout = ({ s, p, f, now, width, height }) => {
const tight = height < 140;
const pad = tight ? 10 : 14;
const unreadMail = s.mail.inbox.filter((m) => m.unread);
const unread = Math.max(inboxUnread(s), unreadMail.length);
const latest = unreadMail[0];
const digits = String(unread).length;
const digits = f.count(unread).length;
const big = (tight ? 28 : 36) - (digits >= 4 ? 8 : 0);
// "unread" sits beside the number where it fits (a half-width column),
// under it otherwise (German, "ungelesen").
const unreadLabel = f.t('widgets.mail.unread', 'unread');
const labelBeside = digits * big * 0.6 + 4 + textWidth(unreadLabel, 13) <= (width - 2) / 2 - 2 * pad;
const upcoming = s.calendar.supported ? upcomingTimed(s.calendar.events, now) : [];
const event = upcoming[0];
@@ -325,16 +329,14 @@ export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => {
<Txt text={f.t('widgets.mail.inbox', 'Inbox')} color={p.fg} size={14} weight="600" />
</FlexWidget>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'flex-end', marginTop: 4 }}>
<TextWidget text={String(unread)} maxLines={1} style={{ color: p.fg, fontSize: big, fontWeight: '700' }} />
<Spacer size={4} horizontal />
<TextWidget text={f.count(unread)} maxLines={1} style={{ color: p.fg, fontSize: big, fontWeight: '700' }} />
{labelBeside ? <Spacer size={4} horizontal /> : null}
{/* Lines the small label up with the big number's baseline. */}
<Txt
text={f.t('widgets.mail.unread', 'unread')}
color={p.muted}
size={13}
style={{ paddingBottom: Math.round((big - 13) * 0.27) }}
/>
{labelBeside ? (
<Txt text={unreadLabel} color={p.muted} size={13} style={{ paddingBottom: Math.round((big - 13) * 0.27) }} />
) : null}
</FlexWidget>
{labelBeside ? null : <Txt text={unreadLabel} color={p.muted} size={13} />}
<Spacer />
{latest ? (
<FlexWidget {...open(links.message(latest))} style={{ width: 'match_parent' }}>
+46 -16
View File
@@ -18,6 +18,7 @@ import {
Surface,
Txt,
} from '../primitives';
import type { Fmt } from '../format';
import type { FolderCount } from '../snapshot';
import type { IconName } from '../icons';
import type { WidgetPalette } from '../theme';
@@ -28,17 +29,19 @@ function inboxCounts(s: Parameters<Layout>[0]['s']) {
return { unread: inbox?.unread ?? 0, total: inbox?.total ?? 0 };
}
function CountText({ p, unread, total }: { p: WidgetPalette; unread: number; total: number }) {
function CountText({ p, f, unread, total, short }: { p: WidgetPalette; f: Fmt; unread: number; total: number; short?: boolean }) {
return (
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
<Txt text={String(unread)} color={p.fg} size={12} weight="600" />
<Txt text={` / ${total}`} color={p.muted} size={12} />
<Txt text={f.count(unread)} color={p.fg} size={12} weight="600" />
{short ? null : <Txt text={` / ${f.count(total)}`} color={p.muted} size={12} />}
</FlexWidget>
);
}
export const InboxLayout: Layout = ({ s, p, f, now, height }) => {
export const InboxLayout: Layout = ({ s, p, f, now, width, height }) => {
const compact = height < 190;
// At two cells wide the title, count, search and compose do not fit.
const narrow = width < 240;
const headerHeight = compact ? 38 : 44;
const rowHeight = compact ? MAIL_ROW_HEIGHT.compact : MAIL_ROW_HEIGHT.full;
const rows = Math.max(1, Math.floor((height - headerHeight) / rowHeight));
@@ -55,8 +58,9 @@ export const InboxLayout: Layout = ({ s, p, f, now, height }) => {
click={open(links.inbox())}
trailing={(
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
<CountText p={p} unread={unread} total={total} />
{compact ? null : <GhostIcon p={p} iconName="search" click={open(links.search())} />}
<Spacer size={8} horizontal />
<CountText p={p} f={f} unread={unread} total={total} short={narrow} />
{compact || narrow ? null : <GhostIcon p={p} iconName="search" click={open(links.search())} />}
<Spacer size={6} horizontal />
<Fab p={p} iconName="edit" size={compact ? 28 : 32} click={open(links.compose())} />
</FlexWidget>
@@ -73,7 +77,14 @@ export const InboxLayout: Layout = ({ s, p, f, now, height }) => {
);
};
export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
/**
* What a labelled button needs: icon, gap, label and its border. A glyph of
* the 13sp label averages a little under half its size; measured against the
* emulator, English fits at three cells and German does not.
*/
const buttonWidth = (label: string) => 15 + 6 + Math.ceil(label.length * 13 * 0.47) + 2;
export const TriageLayout: Layout = ({ s, p, f, now, width, widgetId, local }) => {
const candidates = s.mail.inbox.filter((m) => m.unread);
const current = candidates.find((m) => m.id === local.triageId) ?? candidates[0];
const { unread } = inboxCounts(s);
@@ -93,16 +104,29 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
const position = candidates.indexOf(current) + 1;
const target = { id: current.id, accountId: current.accountId, jmapAccountId: current.jmapAccountId, widgetId };
const notice = noticeFor(s.notice, 'archive', 'trash');
// Long labels (German at three cells) drop to icons instead of being cut:
// first Archive and Delete, then Reply too.
const labels = {
archive: f.t('widgets.mail.archive', 'Archive'),
trash: f.t('widgets.mail.delete', 'Delete'),
reply: f.t('widgets.mail.reply', 'Reply'),
};
const room = width - 2 - 24 - 32 - 18;
const allFit = buttonWidth(labels.archive) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room;
const replyFits = 2 * 32 + buttonWidth(labels.reply) <= room;
return (
<Surface p={p} style={{ padding: 12 }}>
<FlexWidget {...open(links.message(current))} style={{ width: 'match_parent', flex: 1 }}>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
<Avatar initials={current.initials} color={current.color} size={32} />
<Spacer size={10} horizontal />
<Txt text={current.fromName || current.fromEmail} color={p.fg} size={14} weight="700" />
{/* A long name is cut, not the time and position after it. */}
<FlexWidget style={{ flex: 1 }}>
<Txt text={current.fromName || current.fromEmail} color={p.fg} size={14} weight="700" />
</FlexWidget>
<Spacer size={6} horizontal />
<Txt text={f.listDate(current.receivedAt, now)} color={p.fg} size={12} weight="600" />
<Spacer />
<Spacer size={8} horizontal />
<Txt
text={f.t('widgets.mail.position', '{index} of {count}', { index: position, count: Math.max(unread, candidates.length) })}
color={p.muted}
@@ -117,11 +141,11 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
{notice ? <NoticeBar p={p} f={f} notice={notice} /> : null}
<Spacer size={8} />
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
<Button p={p} label={f.t('widgets.mail.archive', 'Archive')} iconName="archive" flex={1} click={action('archive', target)} />
<Button p={p} label={allFit ? labels.archive : undefined} iconName="archive" flex={1} click={action('archive', target)} />
<Spacer size={6} horizontal />
<Button p={p} label={f.t('widgets.mail.delete', 'Delete')} iconName="trash" flex={1} click={action('trash', target)} />
<Button p={p} label={allFit ? labels.trash : undefined} iconName="trash" flex={1} click={action('trash', target)} />
<Spacer size={6} horizontal />
<Button p={p} label={f.t('widgets.mail.reply', 'Reply')} iconName="reply" variant="primary" flex={1} click={open(links.reply(current))} />
<Button p={p} label={allFit || replyFits ? labels.reply : undefined} iconName="reply" variant="primary" flex={1} click={open(links.reply(current))} />
<Spacer size={6} horizontal />
<Button p={p} iconName="chevronRight" click={action('triageNext', { widgetId, id: current.id })} />
</FlexWidget>
@@ -129,8 +153,12 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
);
};
export const UnreadCountLayout: Layout = ({ s, p, f }) => {
export const UnreadCountLayout: Layout = ({ s, p, f, width }) => {
const { unread, total } = inboxCounts(s);
const big = f.count(unread);
const bigSize = big.length <= 3 ? 44 : big.length === 4 ? 38 : 32;
// The total goes when the two do not fit on one line next to each other.
const showTotal = big.length * bigSize * 0.6 + (f.count(total).length + 2) * 15 * 0.55 + 4 <= width - 30;
const senders = s.mail.inbox.filter((m) => m.unread);
const faces = senders.slice(0, 3);
const more = Math.max(0, unread - faces.length);
@@ -143,9 +171,11 @@ export const UnreadCountLayout: Layout = ({ s, p, f }) => {
</FlexWidget>
<FlexWidget>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
<TextWidget text={String(unread)} style={{ color: p.fg, fontSize: 44, fontWeight: '700' }} />
<Spacer size={4} horizontal />
<TextWidget text={`/ ${total}`} style={{ color: p.muted, fontSize: 15, paddingBottom: 8 }} />
<TextWidget text={big} maxLines={1} style={{ color: p.fg, fontSize: bigSize, fontWeight: '700' }} />
{showTotal ? <Spacer size={4} horizontal /> : null}
{showTotal ? (
<TextWidget text={`/ ${f.count(total)}`} maxLines={1} style={{ color: p.muted, fontSize: 15, paddingBottom: 8 }} />
) : null}
</FlexWidget>
<Txt text={f.t('widgets.mail.unread', 'unread')} color={p.muted} size={13} />
</FlexWidget>
+1 -1
View File
@@ -671,7 +671,7 @@ export const TagLayout: Layout = ({ s, p, f, now }) => {
</FlexWidget>
</FlexWidget>
<FlexWidget>
<TextWidget text={String(count)} style={{ color: p.fg, fontSize: 44, fontWeight: '700' }} />
<TextWidget text={f.count(count)} maxLines={1} style={{ color: p.fg, fontSize: f.count(count).length <= 4 ? 44 : 34, fontWeight: '700' }} />
<Txt text={label} color={p.muted} size={13} />
</FlexWidget>
<Txt text={latest} color={p.muted} size={12} />
+6 -4
View File
@@ -337,10 +337,12 @@ export function Header({
>
{iconName ? <Icon name={iconName} color={iconColor ?? p.fg} size={16} /> : null}
{iconName ? <Spacer size={8} horizontal /> : null}
<Txt text={title} color={p.fg} size={14} weight="600" />
{count ? <Spacer size={6} horizontal /> : null}
{count ? <Txt text={count} color={p.muted} size={12} /> : null}
<Spacer />
{/* The title gives way (truncates) before the actions on the right. */}
<FlexWidget style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
<Txt text={title} color={p.fg} size={14} weight="600" />
{count ? <Spacer size={6} horizontal /> : null}
{count ? <Txt text={count} color={p.muted} size={12} /> : null}
</FlexWidget>
{trailing}
</FlexWidget>
<Divider p={p} />