feat(catalog): review fixes for catalog search
All checks were successful
CI / flutter (pull_request) Successful in 1m46s
CI / web-build (pull_request) Successful in 59s
CI / supabase (pull_request) Successful in 12s
CI / supabase-tests (pull_request) Successful in 31s
CI / e2e (pull_request) Successful in 2m54s

Word limit and no-retry state for rejected input, nested /catalog route for
system back, logger filter for supabase libs, more mutation-proof tests,
doc fix, umlaut-aware term sorting, thousands separators. Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-04 10:17:54 +01:00
parent ae099eb3d7
commit 25212929ce
24 changed files with 310 additions and 62 deletions

View file

@ -48,7 +48,17 @@ class AppLogger {
_subscription = null;
}
/// Whether a record is written. Records of the Supabase libraries below
/// INFO are dropped: postgrest logs `'$error from request'` at FINE, and the
/// error text can echo request parameters (search terms, QUE-04).
@visibleForTesting
static bool shouldWrite(LogRecord record) =>
!(record.loggerName.startsWith('supabase') && record.level < Level.INFO);
static void _write(LogRecord record) {
if (!shouldWrite(record)) {
return;
}
developer.log(
record.message,
time: record.time,

View file

@ -24,9 +24,9 @@ GoRouter appRouter(Ref ref) {
GoRoute(
path: AppRoutes.home,
builder: (context, state) => const HomeScreen(),
routes: catalogRoutes,
),
...authRoutes,
...catalogRoutes,
],
);
ref.onDispose(router.dispose);

View file

@ -48,4 +48,4 @@ final class AppRouterProvider
}
}
String _$appRouterHash() => r'e5b9cf23cd96b28b8941552d226d592a1e06efc1';
String _$appRouterHash() => r'cb20752ab64cf1ac56d3bfce5401e187246f5ec7';

View file

@ -5,6 +5,9 @@ abstract final class AppRoutes {
/// Katalogsuche, öffentlich (Spec #3 § 5.5).
static const catalog = '/catalog';
/// Segment of [catalog] below [home] (nested route).
static const catalogSegment = 'catalog';
// Auth (Spec #1 § 5.3)
static const login = '/login';
static const register = '/register';

View file

@ -18,7 +18,6 @@ class AppListFooter extends StatelessWidget {
required this.onRetry,
super.key,
this.errorMessage,
this.loadMoreFocusNode,
});
final bool isLoading;
@ -32,10 +31,6 @@ class AppListFooter extends StatelessWidget {
final VoidCallback onLoadMore;
final VoidCallback onRetry;
/// Focus node of the "load more" button (callers restore focus elsewhere
/// after loading).
final FocusNode? loadMoreFocusNode;
@override
Widget build(BuildContext context) {
final message = errorMessage;
@ -79,7 +74,6 @@ class AppListFooter extends StatelessWidget {
} else if (hasMore) {
content = OutlinedButton(
key: const Key('list_footer_load_more'),
focusNode: loadMoreFocusNode,
onPressed: onLoadMore,
child: Text(loadMoreLabel),
);

View file

@ -19,6 +19,9 @@ abstract final class CatalogSearchLimits {
/// "Suitable from X years" steps.
static const List<int> ageSteps = [6, 8, 10, 12, 14, 16, 18];
/// The backend rejects search texts with more words.
static const int maxWords = 10;
/// Most genres or mechanics that can be selected at once.
static const int maxTerms = 20;
@ -77,12 +80,25 @@ abstract class CatalogSearchQuery with _$CatalogSearchQuery {
const CatalogSearchQuery._();
/// Trims [raw] and cuts it to the maximum length.
static final RegExp _word = RegExp(r'[\p{L}\p{N}]+', unicode: true);
/// Trims [raw] and cuts it to the maximum length and to at most
/// [CatalogSearchLimits.maxWords] words. The backend counts words after its
/// normalization (punctuation separates words, one-letter words count) and
/// rejects more; this is the client-side approximation, the rest is cut off
/// silently.
static String cleanText(String raw) {
final trimmed = raw.trim();
return trimmed.length > CatalogSearchLimits.maxTextLength
? trimmed.substring(0, CatalogSearchLimits.maxTextLength).trim()
: trimmed;
var text = raw.trim();
if (text.length > CatalogSearchLimits.maxTextLength) {
text = text.substring(0, CatalogSearchLimits.maxTextLength).trim();
}
var count = 0;
for (final match in _word.allMatches(text)) {
if (++count == CatalogSearchLimits.maxWords) {
return text.substring(0, match.end);
}
}
return text;
}
/// Whether the text is long enough to count as a search term.

View file

@ -4,9 +4,14 @@ import 'package:shuffle/features/catalog/presentation/catalog_query_params.dart'
import 'package:shuffle/features/catalog/presentation/search/catalog_search_screen.dart';
/// Routes of the catalog (Spec #3 § 5.5). `/catalog` is public.
///
/// These are sub-routes of the start page (path `catalog` below `/`), so the
/// URL stays `/catalog`, but a deep link builds the stack [home, catalog] and
/// the system back button (Android) returns to the start page instead of
/// closing the app.
final List<RouteBase> catalogRoutes = [
GoRoute(
path: AppRoutes.catalog,
path: AppRoutes.catalogSegment,
builder: (context, state) => CatalogSearchScreen(
initialQuery: parseCatalogQuery(state.uri.queryParameters),
),

View file

@ -424,14 +424,20 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
),
);
case CatalogSearchStatus.error:
final offline = state.failure is CatalogNetworkFailure;
final failure = state.failure;
final offline = failure is CatalogNetworkFailure;
// Rejected input does not get better by repeating it: no retry.
final invalid = failure is CatalogInvalidInputFailure;
return AppStateMessage(
key: const Key('catalog_error'),
icon: offline ? Icons.wifi_off : Icons.error_outline,
title: offline
? l10n.catalogSearchErrorOffline
: invalid
? l10n.catalogSearchErrorInvalid
: l10n.catalogSearchErrorGeneric,
actions: [
if (!invalid)
AppStateAction(
key: const Key('catalog_error_retry'),
label: l10n.catalogSearchRetry,

View file

@ -174,10 +174,9 @@ class _TermChoice extends StatelessWidget {
data: (list) {
final sorted = [...list]
..sort(
(a, b) => a
.localizedName(language)
.toLowerCase()
.compareTo(b.localizedName(language).toLowerCase()),
(a, b) =>
_sortKey(a.localizedName(language))
.compareTo(_sortKey(b.localizedName(language))),
);
final full = selected.length >= CatalogSearchLimits.maxTerms;
return Wrap(
@ -239,3 +238,33 @@ class _TermChoice extends StatelessWidget {
);
}
}
/// Sort key that puts umlauts and accents next to their base letters.
String _sortKey(String name) {
const map = {
'ä': 'a',
'ö': 'o',
'ü': 'u',
'ß': 'ss',
'é': 'e',
'è': 'e',
'ê': 'e',
'á': 'a',
'à': 'a',
'â': 'a',
'í': 'i',
'ó': 'o',
'ô': 'o',
'ú': 'u',
'ç': 'c',
'ñ': 'n',
'æ': 'ae',
'œ': 'oe',
};
final buffer = StringBuffer();
for (final rune in name.toLowerCase().runes) {
final char = String.fromCharCode(rune);
buffer.write(map[char] ?? char);
}
return buffer.toString();
}

View file

@ -115,5 +115,6 @@
"catalogSearchEmptyCatalog": "Der Katalog ist noch leer.",
"catalogSearchReportGame": "Fehlendes Spiel melden",
"catalogSearchErrorOffline": "Keine Verbindung. Für die Suche brauchst du Internet.",
"catalogSearchErrorGeneric": "Die Suche ist gerade nicht möglich."
"catalogSearchErrorGeneric": "Die Suche ist gerade nicht möglich.",
"catalogSearchErrorInvalid": "Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter."
}

View file

@ -169,9 +169,9 @@
"catalogSearchRetry": "Try again",
"@catalogSearchRetry": {"description": "Retry button"},
"catalogSearchResultCount": "{count, plural, =0{No games} =1{1 game} other{{count} games}}",
"@catalogSearchResultCount": {"description": "Number of hits above the list", "placeholders": {"count": {"type": "int"}}},
"@catalogSearchResultCount": {"description": "Number of hits above the list", "placeholders": {"count": {"type": "int", "format": "decimalPattern"}}},
"catalogSearchAnnouncement": "{count, plural, =0{No games found} =1{1 game found} other{{count} games found}}",
"@catalogSearchAnnouncement": {"description": "Screen reader announcement after a finished search", "placeholders": {"count": {"type": "int"}}},
"@catalogSearchAnnouncement": {"description": "Screen reader announcement after a finished search", "placeholders": {"count": {"type": "int", "format": "decimalPattern"}}},
"catalogSearchAlsoKnownAs": "also: {title}",
"@catalogSearchAlsoKnownAs": {"description": "Alternative title that matched", "placeholders": {"title": {"type": "String"}}},
"catalogSearchPlayersCount": "{count, plural, =1{1 player} other{{count} players}}",
@ -205,5 +205,7 @@
"catalogSearchErrorOffline": "No connection. You need internet for the search.",
"@catalogSearchErrorOffline": {"description": "Error state: offline"},
"catalogSearchErrorGeneric": "Search is not available right now.",
"@catalogSearchErrorGeneric": {"description": "Error state: other errors"}
"@catalogSearchErrorGeneric": {"description": "Error state: other errors"},
"catalogSearchErrorInvalid": "This search is not supported. Change the search text or the filters.",
"@catalogSearchErrorInvalid": {"description": "Error state: the backend rejected the input (no retry offered)"}
}

View file

@ -793,6 +793,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Search is not available right now.'**
String get catalogSearchErrorGeneric;
/// Error state: the backend rejected the input (no retry offered)
///
/// In en, this message translates to:
/// **'This search is not supported. Change the search text or the filters.'**
String get catalogSearchErrorInvalid;
}
class _AppLocalizationsDelegate

View file

@ -359,10 +359,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String catalogSearchResultCount(int count) {
final intl.NumberFormat countNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String countString = countNumberFormat.format(count);
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Spiele',
other: '$countString Spiele',
one: '1 Spiel',
zero: 'Keine Spiele',
);
@ -371,10 +375,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String catalogSearchAnnouncement(int count) {
final intl.NumberFormat countNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String countString = countNumberFormat.format(count);
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Spiele gefunden',
other: '$countString Spiele gefunden',
one: '1 Spiel gefunden',
zero: 'Keine Spiele gefunden',
);
@ -457,4 +465,8 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get catalogSearchErrorGeneric => 'Die Suche ist gerade nicht möglich.';
@override
String get catalogSearchErrorInvalid =>
'Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter.';
}

View file

@ -355,10 +355,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String catalogSearchResultCount(int count) {
final intl.NumberFormat countNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String countString = countNumberFormat.format(count);
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count games',
other: '$countString games',
one: '1 game',
zero: 'No games',
);
@ -367,10 +371,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String catalogSearchAnnouncement(int count) {
final intl.NumberFormat countNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String countString = countNumberFormat.format(count);
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count games found',
other: '$countString games found',
one: '1 game found',
zero: 'No games found',
);
@ -451,4 +459,8 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get catalogSearchErrorGeneric => 'Search is not available right now.';
@override
String get catalogSearchErrorInvalid =>
'This search is not supported. Change the search text or the filters.';
}

View file

@ -29,4 +29,19 @@ void main() {
expect(records.last.level, Level.SEVERE);
expect(records.last.loggerName, 'Test');
});
test('QUE-04: Supabase library records below INFO are not written', () {
expect(
AppLogger.shouldWrite(LogRecord(Level.FINE, 'x', 'supabase.postgrest')),
isFalse,
);
expect(
AppLogger.shouldWrite(LogRecord(Level.WARNING, 'x', 'supabase.auth')),
isTrue,
);
expect(
AppLogger.shouldWrite(LogRecord(Level.FINE, 'x', 'CatalogRepository')),
isTrue,
);
});
}

View file

@ -160,31 +160,5 @@ void main() {
expect(find.byType(OutlinedButton), findsNothing);
expect(find.byKey(const Key('list_footer_loading')), findsNothing);
});
testWidgets('the load-more button takes a focus node', (tester) async {
final node = FocusNode();
addTearDown(node.dispose);
await pumpApp(
tester,
home: Scaffold(
body: AppListFooter(
isLoading: false,
hasMore: true,
loadingLabel: 'Lädt',
loadMoreLabel: 'Weitere laden',
retryLabel: 'Erneut versuchen',
onLoadMore: () {},
onRetry: () {},
loadMoreFocusNode: node,
),
),
settle: false,
);
node.requestFocus();
await tester.pump();
expect(node.hasPrimaryFocus, isTrue);
});
});
}

View file

@ -16,6 +16,7 @@ import '../../helpers/fakes.dart';
const genreStrategy = '11111111-1111-4111-8111-111111111111';
const genreFamily = '22222222-2222-4222-8222-222222222222';
const mechanicDice = '33333333-3333-4333-8333-333333333333';
const mechanicCards = '44444444-4444-4444-8444-444444444444';
const testGenres = [
CatalogTerm(id: genreStrategy, nameDe: 'Strategie', nameEn: 'Strategy'),

View file

@ -126,6 +126,31 @@ void main() {
expect(body(requests.single), isNot(contains('p_mechanic_ids')));
});
test(
'KAT-01 Kriterium 4: several genre and mechanic IDs are all sent',
() async {
await repository.search(
const CatalogSearchQuery(
filters: CatalogFilters(
genreIds: {genreStrategy, genreFamily},
mechanicIds: {mechanicDice, mechanicCards},
),
),
offset: 0,
);
final sent = body(requests.single);
expect(
sent['p_genre_ids'],
unorderedEquals([genreStrategy, genreFamily]),
);
expect(
sent['p_mechanic_ids'],
unorderedEquals([mechanicDice, mechanicCards]),
);
},
);
test('KAT-01 Kriterium 4: mechanics are sent as p_mechanic_ids', () async {
await repository.search(
const CatalogSearchQuery(
@ -285,7 +310,7 @@ void main() {
expect(result.errorOrNull, isA<CatalogUnexpectedFailure>());
});
test('QUE-04: logs the failure kind, never the search text', () async {
test('QUE-04: the repository logger never logs the search text', () async {
const secret = 'geheimer-suchbegriff';
final records = <LogRecord>[];
Logger.root.level = Level.ALL;
@ -304,6 +329,17 @@ void main() {
offset: 0,
);
// Any other library may echo the text in its own debug logs (postgrest
// does at FINE); AppLogger drops those, so none of them is written.
for (final record in records.where(
(r) => '${r.message} ${r.error}'.contains(secret),
)) {
expect(
AppLogger.shouldWrite(record),
isFalse,
reason: '${record.loggerName} would write the search text',
);
}
final catalog = records.where((r) => r.loggerName == 'CatalogRepository');
expect(catalog, isNotEmpty);
for (final record in catalog) {

View file

@ -41,6 +41,35 @@ void main() {
expect(CatalogSearchQuery.cleanText('a' * 150), hasLength(100));
});
test(
'Eingabegrenzen: the text is cut to 10 words (rest dropped silently)',
() {
expect(
CatalogSearchQuery.cleanText('a b c d e f g h i j k l'),
'a b c d e f g h i j',
);
expect(
CatalogSearchQuery.cleanText('a b c d e f g h i j'),
'a b c d e f g h i j',
);
expect(CatalogSearchQuery.cleanText('a b c'), 'a b c');
},
);
test('Eingabegrenzen: punctuation separates words like in the backend', () {
// Hyphen and colon split words: 12 words, not 6.
expect(
CatalogSearchQuery.cleanText('a-b c:d e-f g-h i-j k-l'),
'a-b c:d e-f g-h i-j',
);
expect(
CatalogSearchQuery.cleanText(
'Æon’s End: Ünïcode Wörter ß 1 2 3 4 5 6 7',
),
'Æon’s End: Ünïcode Wörter ß 1 2 3 4',
);
});
test('KAT-01 Kriterium 4: activeFilterCount counts every filter value', () {
const filters = CatalogFilters(
players: 3,

View file

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
import 'package:shuffle/features/catalog/domain/catalog_term.dart';
import '../../catalog_test_support.dart';
import 'catalog_search_test_support.dart';
@ -142,6 +143,34 @@ void main() {
expect(de, lessThan(de2));
});
testWidgets('terms with umlauts sort next to their base letters', (
tester,
) async {
final repository = FakeCatalogRepository(
all: [game(1)],
genres: const [
CatalogTerm(id: genreStrategy, nameDe: 'Zocken', nameEn: 'Z'),
CatalogTerm(id: genreFamily, nameDe: 'Ökonomie', nameEn: 'E'),
CatalogTerm(
id: '55555555-5555-4555-8555-555555555555',
nameDe: 'Party',
nameEn: 'P',
),
],
);
await pumpView(tester, repository);
await openFilterSheet(tester);
final party = tester.getTopLeft(find.text('Party'));
final oekonomie = tester.getTopLeft(find.text('Ökonomie'));
final zocken = tester.getTopLeft(find.text('Zocken'));
bool before(Offset a, Offset b) =>
a.dy < b.dy || (a.dy == b.dy && a.dx < b.dx);
// Ö sorts as O: Ökonomie, Party, Zocken.
expect(before(oekonomie, party), isTrue);
expect(before(party, zocken), isTrue);
});
testWidgets('QUE-05 Kriterium 4: English names and order', (tester) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository, locale: const Locale('en'));

View file

@ -302,6 +302,31 @@ void main() {
expect(state().loadingMore, isFalse);
});
run('KAT-01 Kriterium 5: a stale loadMore answer is not appended', (
tester,
) async {
final repo = FakeCatalogRepository(manual: true);
useRepository(repo);
controller().init(const CatalogSearchQuery());
await tester.pump();
repo.complete(0, items: [game(1)], totalCount: 3);
await tester.pump();
unawaited(controller().loadMore());
await tester.pump();
// A new search starts while the next page is still on its way ...
controller().submitText('neu');
await tester.pump();
// ... and the old page arrives before the new answer.
repo.complete(1, items: [game(2), game(3)], totalCount: 3);
await tester.pump();
expect(state().items.map((g) => g.id), ['game-1']);
repo.complete(2, items: [game(9, title: 'Neu')], totalCount: 1);
await tester.pump();
expect(state().items.map((g) => g.title), ['Neu']);
});
run('KAT-01 Kriterium 5: an error while loading more keeps the hits', (
tester,
) async {

View file

@ -82,6 +82,26 @@ void main() {
expect(repository.calls.first.query.isEmpty, isTrue);
});
testWidgets('Android: system back from a deep link returns to the start '
'page instead of closing the app', (tester) async {
useWindow(tester, 420);
final repository = FakeCatalogRepository(all: [game(1)]);
final container = await pumpShuffleApp(
tester,
overrides: catalogOverrides(repository),
);
container.read(appRouterProvider).go('/catalog?q=catan');
await tester.pumpAndSettle();
expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget);
// The framework's system back; `false` would mean the app closes.
final handled = await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(handled, isTrue);
expect(find.byKey(const Key('home_screen')), findsOneWidget);
});
testWidgets('start page entry opens the search and back returns', (
tester,
) async {
@ -133,7 +153,13 @@ void main() {
),
);
final router = buildTestRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const SizedBox(),
routes: catalogRoutes,
),
],
initialLocation: initialLocation,
);
addTearDown(router.dispose);

View file

@ -362,6 +362,23 @@ void main() {
expect(find.byKey(const Key('catalog_error')), findsNothing);
});
testWidgets('rejected input shows an own text and offers no retry', (
tester,
) async {
final repository = FakeCatalogRepository()
..failWith = const CatalogFailure.invalidInput();
await pumpView(tester, repository);
expect(
find.text(
'Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter.',
),
findsOneWidget,
);
expect(find.byKey(const Key('catalog_error_retry')), findsNothing);
});
testWidgets('Zustände Kriterium 2: other errors show the generic text', (
tester,
) async {

View file

@ -87,13 +87,13 @@ Formular-Komponenten (Texte von außen, Tests gegen die Guidelines in Hell und D
| Komponente | Zweck und API (Kurzform) |
|---|---|
| `AppStateMessage` | Leer-/Fehlerzustand: `icon` (dekorativ, für Screenreader ausgeblendet), `title`, optional `message`, `actions` (`AppStateAction(label, onPressed, key)`; die erste Aktion ist tonal hervorgehoben, weitere umrandet; alle 48 dp). Titel und Hinweis sind eine Live-Region. |
| `AppListFooter` | Fuß einer seitenweise geladenen Liste: `isLoading` (beschrifteter Fortschrittskreis), `hasMore` (Schaltfläche „Weitere laden“, per Tastatur und Screenreader erreichbar, optional `loadMoreFocusNode`), `errorMessage` + Wiederholen (bereits geladene Einträge bleiben). Vorrang: Fehler vor Laden vor „Weitere laden“. Automatisches Nachladen ist Sache des Aufrufers; die Schaltfläche bleibt der Weg für Tastatur und Screenreader. |
| `AppListFooter` | Fuß einer seitenweise geladenen Liste: `isLoading` (beschrifteter Fortschrittskreis), `hasMore` (Schaltfläche „Weitere laden“, per Tastatur und Screenreader erreichbar), `errorMessage` + Wiederholen (bereits geladene Einträge bleiben). Vorrang: Fehler vor Laden vor „Weitere laden“. Automatisches Nachladen ist Sache des Aufrufers; die Schaltfläche bleibt der Weg für Tastatur und Screenreader. |
**Katalogsuche (`features/catalog/presentation/search/`)** – Aufbau und Verhalten, Entscheidungen der Umsetzung (Spec #3 § 5.6):
- Kompakt (< 600 dp) und mittel (600–1023 dp): Suchfeld, darunter die Schaltfläche „Filter (n)“ mit Bottom Sheet (Änderungen wirken sofort, „Fertig“ schließt); ab 1024 dp feste Seitenleiste (320 dp) links, Trefferliste rechts mit höchstens 720 dp Breite.
- Einfachauswahl (Spielerzahl, Dauer, Alter) als beschriftete Auswahlfelder mit „Beliebig“; Genre und Mechanik als Filter-Chips, nach lokalisiertem Namen sortiert. Fehler beim Laden der Begriffe betrifft nur diese beiden Bereiche („Erneut versuchen“).
- Trefferkachel: `GameCover` klein (Platzhalter), Titel (2 Zeilen), „auch: …“, Jahr · Verlage (zwei, sonst „+n“), Kurzangaben; ein zusammengefasstes Semantics-Label. Ohne `onTap` ist die Kachel kein Button, aber per Code fokussierbar (Fokusring 3 dp, kein Tab-Stopp).
- Trefferkachel: `GameCover` klein (Platzhalter), Titel (2 Zeilen), „auch: …“, Jahr · Verlage (zwei, sonst „+n“), Kurzangaben; ein zusammengefasstes Semantics-Label. Ohne `onTap` ist die Kachel kein Button, aber ein Tab-Stopp mit Fokusring (3 dp): Tastaturnutzer durchlaufen so die Liste, was sie scrollt und das Nachladen auslöst (die Liste wird lazy gebaut). Im Screenreader-Durchgang bewerten, ob die vielen Tab-Stopps stören.
- Trefferzahl über der Liste; Ansage der Trefferzahl 600 ms nach Abschluss der letzten Suche (`SemanticsService.sendAnnouncement`), nie während des Tippens.
- Nach „Weitere laden“ (bzw. „Erneut versuchen“ im Listenfuß) springt der Fokus auf die erste neu geladene Kachel. Automatisches Nachladen, wenn weniger als etwa fünf Kacheln unterhalb des sichtbaren Bereichs liegen; nach einem Fehler nur noch über die Schaltfläche (keine Wiederholungsschleife).