style(favorites): Kachelansicht enger, lange Namen kleiner und zweizeilig
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m58s

Spaltenmindestbreite 76 -> 52 px (Abstand zwischen den Symbolen ~59 -> ~27 px
bei einer 373 px breiten Kachel). LauncherLabel misst den Titel einzeilig in
12 px; passt er nicht, 10 px, zweizeilig mit Silbentrennung und Auslassung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:33:20 +02:00
parent af87c2c112
commit c846eb49cb
3 changed files with 307 additions and 299 deletions
+1
View File
@@ -10,6 +10,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Geändert
- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen.
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
@@ -37,20 +37,20 @@ vi.mock('@/lib/dashboard-api', () => ({
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
}));
import { updateWidgetConfig } from '@/lib/dashboard-api';
import {
createFavorite,
deleteFavorite,
FavoriteRequestError,
fetchFavorites,
createFavorite,
updateFavorite,
deleteFavorite,
reorderFavorites,
uploadFavoriteIcon,
removeFavoriteIcon,
reorderFavorites,
updateFavorite,
uploadFavoriteIcon,
} from '@/lib/favorites-api';
import { updateWidgetConfig } from '@/lib/dashboard-api';
// Must import AFTER mocks
import { FavoritesWidget } from './favorites-widget';
import { FavoritesWidget, LauncherLabel } from './favorites-widget';
// Typed mock helpers
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
@@ -106,13 +106,7 @@ describe('FavoritesWidget', () => {
});
it('calls fetchFavorites with instanceId on mount and renders link titles', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
expect(mockFetch).toHaveBeenCalledWith('fav-1');
@@ -127,13 +121,7 @@ describe('FavoritesWidget', () => {
it('shows empty message when fetchFavorites resolves to empty array', async () => {
mockFetch.mockResolvedValue([]);
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
@@ -143,13 +131,7 @@ describe('FavoritesWidget', () => {
it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => {
mockFetch.mockResolvedValue([]);
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
@@ -189,13 +171,7 @@ describe('FavoritesWidget', () => {
});
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -239,13 +215,7 @@ describe('FavoritesWidget', () => {
});
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -271,13 +241,7 @@ describe('FavoritesWidget', () => {
});
it('view toggle: switching to grid in edit mode renders grid container', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{ viewMode: 'list' }}
isEditMode={true}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{ viewMode: 'list' }} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -299,13 +263,7 @@ describe('FavoritesWidget', () => {
});
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -318,24 +276,16 @@ describe('FavoritesWidget', () => {
// quick-260916-iex: optionaler Titel
describe('optionaler Titel (quick-260916-iex)', () => {
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
const r1 = render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
const r1 = render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
r1.unmount();
const r2 = render(
<FavoritesWidget
instanceId="fav-1"
config={{ title: ' ' }}
isEditMode={false}
/>,
<FavoritesWidget instanceId="fav-1" config={{ title: ' ' }} isEditMode={false} />,
);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -343,13 +293,7 @@ describe('FavoritesWidget', () => {
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
r2.unmount();
render(
<FavoritesWidget
instanceId="fav-1"
config={{ title: 42 }}
isEditMode={false}
/>,
);
render(<FavoritesWidget instanceId="fav-1" config={{ title: 42 }} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
@@ -358,11 +302,7 @@ describe('FavoritesWidget', () => {
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{ title: 'Werkzeuge' }}
isEditMode={false}
/>,
<FavoritesWidget instanceId="fav-1" config={{ title: 'Werkzeuge' }} isEditMode={false} />,
);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -371,15 +311,11 @@ describe('FavoritesWidget', () => {
expect(heading.tagName).toBe('H2');
expect(heading.className).toContain('text-sm');
expect(heading.className).toContain('font-semibold');
expect(
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
});
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
@@ -391,25 +327,17 @@ describe('FavoritesWidget', () => {
expect(titleField.className).toContain('widgetNoDrag');
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: /favorites\.listView/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /favorites\.gridView/i }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /favorites\.listView/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /favorites\.gridView/i })).toBeInTheDocument();
});
it('entprellt das Speichern des Titels um 1500 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await act(async () => {});
const titleField = screen.getByPlaceholderText(
'favorites.titlePlaceholder',
);
const titleField = screen.getByPlaceholderText('favorites.titlePlaceholder');
for (const value of ['W', 'We', 'Werkzeuge']) {
await act(async () => {
@@ -444,9 +372,7 @@ describe('FavoritesWidget', () => {
// Sortierpfeile im Bearbeitungsmodus.
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -460,9 +386,7 @@ describe('FavoritesWidget', () => {
});
it('Kette Proxy -> direkt -> Buchstabe', async () => {
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -561,12 +485,17 @@ describe('FavoritesWidget', () => {
it('kein Direktbild bei Nicht-http-URL', async () => {
mockFetch.mockResolvedValue([
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 },
{
id: 'fav-id-3',
widgetId: 'fav-1',
title: 'Ablage',
url: 'ftp://files.example',
iconUrl: null,
position: 0,
},
]);
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Ablage')).toBeInTheDocument();
@@ -583,9 +512,7 @@ describe('FavoritesWidget', () => {
{ ...BASE_FAVORITES[0], position: 1 },
]);
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -618,9 +545,7 @@ describe('FavoritesWidget', () => {
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
mockReorder.mockRejectedValue(new Error('boom'));
render(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -656,9 +581,7 @@ describe('FavoritesWidget', () => {
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
rerender(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
rerender(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
});
@@ -761,7 +684,7 @@ describe('FavoritesWidget', () => {
});
});
it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => {
it("Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon('fav-id-1', Datei); Formular schliesst", async () => {
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
mockUploadIcon.mockResolvedValue({
...BASE_FAVORITES[0],
@@ -789,7 +712,10 @@ describe('FavoritesWidget', () => {
});
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' }));
expect(mockUpdate).toHaveBeenCalledWith(
'fav-id-1',
expect.objectContaining({ title: 'GitHub' }),
);
});
await waitFor(() => {
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
@@ -857,12 +783,16 @@ describe('FavoritesWidget', () => {
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
});
it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => {
it("Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon('fav-id-1'), danach verschwindet der Knopf, Formular bleibt offen", async () => {
mockFetch.mockResolvedValue([
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
BASE_FAVORITES[1],
]);
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 });
mockRemoveIcon.mockResolvedValue({
...BASE_FAVORITES[0],
uploadedIconMime: null,
iconVersion: 2,
});
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
@@ -881,7 +811,9 @@ describe('FavoritesWidget', () => {
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
).not.toBeInTheDocument();
});
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
});
@@ -895,7 +827,9 @@ describe('FavoritesWidget', () => {
openEditForGitHub();
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
).not.toBeInTheDocument();
});
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
@@ -935,7 +869,11 @@ describe('FavoritesWidget', () => {
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }),
expect.objectContaining({
widgetId: 'fav-1',
title: 'New Link',
url: 'https://example.com',
}),
);
});
await waitFor(() => {
@@ -947,3 +885,31 @@ describe('FavoritesWidget', () => {
});
});
});
describe('LauncherLabel — Beschriftung in der Kachelansicht', () => {
afterEach(() => {
vi.restoreAllMocks();
});
function mockWidths(scroll: number, client: number) {
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(scroll);
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(client);
}
it('bleibt in normaler Groesse, wenn der Titel in eine Zeile passt', () => {
mockWidths(40, 60);
render(<LauncherLabel text="Wiki" />);
const label = screen.getByText('Wiki');
expect(label).not.toHaveAttribute('data-small');
expect(label.className).toContain('text-xs');
});
it('wird kleiner und zweizeilig, wenn der Titel nicht in eine Zeile passt', () => {
mockWidths(120, 60);
render(<LauncherLabel text="Reisekostenabrechnung" />);
const label = screen.getByText('Reisekostenabrechnung');
expect(label).toHaveAttribute('data-small', 'true');
expect(label.className).toContain('text-[10px]');
expect(label.className).toContain('line-clamp-2');
});
});
@@ -1,21 +1,21 @@
'use client';
import { WidgetIcon } from './widget-icon';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { type FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { updateWidgetConfig } from '@/lib/dashboard-api';
import {
FavoriteRequestError,
createFavorite,
deleteFavorite,
type FavoriteLink,
FavoriteRequestError,
fetchFavorites,
removeFavoriteIcon,
reorderFavorites,
updateFavorite,
uploadFavoriteIcon,
type FavoriteLink,
} from '@/lib/favorites-api';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { WidgetIcon } from './widget-icon';
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
const TITLE_DEBOUNCE_MS = 1500;
@@ -51,11 +51,7 @@ function favoriteErrorKey(err: unknown): string {
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
* persistiert ueber PUT /favorites/order
*/
export function FavoritesWidget({
instanceId,
config,
isEditMode,
}: WidgetProps) {
export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps) {
const t = useTranslations('widgets');
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
@@ -68,12 +64,8 @@ export function FavoritesWidget({
);
// Optionaler Titel (quick-260916-iex)
const [title, setTitle] = useState<string>(
typeof config.title === 'string' ? config.title : '',
);
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '');
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
return () => clearTimeout(titleTimerRef.current);
@@ -335,15 +327,15 @@ export function FavoritesWidget({
/>
) : (
hasTitle && (
<h2 className="truncate text-sm font-semibold text-foreground">
{title.trim()}
</h2>
<h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>
)
)}
</div>
)}
<div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}>
<div
className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}
>
{/* View mode toggle (edit mode only) */}
{isEditMode && (
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
@@ -382,23 +374,20 @@ export function FavoritesWidget({
)}
{/* Status messages */}
{loading && (
<p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{loading && <p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
{/* Favorites list / grid */}
{!loading && sortedFavorites.length === 0 && (
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
)}
{!loading && sortedFavorites.length > 0 && (
viewMode === 'grid' ? (
{!loading &&
sortedFavorites.length > 0 &&
(viewMode === 'grid' ? (
<div
data-testid="favorites-grid"
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1"
className="grid grid-cols-[repeat(auto-fill,minmax(52px,1fr))] gap-1"
>
{sortedFavorites.map((fav, index) => (
<FavoriteTile
@@ -430,10 +419,7 @@ export function FavoritesWidget({
))}
</div>
) : (
<div
data-testid="favorites-list"
className="flex flex-col gap-0.5"
>
<div data-testid="favorites-list" className="flex flex-col gap-0.5">
{sortedFavorites.map((fav, index) => (
<FavoriteTile
key={fav.id}
@@ -462,8 +448,7 @@ export function FavoritesWidget({
/>
))}
</div>
)
)}
))}
{/* Add form (edit mode only, D-04) */}
{isEditMode && (
@@ -677,6 +662,60 @@ type FavoriteTileProps = {
launcher?: boolean;
};
/**
* Beschriftung einer Favoriten-Kachel in der Kachelansicht. Passt der Titel
* in normaler Groesse (12 px) nicht in eine Zeile, wuerde er an die
* Nachbarkachel stossen bzw. mitten im Wort umbrechen — dann wird die Schrift
* kleiner (10 px) und darf zweizeilig umbrechen (Silbentrennung, danach "…").
* Gemessen wird immer in normaler Groesse und einzeilig, damit das Ergebnis
* nicht von der eigenen Umschaltung abhaengt (keine Rueckkopplung). Der
* ResizeObserver misst neu, wenn die Kachel breiter oder schmaler wird;
* scrollWidth/clientWidth sind Layoutmasse und von der Dashboard-Skalierung
* (transform) unberuehrt.
*/
export function LauncherLabel({ text }: { text: string }) {
const ref = useRef<HTMLSpanElement>(null);
const [small, setSmall] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: neuer Titel = neu messen, der Wert selbst steht im DOM
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const prev = {
whiteSpace: el.style.whiteSpace,
fontSize: el.style.fontSize,
display: el.style.display,
};
el.style.whiteSpace = 'nowrap';
el.style.fontSize = '12px';
el.style.display = 'block';
const overflow = el.scrollWidth > el.clientWidth + 1;
el.style.whiteSpace = prev.whiteSpace;
el.style.fontSize = prev.fontSize;
el.style.display = prev.display;
setSmall(overflow);
};
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [text]);
return (
<span
ref={ref}
data-small={small ? 'true' : undefined}
className={`line-clamp-2 w-full break-words hyphens-auto ${
small ? 'text-[10px] leading-3' : 'text-xs leading-4'
}`}
>
{text}
</span>
);
}
function FavoriteTile({
fav,
isEditMode,
@@ -728,9 +767,11 @@ function FavoriteTile({
large={launcher}
/>
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}>
{fav.title}
</span>
{launcher ? (
<LauncherLabel text={fav.title} />
) : (
<span className="truncate text-sm">{fav.title}</span>
)}
</a>
{/* Action buttons (edit mode only, D-04) */}