Vatxzz commited on
Commit
52e2700
·
1 Parent(s): 2ae9919

polished the app

Browse files
Files changed (37) hide show
  1. .gitignore +3 -1
  2. app/lib/data/repositories/card_repository.dart +3 -0
  3. app/lib/data/services/api_client.dart +19 -1
  4. app/lib/data/services/local_store.dart +10 -0
  5. app/lib/ui/core/home_shell.dart +4 -42
  6. app/lib/ui/core/theme.dart +11 -1
  7. app/lib/ui/core/widgets/loading_tiles.dart +17 -11
  8. app/lib/ui/core/widgets/processing_glyph.dart +1 -1
  9. app/lib/ui/features/actions/view_models/actions_view_model.dart +2 -1
  10. app/lib/ui/features/catalog/view_models/catalog_view_model.dart +2 -1
  11. app/lib/ui/features/collections/view_models/collections_view_model.dart +2 -1
  12. app/lib/ui/features/collections/views/folder_picker_sheet.dart +166 -0
  13. app/lib/ui/features/concepts/view_models/concepts_view_model.dart +2 -1
  14. app/lib/ui/features/feed/views/knowledge_feed_screen.dart +4 -2
  15. app/lib/ui/features/library/view_models/library_view_model.dart +57 -6
  16. app/lib/ui/features/library/views/library_dialogs.dart +44 -0
  17. app/lib/ui/features/library/views/library_screen.dart +19 -50
  18. app/lib/ui/features/onboarding/views/name_screen.dart +3 -17
  19. app/lib/ui/features/onboarding/views/onboarding_screen.dart +10 -64
  20. app/lib/ui/features/onboarding/views/splash_screen.dart +1 -1
  21. app/lib/ui/features/profile/views/profile_screen.dart +10 -3
  22. app/lib/ui/features/reader/views/insight_section.dart +1 -1
  23. app/lib/ui/features/reader/views/reader_screen.dart +8 -4
  24. app/lib/ui/features/share/view_models/share_view_model.dart +3 -3
  25. app/lib/ui/features/share/views/share_screen.dart +3 -2
  26. app/test/api_exception_test.dart +25 -0
  27. app/test/bulk_move_test.dart +53 -0
  28. app/test/library_wakeup_test.dart +52 -0
  29. app/test/local_store_test.dart +20 -0
  30. app/test/reduced_motion_test.dart +35 -0
  31. app/web/index.html +1 -1
  32. docs/planning/plans/2026-07-10-backend-auth-quotas.md +790 -0
  33. docs/planning/plans/2026-07-10-flutter-auth.md +625 -0
  34. docs/planning/plans/2026-07-10-ui-trust-polish.md +435 -0
  35. docs/planning/plans/2026-07-10-v2-on-device-ai.md +76 -0
  36. docs/planning/specs/2026-07-10-public-distribution-auth-quotas-design.md +133 -0
  37. docs/planning/specs/2026-07-10-v2-on-device-ai-design.md +89 -0
.gitignore CHANGED
@@ -47,4 +47,6 @@ i-want-you-to-spicy-dusk.md
47
  CACHY_OVERVIEW.md
48
  # Root-level Python prototype agent only — anchored so it does NOT swallow the
49
  # Flutter feature dir app/lib/ui/features/presenter/.
50
- /presenter/
 
 
 
47
  CACHY_OVERVIEW.md
48
  # Root-level Python prototype agent only — anchored so it does NOT swallow the
49
  # Flutter feature dir app/lib/ui/features/presenter/.
50
+ /presenter/
51
+ PRODUCT.md
52
+ .impeccable/critique/2026-07-10T11-11-16Z__app-lib.md
app/lib/data/repositories/card_repository.dart CHANGED
@@ -159,6 +159,9 @@ class CardRepository extends ChangeNotifier {
159
  return card;
160
  }
161
 
 
 
 
162
  Future<void> delete(String cardId) async {
163
  await _store.removeCard(cardId);
164
  try {
 
159
  return card;
160
  }
161
 
162
+ /// Clear all locally cached cards. Returns how many were removed.
163
+ Future<int> clearCardCache() => _store.clearCardCache();
164
+
165
  Future<void> delete(String cardId) async {
166
  await _store.removeCard(cardId);
167
  try {
app/lib/data/services/api_client.dart CHANGED
@@ -21,11 +21,29 @@ import '../../domain/models/pipeline_event.dart';
21
  class ApiException implements Exception {
22
  ApiException(this.statusCode, this.message);
23
  final int statusCode;
24
- final String message;
 
 
 
 
 
 
 
 
 
 
 
25
  @override
26
  String toString() => 'ApiException($statusCode): $message';
27
  }
28
 
 
 
 
 
 
 
 
29
  class CreateCardResult {
30
  const CreateCardResult({
31
  required this.cardId,
 
21
  class ApiException implements Exception {
22
  ApiException(this.statusCode, this.message);
23
  final int statusCode;
24
+ final String message; // raw body — for logs only, never for UI
25
+
26
+ /// What users see. Raw bodies (which may include server details or
27
+ /// tracebacks) never leave the data layer.
28
+ String get friendlyMessage => switch (statusCode) {
29
+ 401 || 403 => 'Session expired — please sign in again.',
30
+ 404 => "That card isn't there anymore.",
31
+ 429 => "You've hit today's limit. It resets at midnight UTC.",
32
+ >= 500 => 'Something went wrong on our side. Try again in a moment.',
33
+ _ => "That didn't work. Try again.",
34
+ };
35
+
36
  @override
37
  String toString() => 'ApiException($statusCode): $message';
38
  }
39
 
40
+ /// UI-safe message for any thrown object. The only thing view-models should
41
+ /// ever store in a user-visible error field.
42
+ String friendlyError(Object e) => switch (e) {
43
+ ApiException api => api.friendlyMessage,
44
+ _ => "Can't reach Cachy. Check your connection.",
45
+ };
46
+
47
  class CreateCardResult {
48
  const CreateCardResult({
49
  required this.cardId,
app/lib/data/services/local_store.dart CHANGED
@@ -90,6 +90,16 @@ class LocalStore {
90
  await _prefs.setStringList(_indexKey, index.toList());
91
  }
92
 
 
 
 
 
 
 
 
 
 
 
93
  // --------------------------------------------------------------------- //
94
  // Offline share queue
95
  // --------------------------------------------------------------------- //
 
90
  await _prefs.setStringList(_indexKey, index.toList());
91
  }
92
 
93
+ /// Remove every cached card and the index. Returns how many were removed.
94
+ Future<int> clearCardCache() async {
95
+ final ids = cachedCardIds();
96
+ for (final id in ids) {
97
+ await _prefs.remove('$_cardPrefix$id');
98
+ }
99
+ await _prefs.remove(_indexKey);
100
+ return ids.length;
101
+ }
102
+
103
  // --------------------------------------------------------------------- //
104
  // Offline share queue
105
  // --------------------------------------------------------------------- //
app/lib/ui/core/home_shell.dart CHANGED
@@ -16,14 +16,15 @@ import '../features/actions/views/actions_screen.dart';
16
  import '../features/capture/views/capture_sheet.dart';
17
  import '../features/collections/views/collections_screen.dart';
18
  import '../features/feed/views/knowledge_feed_screen.dart';
 
19
  import '../features/library/view_models/library_view_model.dart';
 
20
  import '../features/library/views/library_screen.dart';
21
  import '../features/profile/views/profile_screen.dart';
22
  import '../features/reader/views/reader_screen.dart';
23
  import 'brand.dart';
24
  import 'theme.dart';
25
  import 'ui_bus.dart';
26
- import 'widgets/adaptive_modal.dart';
27
  import 'widgets/glass.dart';
28
  import 'widgets/selection_action_bar.dart';
29
 
@@ -167,49 +168,12 @@ class _HomeShellState extends State<HomeShell> {
167
  bottomNavigationBar: selecting
168
  ? _SelectionNavSlot(
169
  vm: vm,
170
- onConfirmDelete: () => _confirmBulkDelete(context, vm),
171
  )
172
  : _GlassNav(index: _index, onSelect: _select),
173
  );
174
  }
175
 
176
- Future<void> _confirmBulkDelete(
177
- BuildContext context, LibraryViewModel vm) async {
178
- final count = vm.selectedCount;
179
- final ok = await showAdaptiveModal<bool>(
180
- context: context,
181
- builder: (ctx, dialog) => AlertDialog(
182
- title: Text('Delete $count ${count == 1 ? 'card' : 'cards'}?'),
183
- content: const Text(
184
- 'This removes the cards and their media. This cannot be undone.'),
185
- actions: [
186
- TextButton(
187
- onPressed: () => Navigator.pop(ctx, false),
188
- child: const Text('Cancel'),
189
- ),
190
- FilledButton(
191
- onPressed: () => Navigator.pop(ctx, true),
192
- style: FilledButton.styleFrom(
193
- backgroundColor: Theme.of(ctx).colorScheme.error,
194
- ),
195
- child: const Text('Delete'),
196
- ),
197
- ],
198
- ),
199
- );
200
- if (ok == true && context.mounted) {
201
- await vm.bulkDelete();
202
- if (context.mounted) {
203
- final error = context.read<LibraryViewModel>().error;
204
- if (error != null) {
205
- ScaffoldMessenger.of(context).showSnackBar(
206
- SnackBar(content: Text('Delete failed: $error')),
207
- );
208
- }
209
- }
210
- }
211
- }
212
-
213
  Widget _desktopShell(BuildContext context) {
214
  return Scaffold(
215
  body: Stack(
@@ -480,9 +444,7 @@ class _SelectionNavSlot extends StatelessWidget {
480
  child: SelectionActionBar(
481
  selectedCount: vm.selectedCount,
482
  onClose: vm.clearSelection,
483
- onMoveToFolder: () => ScaffoldMessenger.of(context).showSnackBar(
484
- const SnackBar(content: Text('Move to Folder coming soon')),
485
- ),
486
  onDeleteSelected: onConfirmDelete,
487
  ),
488
  );
 
16
  import '../features/capture/views/capture_sheet.dart';
17
  import '../features/collections/views/collections_screen.dart';
18
  import '../features/feed/views/knowledge_feed_screen.dart';
19
+ import '../features/collections/views/folder_picker_sheet.dart';
20
  import '../features/library/view_models/library_view_model.dart';
21
+ import '../features/library/views/library_dialogs.dart';
22
  import '../features/library/views/library_screen.dart';
23
  import '../features/profile/views/profile_screen.dart';
24
  import '../features/reader/views/reader_screen.dart';
25
  import 'brand.dart';
26
  import 'theme.dart';
27
  import 'ui_bus.dart';
 
28
  import 'widgets/glass.dart';
29
  import 'widgets/selection_action_bar.dart';
30
 
 
168
  bottomNavigationBar: selecting
169
  ? _SelectionNavSlot(
170
  vm: vm,
171
+ onConfirmDelete: () => confirmBulkDelete(context, vm),
172
  )
173
  : _GlassNav(index: _index, onSelect: _select),
174
  );
175
  }
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  Widget _desktopShell(BuildContext context) {
178
  return Scaffold(
179
  body: Stack(
 
444
  child: SelectionActionBar(
445
  selectedCount: vm.selectedCount,
446
  onClose: vm.clearSelection,
447
+ onMoveToFolder: () => showFolderPicker(context, vm),
 
 
448
  onDeleteSelected: onConfirmDelete,
449
  ),
450
  );
app/lib/ui/core/theme.dart CHANGED
@@ -148,7 +148,17 @@ class Motion {
148
  static const slow = Duration(milliseconds: 420);
149
  static const stagger = Duration(milliseconds: 45);
150
  static const curve = Curves.easeOutCubic;
151
- static const spring = Curves.easeOutBack;
 
 
 
 
 
 
 
 
 
 
152
  }
153
 
154
  /// Layout tokens: generous editorial margins.
 
148
  static const slow = Duration(milliseconds: 420);
149
  static const stagger = Duration(milliseconds: 45);
150
  static const curve = Curves.easeOutCubic;
151
+ // Ease-out with no overshoot: the product register bans bounce/elastic.
152
+ static const spring = Curves.easeOutCubic;
153
+ }
154
+
155
+ /// Reduced-motion gate: animations collapse to zero duration when the OS asks
156
+ /// for reduced motion, honouring the accessibility setting everywhere at once.
157
+ extension MotionGate on BuildContext {
158
+ bool get motionEnabled => !MediaQuery.of(this).disableAnimations;
159
+
160
+ /// [d] when motion is allowed, otherwise [Duration.zero] (instant).
161
+ Duration gated(Duration d) => motionEnabled ? d : Duration.zero;
162
  }
163
 
164
  /// Layout tokens: generous editorial margins.
app/lib/ui/core/widgets/loading_tiles.dart CHANGED
@@ -14,6 +14,7 @@ class LoadingTiles extends StatelessWidget {
14
  @override
15
  Widget build(BuildContext context) {
16
  final scheme = Theme.of(context).colorScheme;
 
17
  return GridView.count(
18
  padding: const EdgeInsets.all(Insets.page),
19
  physics: const NeverScrollableScrollPhysics(),
@@ -23,18 +24,23 @@ class LoadingTiles extends StatelessWidget {
23
  childAspectRatio: 0.72,
24
  children: [
25
  for (var i = 0; i < count; i++)
26
- Container(
27
- decoration: BoxDecoration(
28
- color: scheme.surfaceContainerHigh,
29
- borderRadius: BorderRadius.circular(Insets.radius),
30
- ),
31
- )
32
- .animate(onPlay: (c) => c.repeat())
33
- .shimmer(
34
- duration: const Duration(milliseconds: 1100),
35
- delay: Duration(milliseconds: i * 90),
36
- color: scheme.surfaceContainerHighest,
37
  ),
 
 
 
 
 
 
 
 
 
 
 
38
  ],
39
  );
40
  }
 
14
  @override
15
  Widget build(BuildContext context) {
16
  final scheme = Theme.of(context).colorScheme;
17
+ final animate = context.motionEnabled;
18
  return GridView.count(
19
  padding: const EdgeInsets.all(Insets.page),
20
  physics: const NeverScrollableScrollPhysics(),
 
24
  childAspectRatio: 0.72,
25
  children: [
26
  for (var i = 0; i < count; i++)
27
+ () {
28
+ final tile = Container(
29
+ decoration: BoxDecoration(
30
+ color: scheme.surfaceContainerHigh,
31
+ borderRadius: BorderRadius.circular(Insets.radius),
 
 
 
 
 
 
32
  ),
33
+ );
34
+ // Reduced motion: a static placeholder, no looping shimmer.
35
+ if (!animate) return tile;
36
+ return tile
37
+ .animate(onPlay: (c) => c.repeat())
38
+ .shimmer(
39
+ duration: const Duration(milliseconds: 1100),
40
+ delay: Duration(milliseconds: i * 90),
41
+ color: scheme.surfaceContainerHighest,
42
+ );
43
+ }(),
44
  ],
45
  );
46
  }
app/lib/ui/core/widgets/processing_glyph.dart CHANGED
@@ -73,7 +73,7 @@ class _ProcessingGlyphState extends State<ProcessingGlyph>
73
  child: TweenAnimationBuilder<double>(
74
  tween: Tween(begin: 0.0, end: 1.0),
75
  duration: const Duration(milliseconds: 600),
76
- curve: Curves.easeOutBack,
77
  builder: (context, t, _) {
78
  final pulse = 1.0 + 0.04 * (1 - (2 * (_c.value) - 1).abs());
79
  return Transform.scale(
 
73
  child: TweenAnimationBuilder<double>(
74
  tween: Tween(begin: 0.0, end: 1.0),
75
  duration: const Duration(milliseconds: 600),
76
+ curve: Curves.easeOutCubic,
77
  builder: (context, t, _) {
78
  final pulse = 1.0 + 0.04 * (1 - (2 * (_c.value) - 1).abs());
79
  return Transform.scale(
app/lib/ui/features/actions/view_models/actions_view_model.dart CHANGED
@@ -7,6 +7,7 @@ library;
7
  import 'package:flutter/foundation.dart';
8
 
9
  import '../../../../data/repositories/card_repository.dart';
 
10
  import '../../../../domain/models/card.dart';
11
 
12
  enum ActionsStatus { idle, loading, ready, error, empty }
@@ -70,7 +71,7 @@ class ActionsViewModel extends ChangeNotifier {
70
  _status = groups.isEmpty ? ActionsStatus.empty : ActionsStatus.ready;
71
  _error = null;
72
  } catch (e) {
73
- _error = e.toString();
74
  _status = ActionsStatus.error;
75
  }
76
  notifyListeners();
 
7
  import 'package:flutter/foundation.dart';
8
 
9
  import '../../../../data/repositories/card_repository.dart';
10
+ import '../../../../data/services/api_client.dart';
11
  import '../../../../domain/models/card.dart';
12
 
13
  enum ActionsStatus { idle, loading, ready, error, empty }
 
71
  _status = groups.isEmpty ? ActionsStatus.empty : ActionsStatus.ready;
72
  _error = null;
73
  } catch (e) {
74
+ _error = friendlyError(e);
75
  _status = ActionsStatus.error;
76
  }
77
  notifyListeners();
app/lib/ui/features/catalog/view_models/catalog_view_model.dart CHANGED
@@ -5,6 +5,7 @@ library;
5
  import 'package:flutter/foundation.dart';
6
 
7
  import '../../../../data/repositories/card_repository.dart';
 
8
  import '../../../../domain/models/artifact.dart';
9
 
10
  enum CatalogStatus { idle, loading, ready, error, empty }
@@ -84,7 +85,7 @@ class CatalogViewModel extends ChangeNotifier {
84
  _entries.isEmpty ? CatalogStatus.empty : CatalogStatus.ready;
85
  _error = null;
86
  } catch (e) {
87
- _error = '$e';
88
  _status = _entries.isEmpty ? CatalogStatus.error : CatalogStatus.ready;
89
  }
90
  notifyListeners();
 
5
  import 'package:flutter/foundation.dart';
6
 
7
  import '../../../../data/repositories/card_repository.dart';
8
+ import '../../../../data/services/api_client.dart';
9
  import '../../../../domain/models/artifact.dart';
10
 
11
  enum CatalogStatus { idle, loading, ready, error, empty }
 
85
  _entries.isEmpty ? CatalogStatus.empty : CatalogStatus.ready;
86
  _error = null;
87
  } catch (e) {
88
+ _error = friendlyError(e);
89
  _status = _entries.isEmpty ? CatalogStatus.error : CatalogStatus.ready;
90
  }
91
  notifyListeners();
app/lib/ui/features/collections/view_models/collections_view_model.dart CHANGED
@@ -3,6 +3,7 @@ library;
3
  import 'package:flutter/foundation.dart';
4
 
5
  import '../../../../data/repositories/card_repository.dart';
 
6
  import '../../../../domain/models/card.dart';
7
  import '../../../../domain/models/collection.dart';
8
  import '../../../../domain/models/enums.dart';
@@ -95,7 +96,7 @@ class CollectionsViewModel extends ChangeNotifier {
95
  _error = null;
96
  _status = _collections.isEmpty ? CollectionsStatus.empty : CollectionsStatus.ready;
97
  } catch (e) {
98
- _error = '$e';
99
  // Fall back to client-side grouping from cached cards.
100
  try {
101
  final cards = await _repository.list();
 
3
  import 'package:flutter/foundation.dart';
4
 
5
  import '../../../../data/repositories/card_repository.dart';
6
+ import '../../../../data/services/api_client.dart';
7
  import '../../../../domain/models/card.dart';
8
  import '../../../../domain/models/collection.dart';
9
  import '../../../../domain/models/enums.dart';
 
96
  _error = null;
97
  _status = _collections.isEmpty ? CollectionsStatus.empty : CollectionsStatus.ready;
98
  } catch (e) {
99
+ _error = friendlyError(e);
100
  // Fall back to client-side grouping from cached cards.
101
  try {
102
  final cards = await _repository.list();
app/lib/ui/features/collections/views/folder_picker_sheet.dart ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Bulk "Move to Folder" picker — an adaptive sheet listing the user's folders
2
+ /// (plus "New folder…" and "Remove from folders"). Replaces the old
3
+ /// coming-soon stub in the selection action bar.
4
+ library;
5
+
6
+ import 'package:flutter/material.dart';
7
+ import 'package:phosphor_flutter/phosphor_flutter.dart';
8
+ import 'package:provider/provider.dart';
9
+
10
+ import '../../../../data/repositories/card_repository.dart';
11
+ import '../../../../domain/models/collection.dart';
12
+ import '../../../core/theme.dart';
13
+ import '../../../core/widgets/adaptive_modal.dart';
14
+ import '../../library/view_models/library_view_model.dart';
15
+
16
+ /// Present the folder picker for the current selection. Moves every selected
17
+ /// card into the chosen folder and shows a confirmation. No-op if nothing is
18
+ /// selected.
19
+ Future<void> showFolderPicker(BuildContext context, LibraryViewModel vm) async {
20
+ if (!vm.selectionActive) return;
21
+ final count = vm.selectedCount;
22
+ final repo = context.read<CardRepository>();
23
+ final messenger = ScaffoldMessenger.of(context);
24
+
25
+ await showAdaptiveModal<void>(
26
+ context: context,
27
+ builder: (ctx, dialog) => _FolderPicker(
28
+ repo: repo,
29
+ dialog: dialog,
30
+ onPick: (collectionId, label) async {
31
+ Navigator.pop(ctx);
32
+ await vm.bulkMove(collectionId);
33
+ messenger.showSnackBar(SnackBar(
34
+ content: Text(
35
+ 'Moved $count ${count == 1 ? 'card' : 'cards'}'
36
+ '${label == null ? ' out of folders' : ' to "$label"'}',
37
+ ),
38
+ ));
39
+ },
40
+ ),
41
+ );
42
+ }
43
+
44
+ class _FolderPicker extends StatelessWidget {
45
+ const _FolderPicker({
46
+ required this.repo,
47
+ required this.dialog,
48
+ required this.onPick,
49
+ });
50
+
51
+ final CardRepository repo;
52
+ final bool dialog;
53
+
54
+ /// (collectionId, label): collectionId null = remove from all folders.
55
+ final Future<void> Function(String? collectionId, String? label) onPick;
56
+
57
+ @override
58
+ Widget build(BuildContext context) {
59
+ final theme = Theme.of(context);
60
+ final scheme = theme.colorScheme;
61
+ return Container(
62
+ decoration: BoxDecoration(
63
+ color: scheme.surface,
64
+ borderRadius: dialog
65
+ ? BorderRadius.circular(Insets.radius)
66
+ : const BorderRadius.vertical(top: Radius.circular(Insets.radius)),
67
+ ),
68
+ padding: EdgeInsets.only(
69
+ top: 12,
70
+ bottom: MediaQuery.of(context).viewInsets.bottom + 12,
71
+ ),
72
+ child: Column(
73
+ mainAxisSize: MainAxisSize.min,
74
+ children: [
75
+ if (!dialog)
76
+ Container(
77
+ width: 36,
78
+ height: 4,
79
+ margin: const EdgeInsets.only(bottom: 12),
80
+ decoration: BoxDecoration(
81
+ color: scheme.onSurfaceVariant.withValues(alpha: 0.3),
82
+ borderRadius: BorderRadius.circular(2),
83
+ ),
84
+ ),
85
+ Padding(
86
+ padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
87
+ child: Align(
88
+ alignment: Alignment.centerLeft,
89
+ child: Text('Move to folder', style: theme.textTheme.titleMedium),
90
+ ),
91
+ ),
92
+ Flexible(
93
+ child: FutureBuilder<List<CollectionEntry>>(
94
+ future: repo.listCollections(),
95
+ builder: (context, snap) {
96
+ if (snap.connectionState == ConnectionState.waiting) {
97
+ return const Padding(
98
+ padding: EdgeInsets.all(24),
99
+ child: CircularProgressIndicator(),
100
+ );
101
+ }
102
+ final folders = snap.data ?? const <CollectionEntry>[];
103
+ return ListView(
104
+ shrinkWrap: true,
105
+ padding: EdgeInsets.zero,
106
+ children: [
107
+ for (final f in folders)
108
+ ListTile(
109
+ leading: const PhosphorIcon(PhosphorIconsRegular.folder),
110
+ title: Text(f.name),
111
+ onTap: () => onPick(f.id, f.name),
112
+ ),
113
+ const Divider(height: 1),
114
+ ListTile(
115
+ leading: PhosphorIcon(PhosphorIconsRegular.folderPlus,
116
+ color: scheme.primary),
117
+ title: Text('New folder…',
118
+ style: TextStyle(color: scheme.primary)),
119
+ onTap: () => _createAndMove(context),
120
+ ),
121
+ if (folders.isNotEmpty)
122
+ ListTile(
123
+ leading: const PhosphorIcon(PhosphorIconsRegular.folderMinus),
124
+ title: const Text('Remove from folders'),
125
+ onTap: () => onPick(null, null),
126
+ ),
127
+ ],
128
+ );
129
+ },
130
+ ),
131
+ ),
132
+ ],
133
+ ),
134
+ );
135
+ }
136
+
137
+ Future<void> _createAndMove(BuildContext context) async {
138
+ final controller = TextEditingController();
139
+ final name = await showDialog<String>(
140
+ context: context,
141
+ builder: (ctx) => AlertDialog(
142
+ title: const Text('New folder'),
143
+ content: TextField(
144
+ controller: controller,
145
+ autofocus: true,
146
+ textCapitalization: TextCapitalization.words,
147
+ decoration: const InputDecoration(hintText: 'Folder name'),
148
+ onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
149
+ ),
150
+ actions: [
151
+ TextButton(
152
+ onPressed: () => Navigator.pop(ctx),
153
+ child: const Text('Cancel'),
154
+ ),
155
+ FilledButton(
156
+ onPressed: () => Navigator.pop(ctx, controller.text.trim()),
157
+ child: const Text('Create'),
158
+ ),
159
+ ],
160
+ ),
161
+ );
162
+ if (name == null || name.isEmpty) return;
163
+ final created = await repo.createCollection(name);
164
+ await onPick(created.id, created.name);
165
+ }
166
+ }
app/lib/ui/features/concepts/view_models/concepts_view_model.dart CHANGED
@@ -5,6 +5,7 @@ library;
5
  import 'package:flutter/foundation.dart';
6
 
7
  import '../../../../data/repositories/card_repository.dart';
 
8
  import '../../../../domain/models/concept.dart';
9
  import '../../../core/safe_notifier.dart';
10
 
@@ -61,7 +62,7 @@ class ConceptsViewModel extends ChangeNotifier with SafeNotifier {
61
  _status = _entries.isEmpty ? ConceptsStatus.empty : ConceptsStatus.ready;
62
  _error = null;
63
  } catch (e) {
64
- _error = '$e';
65
  _status = _entries.isEmpty ? ConceptsStatus.error : ConceptsStatus.ready;
66
  }
67
  notifyListeners();
 
5
  import 'package:flutter/foundation.dart';
6
 
7
  import '../../../../data/repositories/card_repository.dart';
8
+ import '../../../../data/services/api_client.dart';
9
  import '../../../../domain/models/concept.dart';
10
  import '../../../core/safe_notifier.dart';
11
 
 
62
  _status = _entries.isEmpty ? ConceptsStatus.empty : ConceptsStatus.ready;
63
  _error = null;
64
  } catch (e) {
65
+ _error = friendlyError(e);
66
  _status = _entries.isEmpty ? ConceptsStatus.error : ConceptsStatus.ready;
67
  }
68
  notifyListeners();
app/lib/ui/features/feed/views/knowledge_feed_screen.dart CHANGED
@@ -641,14 +641,16 @@ class _SwipeHint extends StatelessWidget {
641
  @override
642
  Widget build(BuildContext context) {
643
  final scheme = Theme.of(context).colorScheme;
644
- return Column(
645
  mainAxisSize: MainAxisSize.min,
646
  children: [
647
  PhosphorIcon(PhosphorIconsRegular.caretUp, size: 18, color: scheme.onSurfaceVariant),
648
  Text('SWIPE UP',
649
  style: Brand.label(size: 9, color: scheme.onSurfaceVariant, weight: FontWeight.w600)),
650
  ],
651
- )
 
 
652
  .animate(onPlay: (c) => c.repeat(reverse: true))
653
  .moveY(begin: 4, end: -4, duration: 900.ms, curve: Curves.easeInOut);
654
  }
 
641
  @override
642
  Widget build(BuildContext context) {
643
  final scheme = Theme.of(context).colorScheme;
644
+ final hint = Column(
645
  mainAxisSize: MainAxisSize.min,
646
  children: [
647
  PhosphorIcon(PhosphorIconsRegular.caretUp, size: 18, color: scheme.onSurfaceVariant),
648
  Text('SWIPE UP',
649
  style: Brand.label(size: 9, color: scheme.onSurfaceVariant, weight: FontWeight.w600)),
650
  ],
651
+ );
652
+ if (!context.motionEnabled) return hint; // reduced motion: static hint
653
+ return hint
654
  .animate(onPlay: (c) => c.repeat(reverse: true))
655
  .moveY(begin: 4, end: -4, duration: 900.ms, curve: Curves.easeInOut);
656
  }
app/lib/ui/features/library/view_models/library_view_model.dart CHANGED
@@ -7,20 +7,53 @@ import 'dart:async';
7
  import 'package:flutter/foundation.dart';
8
 
9
  import '../../../../data/repositories/card_repository.dart';
 
10
  import '../../../../domain/models/card.dart';
11
  import '../../../../domain/models/enums.dart';
12
  import '../../../core/safe_notifier.dart';
13
 
14
- enum LibraryStatus { idle, loading, ready, error, empty }
15
 
16
  class LibraryViewModel extends ChangeNotifier with SafeNotifier {
17
- LibraryViewModel({required CardRepository repository})
18
- : _repository = repository {
 
 
19
  _repository.addListener(_onRepoChange);
20
  }
21
 
22
  final CardRepository _repository;
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  void _onRepoChange() {
25
  if (_status != LibraryStatus.loading) {
26
  load(showSpinner: false);
@@ -207,13 +240,13 @@ class LibraryViewModel extends ChangeNotifier with SafeNotifier {
207
  }
208
  try {
209
  await _repository.flushPendingShares();
210
- final cards = await _repository.list(state: _filter);
211
  _cards = cards;
212
  _offline = false;
213
  _status = cards.isEmpty ? LibraryStatus.empty : LibraryStatus.ready;
214
  _error = null;
215
  } catch (e) {
216
- _error = '$e';
217
  _offline = true;
218
  _status = _cards.isEmpty ? LibraryStatus.error : LibraryStatus.ready;
219
  }
@@ -247,6 +280,24 @@ class LibraryViewModel extends ChangeNotifier with SafeNotifier {
247
  /// repository. If any deletion fails, the card list and selection are
248
  /// restored exactly to their pre-operation values and an error is surfaced
249
  /// (Requirement 8.9).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  Future<void> bulkDelete() async {
251
  final ids = _selectedIds.toList();
252
  if (ids.isEmpty) return;
@@ -287,7 +338,7 @@ class LibraryViewModel extends ChangeNotifier with SafeNotifier {
287
  ..addAll(selectionSnapshot);
288
  _selectionMode = selectionModeSnapshot;
289
  _selectionAnchorId = anchorSnapshot;
290
- _error = '$e';
291
  notifyListeners();
292
  }
293
  }
 
7
  import 'package:flutter/foundation.dart';
8
 
9
  import '../../../../data/repositories/card_repository.dart';
10
+ import '../../../../data/services/api_client.dart';
11
  import '../../../../domain/models/card.dart';
12
  import '../../../../domain/models/enums.dart';
13
  import '../../../core/safe_notifier.dart';
14
 
15
+ enum LibraryStatus { idle, loading, waking, ready, error, empty }
16
 
17
  class LibraryViewModel extends ChangeNotifier with SafeNotifier {
18
+ LibraryViewModel({
19
+ required CardRepository repository,
20
+ this.wakeRetryDelay = const Duration(seconds: 10),
21
+ }) : _repository = repository {
22
  _repository.addListener(_onRepoChange);
23
  }
24
 
25
  final CardRepository _repository;
26
 
27
+ /// Delay between cold-start retries; overridden to zero in tests.
28
+ final Duration wakeRetryDelay;
29
+
30
+ /// Free HF Spaces nap when idle and take ~30s to wake. We retry a handful of
31
+ /// times, showing the "waking" state, before conceding to an error.
32
+ static const _maxWakeAttempts = 6;
33
+ int _wakeAttempt = 0;
34
+ int get wakeAttempt => _wakeAttempt;
35
+
36
+ /// A connection-shaped failure worth retrying: no HTTP response at all
37
+ /// (socket/timeout, thrown as non-[ApiException]) or a gateway 502/503 from
38
+ /// a Space that is still booting.
39
+ bool _isWaking(Object e) =>
40
+ e is! ApiException || e.statusCode == 502 || e.statusCode == 503;
41
+
42
+ /// Fetch the library, retrying through cold start. Rethrows once retries are
43
+ /// exhausted or the failure isn't cold-start-shaped.
44
+ Future<List<Card>> _fetchWithWake() async {
45
+ for (_wakeAttempt = 0; ; _wakeAttempt++) {
46
+ try {
47
+ return await _repository.list(state: _filter);
48
+ } catch (e) {
49
+ if (!_isWaking(e) || _wakeAttempt >= _maxWakeAttempts) rethrow;
50
+ _status = LibraryStatus.waking;
51
+ notifyListeners();
52
+ await Future<void>.delayed(wakeRetryDelay);
53
+ }
54
+ }
55
+ }
56
+
57
  void _onRepoChange() {
58
  if (_status != LibraryStatus.loading) {
59
  load(showSpinner: false);
 
240
  }
241
  try {
242
  await _repository.flushPendingShares();
243
+ final cards = await _fetchWithWake();
244
  _cards = cards;
245
  _offline = false;
246
  _status = cards.isEmpty ? LibraryStatus.empty : LibraryStatus.ready;
247
  _error = null;
248
  } catch (e) {
249
+ _error = friendlyError(e);
250
  _offline = true;
251
  _status = _cards.isEmpty ? LibraryStatus.error : LibraryStatus.ready;
252
  }
 
280
  /// repository. If any deletion fails, the card list and selection are
281
  /// restored exactly to their pre-operation values and an error is surfaced
282
  /// (Requirement 8.9).
283
+ /// Move every currently-selected card into [collectionId] (null removes them
284
+ /// from all folders). Clears the selection and resyncs on success; surfaces a
285
+ /// friendly error and keeps the selection on failure.
286
+ Future<void> bulkMove(String? collectionId) async {
287
+ final ids = _selectedIds.toList();
288
+ if (ids.isEmpty) return;
289
+ try {
290
+ for (final id in ids) {
291
+ await _repository.moveCardToCollection(id, collectionId);
292
+ }
293
+ clearSelection();
294
+ await load(showSpinner: false);
295
+ } catch (e) {
296
+ _error = friendlyError(e);
297
+ notifyListeners();
298
+ }
299
+ }
300
+
301
  Future<void> bulkDelete() async {
302
  final ids = _selectedIds.toList();
303
  if (ids.isEmpty) return;
 
338
  ..addAll(selectionSnapshot);
339
  _selectionMode = selectionModeSnapshot;
340
  _selectionAnchorId = anchorSnapshot;
341
+ _error = friendlyError(e);
342
  notifyListeners();
343
  }
344
  }
app/lib/ui/features/library/views/library_dialogs.dart ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Shared bulk-selection dialogs for the library. Extracted so the mobile shell
2
+ /// and the desktop overlay invoke the exact same confirmation flow instead of
3
+ /// each carrying its own copy.
4
+ library;
5
+
6
+ import 'package:flutter/material.dart';
7
+
8
+ import '../../../core/widgets/adaptive_modal.dart';
9
+ import '../view_models/library_view_model.dart';
10
+
11
+ /// Confirm and perform a bulk delete of the currently-selected cards. Surfaces
12
+ /// a snackbar if the delete fails.
13
+ Future<void> confirmBulkDelete(BuildContext context, LibraryViewModel vm) async {
14
+ final count = vm.selectedCount;
15
+ if (count == 0) return;
16
+ final messenger = ScaffoldMessenger.of(context);
17
+ final ok = await showAdaptiveModal<bool>(
18
+ context: context,
19
+ builder: (ctx, dialog) => AlertDialog(
20
+ title: Text('Delete $count ${count == 1 ? 'card' : 'cards'}?'),
21
+ content: const Text(
22
+ 'This removes the cards and their media. This cannot be undone.'),
23
+ actions: [
24
+ TextButton(
25
+ onPressed: () => Navigator.pop(ctx, false),
26
+ child: const Text('Cancel'),
27
+ ),
28
+ FilledButton(
29
+ onPressed: () => Navigator.pop(ctx, true),
30
+ style: FilledButton.styleFrom(
31
+ backgroundColor: Theme.of(ctx).colorScheme.error,
32
+ ),
33
+ child: const Text('Delete'),
34
+ ),
35
+ ],
36
+ ),
37
+ );
38
+ if (ok == true) {
39
+ await vm.bulkDelete();
40
+ if (vm.error != null) {
41
+ messenger.showSnackBar(SnackBar(content: Text(vm.error!)));
42
+ }
43
+ }
44
+ }
app/lib/ui/features/library/views/library_screen.dart CHANGED
@@ -1,9 +1,9 @@
1
  /// The library: a browsable wall of card faces (docs/06), not a feed of text.
2
- /// Two segments — Cards (the grid) and To-do (actions followed off reels, folded
3
- /// in from the old Actions tab). Branded chrome (wordmark, gradient tab
4
- /// indicator), designed empty/loading/error/offline states, and a staggered
5
- /// tile entrance. Capture lives in the shell's center button; search opens a
6
- /// dedicated screen. Tap a tile → reader via a shared-element face transition.
7
  library;
8
 
9
  import 'dart:io' show Platform;
@@ -21,7 +21,6 @@ import '../../../../domain/models/highlight.dart';
21
  import '../../../core/app_controller.dart';
22
  import '../../../core/brand.dart';
23
  import '../../../core/theme.dart';
24
- import '../../../core/widgets/adaptive_modal.dart';
25
  import '../../../core/widgets/empty_state.dart';
26
  import '../../../core/widgets/error_state.dart';
27
  import '../../../core/widgets/loading_tiles.dart';
@@ -30,6 +29,7 @@ import '../../../core/widgets/split_pane.dart';
30
  import '../../../core/widgets/spot_art.dart';
31
  import '../../capture/views/capture_sheet.dart';
32
  import '../../catalog/views/catalog_screen.dart';
 
33
  import '../../concepts/views/concepts_screen.dart';
34
  import '../../graph/views/graph_screen.dart';
35
  import '../../library/views/library_chat_screen.dart';
@@ -38,6 +38,7 @@ import '../../reader/views/reader_screen.dart';
38
  import '../../search/views/search_screen.dart';
39
  import '../view_models/library_view_model.dart';
40
  import 'card_tile.dart';
 
41
  import 'grid_navigation.dart';
42
 
43
  class LibraryScreen extends StatelessWidget {
@@ -312,60 +313,28 @@ class _CardsTabState extends State<_CardsTab> {
312
  child: SelectionActionBar(
313
  selectedCount: vm.selectedCount,
314
  onClose: () => context.read<LibraryViewModel>().clearSelection(),
315
- onMoveToFolder: () {
316
- ScaffoldMessenger.of(context).showSnackBar(
317
- const SnackBar(content: Text('Move to Folder coming soon')),
318
- );
319
- },
320
- onDeleteSelected: () => _confirmBulkDelete(context),
321
  ),
322
  ),
323
  ],
324
  );
325
  }
326
 
327
- Future<void> _confirmBulkDelete(BuildContext context) async {
328
- final vm = context.read<LibraryViewModel>();
329
- final count = vm.selectedCount;
330
- final ok = await showAdaptiveModal<bool>(
331
- context: context,
332
- builder: (ctx, dialog) => AlertDialog(
333
- title: Text('Delete $count ${count == 1 ? 'card' : 'cards'}?'),
334
- content: const Text(
335
- 'This removes the cards and their media. This cannot be undone.'),
336
- actions: [
337
- TextButton(
338
- onPressed: () => Navigator.pop(ctx, false),
339
- child: const Text('Cancel'),
340
- ),
341
- FilledButton(
342
- onPressed: () => Navigator.pop(ctx, true),
343
- style: FilledButton.styleFrom(
344
- backgroundColor: Theme.of(ctx).colorScheme.error,
345
- ),
346
- child: const Text('Delete'),
347
- ),
348
- ],
349
- ),
350
- );
351
- if (ok == true && context.mounted) {
352
- await context.read<LibraryViewModel>().bulkDelete();
353
- // If bulkDelete surfaced an error (vm.error != null), show it.
354
- if (context.mounted) {
355
- final error = context.read<LibraryViewModel>().error;
356
- if (error != null) {
357
- ScaffoldMessenger.of(context).showSnackBar(
358
- SnackBar(content: Text('Delete failed: $error')),
359
- );
360
- }
361
- }
362
- }
363
- }
364
-
365
  Widget _body(BuildContext context, LibraryViewModel vm, dynamic api) {
366
  switch (vm.status) {
367
  case LibraryStatus.loading:
368
  return const LoadingTiles();
 
 
 
 
 
 
 
 
 
369
  case LibraryStatus.error:
370
  return _scrollable(
371
  ErrorState(
 
1
  /// The library: a browsable wall of card faces (docs/06), not a feed of text.
2
+ /// Three segments — Cards (the grid), Concepts, and Catalog. Branded chrome
3
+ /// (wordmark, gradient tab indicator), designed empty/loading/waking/error/
4
+ /// offline states, and a staggered tile entrance. Capture lives in the shell's
5
+ /// center button; search opens a dedicated screen. Tap a tile reader via a
6
+ /// shared-element face transition.
7
  library;
8
 
9
  import 'dart:io' show Platform;
 
21
  import '../../../core/app_controller.dart';
22
  import '../../../core/brand.dart';
23
  import '../../../core/theme.dart';
 
24
  import '../../../core/widgets/empty_state.dart';
25
  import '../../../core/widgets/error_state.dart';
26
  import '../../../core/widgets/loading_tiles.dart';
 
29
  import '../../../core/widgets/spot_art.dart';
30
  import '../../capture/views/capture_sheet.dart';
31
  import '../../catalog/views/catalog_screen.dart';
32
+ import '../../collections/views/folder_picker_sheet.dart';
33
  import '../../concepts/views/concepts_screen.dart';
34
  import '../../graph/views/graph_screen.dart';
35
  import '../../library/views/library_chat_screen.dart';
 
38
  import '../../search/views/search_screen.dart';
39
  import '../view_models/library_view_model.dart';
40
  import 'card_tile.dart';
41
+ import 'library_dialogs.dart';
42
  import 'grid_navigation.dart';
43
 
44
  class LibraryScreen extends StatelessWidget {
 
313
  child: SelectionActionBar(
314
  selectedCount: vm.selectedCount,
315
  onClose: () => context.read<LibraryViewModel>().clearSelection(),
316
+ onMoveToFolder: () =>
317
+ showFolderPicker(context, context.read<LibraryViewModel>()),
318
+ onDeleteSelected: () => confirmBulkDelete(context, vm),
 
 
 
319
  ),
320
  ),
321
  ],
322
  );
323
  }
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  Widget _body(BuildContext context, LibraryViewModel vm, dynamic api) {
326
  switch (vm.status) {
327
  case LibraryStatus.loading:
328
  return const LoadingTiles();
329
+ case LibraryStatus.waking:
330
+ return _scrollable(
331
+ const EmptyState(
332
+ showGlyph: true,
333
+ title: 'Waking Cachy up…',
334
+ message: 'The free server naps when idle. First load takes about '
335
+ '30 seconds — your library is on its way.',
336
+ ),
337
+ );
338
  case LibraryStatus.error:
339
  return _scrollable(
340
  ErrorState(
app/lib/ui/features/onboarding/views/name_screen.dart CHANGED
@@ -4,7 +4,6 @@
4
  library;
5
 
6
  import 'package:flutter/material.dart';
7
- import 'package:google_fonts/google_fonts.dart';
8
  import 'package:phosphor_flutter/phosphor_flutter.dart';
9
  import 'package:provider/provider.dart';
10
 
@@ -87,26 +86,13 @@ class _NameScreenState extends State<NameScreen> {
87
  const SizedBox(height: 32),
88
  RichText(
89
  text: TextSpan(
90
- style: GoogleFonts.fraunces(
91
- fontSize: 42,
92
- fontWeight: FontWeight.w800,
93
- letterSpacing: -1.0,
94
- height: 1.1,
95
- color: scheme.onSurface,
96
- ),
97
  children: [
98
  const TextSpan(text: "What's\nyour "),
99
  TextSpan(
100
  text: 'name?',
101
- style: TextStyle(
102
- color: scheme.primary,
103
- shadows: [
104
- Shadow(
105
- color: scheme.primary.withValues(alpha: 0.35),
106
- blurRadius: 24,
107
- ),
108
- ],
109
- ),
110
  ),
111
  ],
112
  ),
 
4
  library;
5
 
6
  import 'package:flutter/material.dart';
 
7
  import 'package:phosphor_flutter/phosphor_flutter.dart';
8
  import 'package:provider/provider.dart';
9
 
 
86
  const SizedBox(height: 32),
87
  RichText(
88
  text: TextSpan(
89
+ style: theme.textTheme.displaySmall
90
+ ?.copyWith(color: scheme.onSurface),
 
 
 
 
 
91
  children: [
92
  const TextSpan(text: "What's\nyour "),
93
  TextSpan(
94
  text: 'name?',
95
+ style: TextStyle(color: scheme.primary),
 
 
 
 
 
 
 
 
96
  ),
97
  ],
98
  ),
app/lib/ui/features/onboarding/views/onboarding_screen.dart CHANGED
@@ -1,10 +1,10 @@
1
- /// First-run onboarding: detailed 3-phase showcase adapted from Insightr
2
- /// (demo) featuring Cachy logo headers, Fraunces serif display headlines,
3
- /// floating capability badges, mock structured feature cards, and library vault previews.
 
4
  library;
5
 
6
  import 'package:flutter/material.dart';
7
- import 'package:google_fonts/google_fonts.dart';
8
  import 'package:phosphor_flutter/phosphor_flutter.dart';
9
 
10
  import '../../../core/brand.dart';
@@ -160,33 +160,8 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
160
  class _LogoBadge extends StatelessWidget {
161
  @override
162
  Widget build(BuildContext context) {
163
- final scheme = Theme.of(context).colorScheme;
164
- return Row(
165
- children: [
166
- Container(
167
- width: 34,
168
- height: 34,
169
- decoration: BoxDecoration(
170
- color: scheme.primary,
171
- borderRadius: BorderRadius.circular(10),
172
- boxShadow: [
173
- BoxShadow(
174
- color: scheme.primary.withValues(alpha: 0.4),
175
- blurRadius: 12,
176
- offset: const Offset(0, 3),
177
- ),
178
- ],
179
- ),
180
- child: PhosphorIcon(
181
- PhosphorIconsRegular.lightning,
182
- color: scheme.onPrimary,
183
- size: 20,
184
- ),
185
- ),
186
- const SizedBox(width: 10),
187
- Text('Cachy', style: Brand.wordmarkStyle(20, color: scheme.onSurface)),
188
- ],
189
- );
190
  }
191
  }
192
 
@@ -244,27 +219,10 @@ class _PageHook extends StatelessWidget {
244
  RichText(
245
  textAlign: TextAlign.center,
246
  text: TextSpan(
247
- style: GoogleFonts.fraunces(
248
- fontSize: 48,
249
- fontWeight: FontWeight.w800,
250
- letterSpacing: -1.2,
251
- height: 1.1,
252
- color: scheme.onSurface,
253
- ),
254
  children: [
255
  const TextSpan(text: 'Any Link,\n'),
256
- TextSpan(
257
- text: 'Captured.',
258
- style: TextStyle(
259
- color: scheme.primary,
260
- shadows: [
261
- Shadow(
262
- color: scheme.primary.withValues(alpha: 0.35),
263
- blurRadius: 24,
264
- ),
265
- ],
266
- ),
267
- ),
268
  ],
269
  ),
270
  ),
@@ -308,13 +266,7 @@ class _PageStructure extends StatelessWidget {
308
  RichText(
309
  textAlign: TextAlign.center,
310
  text: TextSpan(
311
- style: GoogleFonts.fraunces(
312
- fontSize: 40,
313
- fontWeight: FontWeight.w800,
314
- letterSpacing: -1,
315
- height: 1.1,
316
- color: scheme.onSurface,
317
- ),
318
  children: [
319
  const TextSpan(text: 'Every Source,\n'),
320
  TextSpan(
@@ -488,13 +440,7 @@ class _PageLibrary extends StatelessWidget {
488
  RichText(
489
  textAlign: TextAlign.center,
490
  text: TextSpan(
491
- style: GoogleFonts.fraunces(
492
- fontSize: 40,
493
- fontWeight: FontWeight.w800,
494
- letterSpacing: -1,
495
- height: 1.1,
496
- color: scheme.onSurface,
497
- ),
498
  children: [
499
  const TextSpan(text: 'Personal Web\n'),
500
  TextSpan(
 
1
+ /// First-run onboarding: a 3-page showcase capture, structure, library — on
2
+ /// the brand's calm-editorial chrome (CachyGlyph wordmark, Fraunces serif
3
+ /// display via the theme), floating capability badges, mock structured feature
4
+ /// cards, and library vault previews.
5
  library;
6
 
7
  import 'package:flutter/material.dart';
 
8
  import 'package:phosphor_flutter/phosphor_flutter.dart';
9
 
10
  import '../../../core/brand.dart';
 
160
  class _LogoBadge extends StatelessWidget {
161
  @override
162
  Widget build(BuildContext context) {
163
+ // The real brand mark, not a generic lightning-in-a-box.
164
+ return const CachyWordmark(size: 20);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  }
166
  }
167
 
 
219
  RichText(
220
  textAlign: TextAlign.center,
221
  text: TextSpan(
222
+ style: theme.textTheme.displayMedium?.copyWith(color: scheme.onSurface),
 
 
 
 
 
 
223
  children: [
224
  const TextSpan(text: 'Any Link,\n'),
225
+ TextSpan(text: 'Captured.', style: TextStyle(color: scheme.primary)),
 
 
 
 
 
 
 
 
 
 
 
226
  ],
227
  ),
228
  ),
 
266
  RichText(
267
  textAlign: TextAlign.center,
268
  text: TextSpan(
269
+ style: theme.textTheme.displaySmall?.copyWith(color: scheme.onSurface),
 
 
 
 
 
 
270
  children: [
271
  const TextSpan(text: 'Every Source,\n'),
272
  TextSpan(
 
440
  RichText(
441
  textAlign: TextAlign.center,
442
  text: TextSpan(
443
+ style: theme.textTheme.displaySmall?.copyWith(color: scheme.onSurface),
 
 
 
 
 
 
444
  children: [
445
  const TextSpan(text: 'Personal Web\n'),
446
  TextSpan(
app/lib/ui/features/onboarding/views/splash_screen.dart CHANGED
@@ -31,7 +31,7 @@ class _SplashScreenState extends State<SplashScreen>
31
  );
32
  _drop = CurvedAnimation(
33
  parent: _c,
34
- curve: const Interval(0.0, 0.55, curve: Curves.easeOutBack),
35
  );
36
  _wordmark = CurvedAnimation(
37
  parent: _c,
 
31
  );
32
  _drop = CurvedAnimation(
33
  parent: _c,
34
+ curve: const Interval(0.0, 0.55, curve: Curves.easeOutCubic),
35
  );
36
  _wordmark = CurvedAnimation(
37
  parent: _c,
app/lib/ui/features/profile/views/profile_screen.dart CHANGED
@@ -249,9 +249,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
249
  ),
250
  );
251
  if (ok == true && mounted) {
252
- ScaffoldMessenger.of(context).showSnackBar(
253
- const SnackBar(content: Text('Offline cache cleared')),
254
- );
 
 
 
 
 
 
 
255
  }
256
  }
257
 
 
249
  ),
250
  );
251
  if (ok == true && mounted) {
252
+ final removed = await context.read<CardRepository>().clearCardCache();
253
+ if (mounted) {
254
+ ScaffoldMessenger.of(context).showSnackBar(
255
+ SnackBar(
256
+ content: Text(removed == 0
257
+ ? 'Nothing cached yet'
258
+ : 'Cleared $removed offline ${removed == 1 ? 'card' : 'cards'}'),
259
+ ),
260
+ );
261
+ }
262
  }
263
  }
264
 
app/lib/ui/features/reader/views/insight_section.dart CHANGED
@@ -561,7 +561,7 @@ class _QuizCardState extends State<_QuizCard> {
561
  ),
562
  ),
563
  ],
564
- ).animate().fadeIn(duration: 300.ms).scaleXY(begin: 0.96, end: 1, curve: Curves.easeOutBack);
565
  }
566
  }
567
 
 
561
  ),
562
  ),
563
  ],
564
+ ).animate().fadeIn(duration: 300.ms).scaleXY(begin: 0.96, end: 1, curve: Curves.easeOutCubic);
565
  }
566
  }
567
 
app/lib/ui/features/reader/views/reader_screen.dart CHANGED
@@ -764,11 +764,13 @@ class _PulsingDot extends StatelessWidget {
764
 
765
  @override
766
  Widget build(BuildContext context) {
767
- return Container(
768
  width: 8,
769
  height: 8,
770
  decoration: BoxDecoration(color: color, shape: BoxShape.circle),
771
- )
 
 
772
  .animate(onPlay: (c) => c.repeat(reverse: true))
773
  .scaleXY(
774
  begin: 0.5,
@@ -1321,14 +1323,16 @@ class _SkeletonBox extends StatelessWidget {
1321
  @override
1322
  Widget build(BuildContext context) {
1323
  final scheme = Theme.of(context).colorScheme;
1324
- return Container(
1325
  width: width,
1326
  height: height,
1327
  decoration: BoxDecoration(
1328
  color: scheme.surfaceContainerHigh,
1329
  borderRadius: BorderRadius.circular(radius),
1330
  ),
1331
- )
 
 
1332
  .animate(onPlay: (c) => c.repeat())
1333
  .shimmer(
1334
  duration: const Duration(milliseconds: 1200),
 
764
 
765
  @override
766
  Widget build(BuildContext context) {
767
+ final dot = Container(
768
  width: 8,
769
  height: 8,
770
  decoration: BoxDecoration(color: color, shape: BoxShape.circle),
771
+ );
772
+ if (!context.motionEnabled) return dot; // reduced motion: static dot
773
+ return dot
774
  .animate(onPlay: (c) => c.repeat(reverse: true))
775
  .scaleXY(
776
  begin: 0.5,
 
1323
  @override
1324
  Widget build(BuildContext context) {
1325
  final scheme = Theme.of(context).colorScheme;
1326
+ final box = Container(
1327
  width: width,
1328
  height: height,
1329
  decoration: BoxDecoration(
1330
  color: scheme.surfaceContainerHigh,
1331
  borderRadius: BorderRadius.circular(radius),
1332
  ),
1333
+ );
1334
+ if (!context.motionEnabled) return box; // reduced motion: static block
1335
+ return box
1336
  .animate(onPlay: (c) => c.repeat())
1337
  .shimmer(
1338
  duration: const Duration(milliseconds: 1200),
app/lib/ui/features/share/view_models/share_view_model.dart CHANGED
@@ -64,11 +64,11 @@ class ShareViewModel extends ChangeNotifier {
64
  } catch (e) {
65
  if (e is ApiException) {
66
  _status = ShareStatus.failed;
67
- _failureReason = e.message;
68
  } else {
69
  // Network down: repository has queued the share for later.
70
  _status = ShareStatus.queuedOffline;
71
- _error = '$e';
72
  }
73
  notifyListeners();
74
  return null;
@@ -92,7 +92,7 @@ class ShareViewModel extends ChangeNotifier {
92
  _safeNotify();
93
  },
94
  onError: (e) {
95
- _error = '$e';
96
  _safeNotify();
97
  },
98
  cancelOnError: false,
 
64
  } catch (e) {
65
  if (e is ApiException) {
66
  _status = ShareStatus.failed;
67
+ _failureReason = e.friendlyMessage;
68
  } else {
69
  // Network down: repository has queued the share for later.
70
  _status = ShareStatus.queuedOffline;
71
+ _error = friendlyError(e);
72
  }
73
  notifyListeners();
74
  return null;
 
92
  _safeNotify();
93
  },
94
  onError: (e) {
95
+ _error = friendlyError(e);
96
  _safeNotify();
97
  },
98
  cancelOnError: false,
app/lib/ui/features/share/views/share_screen.dart CHANGED
@@ -133,10 +133,11 @@ class _ShareViewState extends State<_ShareView> {
133
  children: [
134
  const PhosphorIcon(PhosphorIconsRegular.cloudSlash, size: 48),
135
  const SizedBox(height: 14),
136
- Text('Saved offline', style: theme.textTheme.titleLarge),
137
  const SizedBox(height: 8),
138
  const Text(
139
- "We'll process this reel as soon as you're back online.",
 
140
  textAlign: TextAlign.center,
141
  ),
142
  const SizedBox(height: 20),
 
133
  children: [
134
  const PhosphorIcon(PhosphorIconsRegular.cloudSlash, size: 48),
135
  const SizedBox(height: 14),
136
+ Text('Saved — will process shortly', style: theme.textTheme.titleLarge),
137
  const SizedBox(height: 8),
138
  const Text(
139
+ "We'll process this as soon as Cachy is reachable — the free "
140
+ 'server can take ~30s to wake up.',
141
  textAlign: TextAlign.center,
142
  ),
143
  const SizedBox(height: 20),
app/test/api_exception_test.dart ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter_test/flutter_test.dart';
2
+ import 'package:cachy/data/services/api_client.dart';
3
+
4
+ void main() {
5
+ test('friendly messages never leak bodies or tracebacks', () {
6
+ expect(ApiException(500, '{"detail":"X","traceback":"Trace..."}').friendlyMessage,
7
+ 'Something went wrong on our side. Try again in a moment.');
8
+ expect(ApiException(429, '{"error":"quota"}').friendlyMessage,
9
+ "You've hit today's limit. It resets at midnight UTC.");
10
+ expect(ApiException(401, 'x').friendlyMessage,
11
+ 'Session expired — please sign in again.');
12
+ expect(ApiException(404, 'x').friendlyMessage,
13
+ "That card isn't there anymore.");
14
+ for (final code in [400, 401, 404, 429, 500, 503]) {
15
+ final msg = ApiException(code, 'traceback secret').friendlyMessage;
16
+ expect(msg.contains('traceback'), isFalse);
17
+ expect(msg.contains('secret'), isFalse);
18
+ }
19
+ });
20
+
21
+ test('friendlyError handles non-Api exceptions', () {
22
+ expect(friendlyError(Exception('SocketException: conn refused')),
23
+ "Can't reach Cachy. Check your connection.");
24
+ });
25
+ }
app/test/bulk_move_test.dart ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dart:convert';
2
+
3
+ import 'package:flutter_test/flutter_test.dart';
4
+ import 'package:http/http.dart' as http;
5
+ import 'package:http/testing.dart';
6
+ import 'package:shared_preferences/shared_preferences.dart';
7
+ import 'package:cachy/data/repositories/card_repository.dart';
8
+ import 'package:cachy/data/services/api_client.dart';
9
+ import 'package:cachy/data/services/local_store.dart';
10
+ import 'package:cachy/ui/features/library/view_models/library_view_model.dart';
11
+
12
+ /// Builds a VM over a real repo whose MockClient serves the given cards and
13
+ /// records every move POST as cardId -> collection_id.
14
+ Future<(LibraryViewModel, Map<String, String?>)> _vm(List<String> ids) async {
15
+ SharedPreferences.setMockInitialValues({});
16
+ final store = await LocalStore.open();
17
+ final moves = <String, String?>{};
18
+ final mock = MockClient((req) async {
19
+ final path = req.url.path;
20
+ if (path == '/cards') {
21
+ return http.Response(
22
+ jsonEncode([for (final id in ids) {'card_id': id, 'state': 'ready'}]),
23
+ 200,
24
+ );
25
+ }
26
+ final move = RegExp(r'^/collections/cards/(.+)/move$').firstMatch(path);
27
+ if (move != null) {
28
+ moves[move.group(1)!] = jsonDecode(req.body)['collection_id'] as String?;
29
+ return http.Response('{}', 200);
30
+ }
31
+ return http.Response('[]', 200);
32
+ });
33
+ final api = ApiClient(baseUrl: 'http://x', client: mock);
34
+ final repo = CardRepository(api: api, store: store);
35
+ return (LibraryViewModel(repository: repo), moves);
36
+ }
37
+
38
+ void main() {
39
+ TestWidgetsFlutterBinding.ensureInitialized();
40
+
41
+ test('bulkMove moves every selected card and clears selection', () async {
42
+ final (vm, moves) = await _vm(['a', 'b', 'c']);
43
+ await vm.load();
44
+ vm.toggleSelection('a');
45
+ vm.toggleSelection('c');
46
+ expect(vm.selectionActive, isTrue);
47
+
48
+ await vm.bulkMove('folder-1');
49
+
50
+ expect(moves, {'a': 'folder-1', 'c': 'folder-1'});
51
+ expect(vm.selectionActive, isFalse);
52
+ });
53
+ }
app/test/library_wakeup_test.dart ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter_test/flutter_test.dart';
2
+ import 'package:http/http.dart' as http;
3
+ import 'package:http/testing.dart';
4
+ import 'package:shared_preferences/shared_preferences.dart';
5
+ import 'package:cachy/data/repositories/card_repository.dart';
6
+ import 'package:cachy/data/services/api_client.dart';
7
+ import 'package:cachy/data/services/local_store.dart';
8
+ import 'package:cachy/ui/features/library/view_models/library_view_model.dart';
9
+
10
+ /// Real repository driven by a MockClient that "naps" (throws) a fixed number
11
+ /// of times before answering — exactly the sleeping-HF-Space cold start.
12
+ Future<LibraryViewModel> _vm(int naps, {int status = 200}) async {
13
+ SharedPreferences.setMockInitialValues({}); // empty cache => list() rethrows
14
+ final store = await LocalStore.open();
15
+ var calls = 0;
16
+ final mock = MockClient((req) async {
17
+ calls++;
18
+ if (calls <= naps) {
19
+ if (status == 200) throw http.ClientException('nap');
20
+ return http.Response('waking', status);
21
+ }
22
+ return http.Response('[]', 200);
23
+ });
24
+ final api = ApiClient(baseUrl: 'http://x', client: mock);
25
+ final repo = CardRepository(api: api, store: store);
26
+ return LibraryViewModel(repository: repo, wakeRetryDelay: Duration.zero);
27
+ }
28
+
29
+ void main() {
30
+ TestWidgetsFlutterBinding.ensureInitialized();
31
+
32
+ test('connection nap enters waking state, then recovers', () async {
33
+ final vm = await _vm(2);
34
+ final seen = <LibraryStatus>[];
35
+ vm.addListener(() => seen.add(vm.status));
36
+ await vm.load();
37
+ expect(seen, contains(LibraryStatus.waking));
38
+ expect(vm.status, LibraryStatus.empty);
39
+ });
40
+
41
+ test('503 while waking is retried too', () async {
42
+ final vm = await _vm(2, status: 503);
43
+ await vm.load();
44
+ expect(vm.status, LibraryStatus.empty);
45
+ });
46
+
47
+ test('persistent failure lands on error after max attempts', () async {
48
+ final vm = await _vm(99);
49
+ await vm.load();
50
+ expect(vm.status, LibraryStatus.error);
51
+ });
52
+ }
app/test/local_store_test.dart ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter_test/flutter_test.dart';
2
+ import 'package:shared_preferences/shared_preferences.dart';
3
+ import 'package:cachy/data/services/local_store.dart';
4
+
5
+ void main() {
6
+ TestWidgetsFlutterBinding.ensureInitialized();
7
+
8
+ test('clearCardCache removes all cached cards and the index', () async {
9
+ SharedPreferences.setMockInitialValues({});
10
+ final store = await LocalStore.open();
11
+ await store.cacheCard('c1', {'id': 'c1'});
12
+ await store.cacheCard('c2', {'id': 'c2'});
13
+ expect(store.cachedCardIds(), hasLength(2));
14
+
15
+ final removed = await store.clearCardCache();
16
+ expect(removed, 2);
17
+ expect(store.cachedCardIds(), isEmpty);
18
+ expect(store.readCard('c1'), isNull);
19
+ });
20
+ }
app/test/reduced_motion_test.dart ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter/material.dart';
2
+ import 'package:flutter_test/flutter_test.dart';
3
+ import 'package:cachy/ui/core/theme.dart';
4
+
5
+ void main() {
6
+ testWidgets('motionEnabled follows MediaQuery.disableAnimations', (tester) async {
7
+ late bool enabled;
8
+ late Duration gated;
9
+ await tester.pumpWidget(MediaQuery(
10
+ data: const MediaQueryData(disableAnimations: true),
11
+ child: Builder(builder: (context) {
12
+ enabled = context.motionEnabled;
13
+ gated = context.gated(Motion.medium);
14
+ return const SizedBox();
15
+ }),
16
+ ));
17
+ expect(enabled, isFalse);
18
+ expect(gated, Duration.zero);
19
+ });
20
+
21
+ testWidgets('motion on by default', (tester) async {
22
+ late bool enabled;
23
+ late Duration gated;
24
+ await tester.pumpWidget(MediaQuery(
25
+ data: const MediaQueryData(),
26
+ child: Builder(builder: (context) {
27
+ enabled = context.motionEnabled;
28
+ gated = context.gated(Motion.medium);
29
+ return const SizedBox();
30
+ }),
31
+ ));
32
+ expect(enabled, isTrue);
33
+ expect(gated, Motion.medium);
34
+ });
35
+ }
app/web/index.html CHANGED
@@ -69,7 +69,7 @@
69
  stroke: #1C1917;
70
  }
71
  .glyph-reel-group {
72
- animation: reelDrop 0.72s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
73
  }
74
  @keyframes reelDrop {
75
  0% { transform: translateY(-44px); }
 
69
  stroke: #1C1917;
70
  }
71
  .glyph-reel-group {
72
+ animation: reelDrop 0.72s cubic-bezier(0.22, 1, 0.36, 1) forwards;
73
  }
74
  @keyframes reelDrop {
75
  0% { transform: translateY(-44px); }
docs/planning/plans/2026-07-10-backend-auth-quotas.md ADDED
@@ -0,0 +1,790 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend Auth + Quotas + Hardening Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Replace the spoofable `x-owner-id` header with verified Firebase ID tokens, add per-user/per-IP daily quotas with degrade-to-fallback, and lock down admin/debug/error surfaces.
6
+
7
+ **Architecture:** A FastAPI dependency `get_owner` verifies the bearer token via `firebase_admin.auth.verify_id_token` and yields the uid; every route swaps its header param for this dependency. Quotas live in a new `usage` table keyed `(owner_id, day, kind)`; past-quota card creation flags the job `degraded` so the worker takes the existing paragraph-fallback path instead of failing. A temporary `/auth/claim` migrates legacy name-keyed rows.
8
+
9
+ **Tech Stack:** FastAPI, SQLAlchemy async + aiosqlite, firebase-admin, pytest + pytest-asyncio + httpx.
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: `docs/superpowers/specs/2026-07-10-public-distribution-auth-quotas-design.md`.
14
+ - Python style: type hints + docstrings on every function; explicit error handling (no `except: pass`); `pathlib.Path`; config only via `app.config.Settings` (pydantic-settings, `.env`).
15
+ - Quota defaults (env-overridable): cards 10/day, chat 30/day, connections-refresh 3/day, per-IP cards 30/day.
16
+ - `x-owner-id` header support is removed entirely; legacy name survives only as `/auth/claim`'s body parameter.
17
+ - Every external dependency optional / graceful degradation preserved: if `FIREBASE_PROJECT_ID` is unset, `get_owner` must raise 503 with "auth not configured" (never crash at import).
18
+ - Run tests with `cd backend && .venv/bin/pytest` after every task.
19
+ - Do not commit unless the user asked; each task's Commit step is conditional on that standing permission being granted at execution time.
20
+
21
+ ---
22
+
23
+ ### Task 1: Restore the pytest harness
24
+
25
+ **Files:**
26
+ - Create: `backend/tests/__init__.py` (empty)
27
+ - Create: `backend/tests/conftest.py`
28
+ - Create: `backend/tests/test_health.py`
29
+
30
+ **Interfaces:**
31
+ - Produces: `client` async fixture (httpx.AsyncClient against the app, fresh in-memory-style temp SQLite per test) used by every later test.
32
+
33
+ - [ ] **Step 1: Write conftest**
34
+
35
+ ```python
36
+ """Shared fixtures: temp-file SQLite DB + httpx client bound to the app.
37
+
38
+ The worker loop is not started (tests drive functions directly); lifespan is
39
+ bypassed by calling db.init_db() ourselves against a temp database file.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import asyncio
45
+ from collections.abc import AsyncIterator
46
+ from pathlib import Path
47
+
48
+ import pytest
49
+ import pytest_asyncio
50
+ from httpx import ASGITransport, AsyncClient
51
+
52
+
53
+ @pytest_asyncio.fixture
54
+ async def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
55
+ """App-bound HTTP client with an isolated database per test."""
56
+ db_path = tmp_path / "test.db"
57
+ monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{db_path}")
58
+ # Settings and the engine are cached at import; reset both.
59
+ from app import config
60
+ config.get_settings.cache_clear()
61
+ from app.store import db as store_db
62
+ await store_db.dispose_db()
63
+ store_db.reset_engine() # added in Step 2 if absent
64
+ await store_db.init_db()
65
+
66
+ from app.main import app
67
+ transport = ASGITransport(app=app)
68
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
69
+ yield c
70
+ await store_db.dispose_db()
71
+ ```
72
+
73
+ - [ ] **Step 2: Ensure `reset_engine` exists in `backend/app/store/db.py`**
74
+
75
+ Read `db.py`'s engine setup. If the engine/sessionmaker are module-level singletons built from `get_settings().database_url` at import, add:
76
+
77
+ ```python
78
+ def reset_engine() -> None:
79
+ """Rebuild the engine/sessionmaker from current settings (tests swap DATABASE_URL)."""
80
+ global _engine, _session_factory
81
+ _engine = create_async_engine(get_settings().database_url, future=True)
82
+ _session_factory = async_sessionmaker(_engine, expire_on_commit=False)
83
+ ```
84
+
85
+ Match the actual variable names used in `db.py` (`_engine` / `_session_factory` may differ — mirror what `dispose_db()` touches).
86
+
87
+ - [ ] **Step 3: Write the smoke test**
88
+
89
+ ```python
90
+ """Harness smoke test: the app answers /health on an isolated DB."""
91
+
92
+ import pytest
93
+
94
+
95
+ @pytest.mark.asyncio
96
+ async def test_health(client) -> None:
97
+ resp = await client.get("/health")
98
+ assert resp.status_code == 200
99
+ assert resp.json()["status"] == "ok"
100
+ ```
101
+
102
+ - [ ] **Step 4: Run**
103
+
104
+ Run: `cd backend && .venv/bin/pytest tests/test_health.py -v` — Expected: PASS. If `pytest-asyncio` mode errors appear, add to `backend/pyproject.toml` under `[tool.pytest.ini_options]`: `asyncio_mode = "auto"` (then the `@pytest.mark.asyncio` markers are optional but harmless).
105
+
106
+ - [ ] **Step 5: Commit** — `test: restore pytest harness with isolated per-test DB`
107
+
108
+ ---
109
+
110
+ ### Task 2: `get_owner` — Firebase token verification dependency
111
+
112
+ **Files:**
113
+ - Modify: `backend/pyproject.toml` (add `"firebase-admin>=6.5"` to dependencies)
114
+ - Modify: `backend/app/config.py` (add `firebase_project_id: str = ""`)
115
+ - Create: `backend/app/auth.py`
116
+ - Create: `backend/tests/test_auth.py`
117
+
118
+ **Interfaces:**
119
+ - Produces: `async def get_owner(authorization: str | None = Header(None)) -> str` — FastAPI dependency returning the verified uid; raises HTTPException 401 (missing/invalid token) or 503 (auth unconfigured). Also `OwnerDep = Annotated[str, Depends(get_owner)]` for route signatures.
120
+
121
+ - [ ] **Step 1: Write failing tests**
122
+
123
+ ```python
124
+ """get_owner: valid token -> uid; garbage/missing -> 401; unconfigured -> 503."""
125
+
126
+ from unittest.mock import patch
127
+
128
+ import pytest
129
+ from fastapi import HTTPException
130
+
131
+ from app.auth import get_owner
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_missing_header_401() -> None:
136
+ with pytest.raises(HTTPException) as exc:
137
+ await get_owner(authorization=None)
138
+ assert exc.value.status_code == 401
139
+
140
+
141
+ @pytest.mark.asyncio
142
+ async def test_valid_token_returns_uid(monkeypatch: pytest.MonkeyPatch) -> None:
143
+ monkeypatch.setenv("FIREBASE_PROJECT_ID", "demo-project")
144
+ from app import config
145
+ config.get_settings.cache_clear()
146
+ with patch("app.auth._verify", return_value={"uid": "user-123"}):
147
+ uid = await get_owner(authorization="Bearer sometoken")
148
+ assert uid == "user-123"
149
+
150
+
151
+ @pytest.mark.asyncio
152
+ async def test_invalid_token_401(monkeypatch: pytest.MonkeyPatch) -> None:
153
+ monkeypatch.setenv("FIREBASE_PROJECT_ID", "demo-project")
154
+ from app import config
155
+ config.get_settings.cache_clear()
156
+ with patch("app.auth._verify", side_effect=ValueError("bad token")):
157
+ with pytest.raises(HTTPException) as exc:
158
+ await get_owner(authorization="Bearer garbage")
159
+ assert exc.value.status_code == 401
160
+
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_unconfigured_503(monkeypatch: pytest.MonkeyPatch) -> None:
164
+ monkeypatch.delenv("FIREBASE_PROJECT_ID", raising=False)
165
+ from app import config
166
+ config.get_settings.cache_clear()
167
+ with pytest.raises(HTTPException) as exc:
168
+ await get_owner(authorization="Bearer sometoken")
169
+ assert exc.value.status_code == 503
170
+ ```
171
+
172
+ - [ ] **Step 2: Run to verify failure** — `cd backend && .venv/bin/pytest tests/test_auth.py -v` — Expected: FAIL, `ModuleNotFoundError: app.auth`.
173
+
174
+ - [ ] **Step 3: Implement `backend/app/auth.py`**
175
+
176
+ ```python
177
+ """Verified identity: Firebase ID token -> uid.
178
+
179
+ The client sends `Authorization: Bearer <ID token>`; we verify the signature
180
+ against Google's public certs (firebase-admin handles fetching/rotation).
181
+ No service-account secret is needed for verification — only the project id.
182
+ """
183
+
184
+ from __future__ import annotations
185
+
186
+ import logging
187
+ from typing import Annotated
188
+
189
+ from fastapi import Depends, Header, HTTPException
190
+
191
+ from app.config import get_settings
192
+
193
+ log = logging.getLogger("app.auth")
194
+
195
+ _initialized = False
196
+
197
+
198
+ def _verify(token: str) -> dict:
199
+ """Verify a Firebase ID token, initializing the SDK lazily (once)."""
200
+ global _initialized
201
+ import firebase_admin
202
+ from firebase_admin import auth as fb_auth
203
+
204
+ if not _initialized:
205
+ firebase_admin.initialize_app(
206
+ options={"projectId": get_settings().firebase_project_id}
207
+ )
208
+ _initialized = True
209
+ return fb_auth.verify_id_token(token)
210
+
211
+
212
+ async def get_owner(authorization: str | None = Header(None)) -> str:
213
+ """FastAPI dependency: the verified Firebase uid of the caller."""
214
+ if not get_settings().firebase_project_id:
215
+ raise HTTPException(status_code=503, detail="auth not configured")
216
+ if not authorization or not authorization.startswith("Bearer "):
217
+ raise HTTPException(status_code=401, detail="missing bearer token")
218
+ token = authorization.removeprefix("Bearer ").strip()
219
+ try:
220
+ decoded = _verify(token)
221
+ except Exception as exc: # firebase raises several exc types; all mean 401
222
+ log.info("token verification failed: %s: %s", type(exc).__name__, exc)
223
+ raise HTTPException(status_code=401, detail="invalid or expired token")
224
+ return str(decoded["uid"])
225
+
226
+
227
+ OwnerDep = Annotated[str, Depends(get_owner)]
228
+ ```
229
+
230
+ Add to `backend/app/config.py` inside `Settings` (near the storage block):
231
+
232
+ ```python
233
+ # auth — Firebase project id; token verification needs no secret.
234
+ firebase_project_id: str = ""
235
+ ```
236
+
237
+ Add `"firebase-admin>=6.5",` to `dependencies` in `backend/pyproject.toml`, then `cd backend && .venv/bin/pip install -e .`.
238
+
239
+ - [ ] **Step 4: Run** — `cd backend && .venv/bin/pytest tests/test_auth.py -v` — Expected: 4 PASS.
240
+
241
+ - [ ] **Step 5: Commit** — `feat: verified Firebase identity dependency (get_owner)`
242
+
243
+ ---
244
+
245
+ ### Task 3: Swap every `x_owner_id` header for `OwnerDep`
246
+
247
+ **Files:**
248
+ - Modify: `backend/app/api/cards.py`, `catalog.py`, `collections.py`, `concepts.py`, `connections.py`, `feed.py`, `graph.py`, `library_chat.py`, `search.py` (≈56 sites)
249
+ - Create: `backend/tests/test_auth_routes.py`
250
+
251
+ **Interfaces:**
252
+ - Consumes: `OwnerDep` from Task 2.
253
+ - Produces: every data route requires auth; tests override `get_owner` via `app.dependency_overrides` (this is also the pattern all later route tests use).
254
+
255
+ - [ ] **Step 1: Write failing tests**
256
+
257
+ ```python
258
+ """Routes reject anonymous callers and scope rows to the verified uid."""
259
+
260
+ import pytest
261
+
262
+ from app.auth import get_owner
263
+ from app.main import app
264
+
265
+
266
+ @pytest.mark.asyncio
267
+ async def test_cards_requires_auth(client) -> None:
268
+ resp = await client.get("/cards")
269
+ # 401 (bad token) or 503 (auth unconfigured in test env) — never 200.
270
+ assert resp.status_code in (401, 503)
271
+
272
+
273
+ @pytest.mark.asyncio
274
+ async def test_owner_scoping(client) -> None:
275
+ app.dependency_overrides[get_owner] = lambda: "uid-a"
276
+ try:
277
+ created = await client.post("/cards", json={"url": "https://example.com/a"})
278
+ assert created.status_code in (200, 201)
279
+ mine = await client.get("/cards")
280
+ assert mine.status_code == 200
281
+
282
+ app.dependency_overrides[get_owner] = lambda: "uid-b"
283
+ theirs = await client.get("/cards")
284
+ assert theirs.status_code == 200
285
+ assert theirs.json() == [] # uid-b sees nothing of uid-a's
286
+ finally:
287
+ app.dependency_overrides.clear()
288
+ ```
289
+
290
+ - [ ] **Step 2: Run to verify failure** — `cd backend && .venv/bin/pytest tests/test_auth_routes.py -v` — Expected: `test_cards_requires_auth` FAILS (anonymous GET /cards currently returns 200).
291
+
292
+ - [ ] **Step 3: Mechanical swap in all 9 API modules**
293
+
294
+ In each file: add `from app.auth import OwnerDep`; replace every parameter
295
+ `x_owner_id: Annotated[str | None, Header()] = None` with `owner_id: OwnerDep`
296
+ and every body use of `x_owner_id` with `owner_id`. Delete now-unused `Header` imports. Where routes special-cased `owner_id is None` (e.g. `cards.py:241`), the branch is dead — owner is always set now; remove the conditional and always filter by owner. Grep afterwards: `grep -rn "x_owner_id" backend/app` must return nothing.
297
+
298
+ The SSE stream route (`/cards/{id}/stream`) gets the same `OwnerDep`.
299
+
300
+ - [ ] **Step 4: Run full suite** — `cd backend && .venv/bin/pytest -v` — Expected: all PASS.
301
+
302
+ - [ ] **Step 5: Commit** — `feat: all data routes require verified owner identity`
303
+
304
+ ---
305
+
306
+ ### Task 4: Usage table + quota dependency
307
+
308
+ **Files:**
309
+ - Modify: `backend/app/store/db.py` (UsageRow + helpers; add `degraded` to JobRow; add both columns to the lightweight migration map near line 383)
310
+ - Modify: `backend/app/config.py` (quota settings)
311
+ - Create: `backend/app/quota.py`
312
+ - Create: `backend/tests/test_quota.py`
313
+
314
+ **Interfaces:**
315
+ - Produces:
316
+ - `db.UsageRow` (`owner_id: str, day: str, kind: str, count: int`, PK = first three)
317
+ - `async def db.spend_usage(session, *, owner_id: str, kind: str, limit: int) -> tuple[bool, int]` — atomically increments and returns `(allowed, used_after)`; increments even when over limit is NOT performed (count stays at limit).
318
+ - `quota.spend(kind: str, limit_attr: str)` — dependency factory raising 429 `{"error": "quota", "kind": ..., "used": ..., "limit": ..., "resets_at": ...}`.
319
+ - `quota.card_budget(owner_id, request, session) -> bool` — non-raising check for card creation (True = within quota, False = degrade), which also enforces the per-IP cap (raising 429 only for the IP cap).
320
+ - `JobRow.degraded: bool` column.
321
+
322
+ - [ ] **Step 1: Write failing tests**
323
+
324
+ ```python
325
+ """Quota accounting: daily counters, limits, per-IP cap, UTC rollover."""
326
+
327
+ import pytest
328
+
329
+ from app.store import db
330
+
331
+
332
+ @pytest.mark.asyncio
333
+ async def test_spend_usage_counts_and_caps(client) -> None: # client fixture builds the DB
334
+ async with db.session() as s:
335
+ for i in range(3):
336
+ allowed, used = await db.spend_usage(s, owner_id="u1", kind="chat", limit=3)
337
+ assert allowed and used == i + 1
338
+ allowed, used = await db.spend_usage(s, owner_id="u1", kind="chat", limit=3)
339
+ assert not allowed and used == 3
340
+
341
+
342
+ @pytest.mark.asyncio
343
+ async def test_usage_is_per_owner_and_per_kind(client) -> None:
344
+ async with db.session() as s:
345
+ await db.spend_usage(s, owner_id="u1", kind="chat", limit=3)
346
+ allowed, used = await db.spend_usage(s, owner_id="u2", kind="chat", limit=3)
347
+ assert allowed and used == 1
348
+ allowed, used = await db.spend_usage(s, owner_id="u1", kind="cards", limit=3)
349
+ assert allowed and used == 1
350
+ ```
351
+
352
+ - [ ] **Step 2: Run to verify failure** — Expected: FAIL, `spend_usage` not defined.
353
+
354
+ - [ ] **Step 3: Implement**
355
+
356
+ `db.py` additions:
357
+
358
+ ```python
359
+ class UsageRow(Base):
360
+ """Daily metered usage. One row per (owner, UTC day, kind); owner_id also
361
+ stores "ip:<addr>" rows for the anonymous-farming IP cap."""
362
+
363
+ __tablename__ = "usage"
364
+
365
+ owner_id: Mapped[str] = mapped_column(String, primary_key=True)
366
+ day: Mapped[str] = mapped_column(String, primary_key=True) # "YYYY-MM-DD" UTC
367
+ kind: Mapped[str] = mapped_column(String, primary_key=True)
368
+ count: Mapped[int] = mapped_column(Integer, default=0)
369
+
370
+
371
+ def _today() -> str:
372
+ """Current UTC day key."""
373
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
374
+
375
+
376
+ async def spend_usage(
377
+ db_session: AsyncSession, *, owner_id: str, kind: str, limit: int
378
+ ) -> tuple[bool, int]:
379
+ """Increment today's counter unless at limit. Returns (allowed, used_after)."""
380
+ day = _today()
381
+ row = await db_session.get(UsageRow, (owner_id, day, kind))
382
+ if row is None:
383
+ row = UsageRow(owner_id=owner_id, day=day, kind=kind, count=0)
384
+ db_session.add(row)
385
+ if row.count >= limit:
386
+ return False, row.count
387
+ row.count += 1
388
+ await db_session.commit()
389
+ return True, row.count
390
+ ```
391
+
392
+ `JobRow` gains `degraded: Mapped[bool] = mapped_column(Boolean, default=False)`; add `"degraded": "BOOLEAN DEFAULT 0"` to the jobs entry of the additive-migration map (the dict near db.py:383 that backfills missing columns).
393
+
394
+ `config.py` additions:
395
+
396
+ ```python
397
+ # quotas (per UTC day)
398
+ quota_cards_per_day: int = 10
399
+ quota_chat_per_day: int = 30
400
+ quota_connections_refresh_per_day: int = 3
401
+ quota_ip_cards_per_day: int = 30
402
+ ```
403
+
404
+ `backend/app/quota.py`:
405
+
406
+ ```python
407
+ """Per-user daily quotas. Chat-style routes raise 429; card creation degrades
408
+ instead (see cards.py) so a save never hard-fails."""
409
+
410
+ from __future__ import annotations
411
+
412
+ from datetime import datetime, time, timezone
413
+
414
+ from fastapi import Depends, HTTPException, Request
415
+
416
+ from app.auth import get_owner
417
+ from app.config import get_settings
418
+ from app.store import db
419
+
420
+
421
+ def _resets_at() -> str:
422
+ """ISO timestamp of the next UTC midnight (quota reset)."""
423
+ now = datetime.now(timezone.utc)
424
+ tomorrow = datetime.combine(now.date(), time.min, tzinfo=timezone.utc)
425
+ return tomorrow.replace(day=now.day).isoformat() # replaced in Step 3b
426
+
427
+
428
+ def spend(kind: str, limit_attr: str):
429
+ """Dependency factory: spend one unit of `kind` or raise 429."""
430
+
431
+ async def _dep(owner_id: str = Depends(get_owner)) -> str:
432
+ limit = getattr(get_settings(), limit_attr)
433
+ async with db.session() as s:
434
+ allowed, used = await db.spend_usage(
435
+ s, owner_id=owner_id, kind=kind, limit=limit
436
+ )
437
+ if not allowed:
438
+ raise HTTPException(
439
+ status_code=429,
440
+ detail={
441
+ "error": "quota", "kind": kind,
442
+ "used": used, "limit": limit, "resets_at": _resets_at(),
443
+ },
444
+ )
445
+ return owner_id
446
+
447
+ return _dep
448
+
449
+
450
+ async def card_budget(owner_id: str, request: Request) -> bool:
451
+ """Card-creation budget. Enforces the per-IP cap (429) and returns whether
452
+ the owner still has AI budget today (False -> degrade, never fail)."""
453
+ settings = get_settings()
454
+ ip = request.client.host if request.client else "unknown"
455
+ async with db.session() as s:
456
+ ip_ok, _ = await db.spend_usage(
457
+ s, owner_id=f"ip:{ip}", kind="cards", limit=settings.quota_ip_cards_per_day
458
+ )
459
+ if not ip_ok:
460
+ raise HTTPException(status_code=429, detail={
461
+ "error": "quota", "kind": "ip", "limit": settings.quota_ip_cards_per_day,
462
+ "used": settings.quota_ip_cards_per_day, "resets_at": _resets_at(),
463
+ })
464
+ allowed, _ = await db.spend_usage(
465
+ s, owner_id=owner_id, kind="cards", limit=settings.quota_cards_per_day
466
+ )
467
+ return allowed
468
+ ```
469
+
470
+ - [ ] **Step 3b: Fix `_resets_at`** — the naive `.replace(day=...)` is wrong at month end. Use:
471
+
472
+ ```python
473
+ from datetime import timedelta
474
+
475
+ def _resets_at() -> str:
476
+ now = datetime.now(timezone.utc)
477
+ tomorrow = datetime.combine(now.date() + timedelta(days=1), time.min, tzinfo=timezone.utc)
478
+ return tomorrow.isoformat()
479
+ ```
480
+
481
+ - [ ] **Step 4: Run** — `cd backend && .venv/bin/pytest tests/test_quota.py -v` — Expected: PASS.
482
+
483
+ - [ ] **Step 5: Commit** — `feat: usage table + daily quota accounting`
484
+
485
+ ---
486
+
487
+ ### Task 5: Wire quotas into routes; degrade card creation
488
+
489
+ **Files:**
490
+ - Modify: `backend/app/api/cards.py` (create route + chat + rabbithole), `backend/app/api/library_chat.py`, `backend/app/api/connections.py`
491
+ - Modify: `backend/app/pipeline/worker.py` + `backend/app/pipeline/structuring.py` (degraded path)
492
+ - Create: `backend/app/api/me.py` (`GET /me/quota`), register in `main.py`
493
+ - Create: `backend/tests/test_quota_routes.py`
494
+
495
+ **Interfaces:**
496
+ - Consumes: `quota.spend`, `quota.card_budget`, `JobRow.degraded` from Task 4.
497
+ - Produces: `structure(bundle, transcript, caption, force_fallback: bool = False)` — when True returns `_paragraph_fallback(...)` immediately, with `degraded=True, degraded_reason="quota"`. `GET /me/quota` → `{"cards": {"used": n, "limit": n}, "chat": {...}, "resets_at": iso}`.
498
+
499
+ - [ ] **Step 1: Write failing tests**
500
+
501
+ ```python
502
+ """Quota wiring: chat 429s past limit; card creation degrades; /me/quota reports."""
503
+
504
+ import pytest
505
+
506
+ from app.auth import get_owner
507
+ from app.main import app
508
+ from app.store import db
509
+
510
+
511
+ @pytest.fixture(autouse=True)
512
+ def _small_limits(monkeypatch):
513
+ monkeypatch.setenv("QUOTA_CARDS_PER_DAY", "1")
514
+ monkeypatch.setenv("QUOTA_CHAT_PER_DAY", "1")
515
+ from app import config
516
+ config.get_settings.cache_clear()
517
+ yield
518
+ config.get_settings.cache_clear()
519
+
520
+
521
+ @pytest.fixture
522
+ def as_user():
523
+ app.dependency_overrides[get_owner] = lambda: "uid-q"
524
+ yield
525
+ app.dependency_overrides.clear()
526
+
527
+
528
+ @pytest.mark.asyncio
529
+ async def test_card_creation_degrades_past_quota(client, as_user) -> None:
530
+ r1 = await client.post("/cards", json={"url": "https://example.com/1"})
531
+ assert r1.status_code in (200, 201)
532
+ r2 = await client.post("/cards", json={"url": "https://example.com/2"})
533
+ assert r2.status_code in (200, 201) # degrade, never fail
534
+ async with db.session() as s:
535
+ jobs = (await s.execute(db.select(db.JobRow).order_by(db.JobRow.created_at))).scalars().all()
536
+ assert [j.degraded for j in jobs] == [False, True]
537
+
538
+
539
+ @pytest.mark.asyncio
540
+ async def test_me_quota(client, as_user) -> None:
541
+ resp = await client.get("/me/quota")
542
+ assert resp.status_code == 200
543
+ body = resp.json()
544
+ assert body["cards"]["limit"] == 1 and "resets_at" in body
545
+ ```
546
+
547
+ (If `db.select` isn't re-exported, import `select` from sqlalchemy in the test.)
548
+
549
+ - [ ] **Step 2: Run to verify failure** — Expected: FAIL (`degraded` never True; /me/quota 404).
550
+
551
+ - [ ] **Step 3: Implement**
552
+
553
+ `cards.py` create route: add `request: Request` param; after resolving the cache-miss path and before creating the JobRow, call `within = await quota.card_budget(owner_id, request)`; create the job with `degraded=not within`; include `"quota_degraded": not within` in the response body.
554
+
555
+ Chat routes: append `dependencies=[Depends(quota.spend("chat", "quota_chat_per_day"))]` to the route decorators of card chat, rabbithole (in `cards.py`) and library chat (`library_chat.py`). Connections refresh: inside the handler, only when `refresh=True`, call the spend dependency logic directly:
556
+
557
+ ```python
558
+ if refresh:
559
+ await quota.spend("connections_refresh", "quota_connections_refresh_per_day")(owner_id)
560
+ ```
561
+
562
+ `structuring.py`: change signature to `def structure(bundle: str, transcript: str = "", caption: str = "", force_fallback: bool = False) -> "StructuredCard":` and as the first statement:
563
+
564
+ ```python
565
+ if force_fallback:
566
+ log.info("structuring: quota-degraded card -> paragraph fallback")
567
+ return _paragraph_fallback(bundle, transcript, caption, reason="quota")
568
+ ```
569
+
570
+ Match `_paragraph_fallback`'s real signature (read it first; pass whatever it actually takes, setting `degraded_reason="quota"` however the non-forced fallback path does).
571
+
572
+ `worker.py` `_run_job`: where `structure(...)` is called, pass `force_fallback=job.degraded`.
573
+
574
+ `me.py`:
575
+
576
+ ```python
577
+ """The caller's own quota status — powers the profile meter."""
578
+
579
+ from __future__ import annotations
580
+
581
+ from fastapi import APIRouter
582
+
583
+ from app.auth import OwnerDep
584
+ from app.config import get_settings
585
+ from app.quota import _resets_at
586
+ from app.store import db
587
+
588
+ router = APIRouter(prefix="/me", tags=["me"])
589
+
590
+
591
+ @router.get("/quota")
592
+ async def my_quota(owner_id: OwnerDep) -> dict:
593
+ """Today's used/limit per metered kind."""
594
+ settings = get_settings()
595
+ day = db._today()
596
+ out: dict = {"resets_at": _resets_at()}
597
+ async with db.session() as s:
598
+ for kind, limit in (
599
+ ("cards", settings.quota_cards_per_day),
600
+ ("chat", settings.quota_chat_per_day),
601
+ ):
602
+ row = await s.get(db.UsageRow, (owner_id, day, kind))
603
+ out[kind] = {"used": row.count if row else 0, "limit": limit}
604
+ return out
605
+ ```
606
+
607
+ Register in `main.py`: `from app.api import me` + `app.include_router(me.router)`.
608
+
609
+ - [ ] **Step 4: Run full suite** — `cd backend && .venv/bin/pytest -v` — Expected: all PASS.
610
+
611
+ - [ ] **Step 5: Commit** — `feat: quotas wired — chat 429s, cards degrade, /me/quota`
612
+
613
+ ---
614
+
615
+ ### Task 6: `/auth/claim` — legacy name migration
616
+
617
+ **Files:**
618
+ - Modify: `backend/app/store/db.py` (ClaimRow + `claim_owner` helper)
619
+ - Create: `backend/app/api/auth_routes.py`, register in `main.py`
620
+ - Create: `backend/tests/test_claim.py`
621
+
622
+ **Interfaces:**
623
+ - Produces: `POST /auth/claim {"name": str}` → 200 `{"claimed": <row count>}` or 409 if the name was already claimed. `db.claim_owner(session, *, name: str, uid: str) -> int | None` (None = already claimed by someone else).
624
+
625
+ - [ ] **Step 1: Write failing tests**
626
+
627
+ ```python
628
+ """First-claim-wins migration of legacy name-keyed rows."""
629
+
630
+ import pytest
631
+
632
+ from app.auth import get_owner
633
+ from app.main import app
634
+ from app.store import db
635
+
636
+
637
+ async def _seed_legacy_card(name: str) -> None:
638
+ async with db.session() as s:
639
+ s.add(db.CardRow(owner_id=name, url=f"https://example.com/{name}", state="ready"))
640
+ await s.commit()
641
+
642
+
643
+ @pytest.mark.asyncio
644
+ async def test_claim_repoints_rows(client) -> None:
645
+ await _seed_legacy_card("Vatsal")
646
+ app.dependency_overrides[get_owner] = lambda: "uid-new"
647
+ try:
648
+ resp = await client.post("/auth/claim", json={"name": "Vatsal"})
649
+ assert resp.status_code == 200 and resp.json()["claimed"] >= 1
650
+ again = await client.post("/auth/claim", json={"name": "Vatsal"})
651
+ assert again.status_code == 200 # same uid re-claiming is a no-op success
652
+ app.dependency_overrides[get_owner] = lambda: "uid-thief"
653
+ stolen = await client.post("/auth/claim", json={"name": "Vatsal"})
654
+ assert stolen.status_code == 409
655
+ finally:
656
+ app.dependency_overrides.clear()
657
+ ```
658
+
659
+ Adjust `_seed_legacy_card` to CardRow's actual required columns (read the model; fill mandatory fields minimally).
660
+
661
+ - [ ] **Step 2: Run to verify failure** — Expected: 404 on /auth/claim.
662
+
663
+ - [ ] **Step 3: Implement**
664
+
665
+ `db.py`:
666
+
667
+ ```python
668
+ class ClaimRow(Base):
669
+ """Legacy display-name -> uid claims; first claim wins."""
670
+
671
+ __tablename__ = "claims"
672
+
673
+ name: Mapped[str] = mapped_column(String, primary_key=True)
674
+ uid: Mapped[str] = mapped_column(String, nullable=False)
675
+
676
+
677
+ async def claim_owner(db_session: AsyncSession, *, name: str, uid: str) -> int | None:
678
+ """Re-point every legacy row owned by `name` to `uid`. None = taken."""
679
+ existing = await db_session.get(ClaimRow, name)
680
+ if existing is not None:
681
+ return 0 if existing.uid == uid else None
682
+ db_session.add(ClaimRow(name=name, uid=uid))
683
+ total = 0
684
+ for model in (CardRow, CollectionRow, ConversationRow, ConnectionRow):
685
+ res = await db_session.execute(
686
+ update(model).where(model.owner_id == name).values(owner_id=uid)
687
+ )
688
+ total += res.rowcount or 0
689
+ await db_session.commit()
690
+ return total
691
+ ```
692
+
693
+ `auth_routes.py`:
694
+
695
+ ```python
696
+ """Temporary migration endpoint (delete ~1 month after auth ships)."""
697
+
698
+ from __future__ import annotations
699
+
700
+ from fastapi import APIRouter, HTTPException
701
+ from pydantic import BaseModel
702
+
703
+ from app.auth import OwnerDep
704
+ from app.store import db
705
+
706
+ router = APIRouter(prefix="/auth", tags=["auth"])
707
+
708
+
709
+ class ClaimRequest(BaseModel):
710
+ name: str
711
+
712
+
713
+ @router.post("/claim")
714
+ async def claim(req: ClaimRequest, owner_id: OwnerDep) -> dict:
715
+ """Adopt legacy rows keyed by the pre-auth display name. First claim wins."""
716
+ name = req.name.strip()
717
+ if not name:
718
+ raise HTTPException(status_code=422, detail="name required")
719
+ async with db.session() as s:
720
+ claimed = await db.claim_owner(s, name=name, uid=owner_id)
721
+ if claimed is None:
722
+ raise HTTPException(status_code=409, detail="name already claimed")
723
+ return {"claimed": claimed}
724
+ ```
725
+
726
+ - [ ] **Step 4: Run** — `cd backend && .venv/bin/pytest tests/test_claim.py -v` — Expected: PASS.
727
+
728
+ - [ ] **Step 5: Commit** — `feat: /auth/claim legacy library migration (first-claim-wins)`
729
+
730
+ ---
731
+
732
+ ### Task 7: Hardening — admin token, clean 500s, CORS
733
+
734
+ **Files:**
735
+ - Modify: `backend/app/main.py`, `backend/app/config.py`
736
+ - Create: `backend/tests/test_hardening.py`
737
+
738
+ **Interfaces:**
739
+ - Consumes: nothing new. Produces: `Settings.admin_token: str = ""`, `Settings.cors_origins: str = ""` (comma-separated).
740
+
741
+ - [ ] **Step 1: Write failing tests**
742
+
743
+ ```python
744
+ """Admin/debug gated; 500s never leak tracebacks."""
745
+
746
+ import pytest
747
+
748
+
749
+ @pytest.mark.asyncio
750
+ async def test_admin_requires_token(client, monkeypatch) -> None:
751
+ monkeypatch.setenv("ADMIN_TOKEN", "s3cret")
752
+ from app import config
753
+ config.get_settings.cache_clear()
754
+ assert (await client.get("/admin/stats")).status_code == 401
755
+ assert (await client.get("/debug/jobs")).status_code == 401
756
+ ok = await client.get("/admin/stats", headers={"x-admin-token": "s3cret"})
757
+ assert ok.status_code == 200
758
+
759
+
760
+ @pytest.mark.asyncio
761
+ async def test_admin_disabled_when_unset(client, monkeypatch) -> None:
762
+ monkeypatch.delenv("ADMIN_TOKEN", raising=False)
763
+ from app import config
764
+ config.get_settings.cache_clear()
765
+ assert (await client.get("/debug/jobs")).status_code == 401
766
+ ```
767
+
768
+ - [ ] **Step 2: Run to verify failure** — Expected: FAIL (currently 200 without token).
769
+
770
+ - [ ] **Step 3: Implement**
771
+
772
+ `config.py`: add `admin_token: str = ""` and `cors_origins: str = ""`.
773
+
774
+ `main.py`:
775
+
776
+ ```python
777
+ from fastapi import Depends, Header, HTTPException
778
+
779
+ async def require_admin(x_admin_token: str | None = Header(None)) -> None:
780
+ """Gate for owner-only endpoints; unset ADMIN_TOKEN disables them entirely."""
781
+ expected = get_settings().admin_token
782
+ if not expected or x_admin_token != expected:
783
+ raise HTTPException(status_code=401, detail="admin token required")
784
+ ```
785
+
786
+ Add `dependencies=[Depends(require_admin)]` to the `/admin/stats`, `/debug/jobs`, `/debug/kill_stuck` decorators. Replace the 500 handler body: keep the full-traceback `log.error`, return only `{"detail": "internal error"}` (no exception text, no traceback). CORS: `allow_origins=[o.strip() for o in get_settings().cors_origins.split(",") if o.strip()] or ["http://localhost:8000"]` — set the real Space origin via env in deployment. Import `get_settings` from `app.config` at top of main.py.
787
+
788
+ - [ ] **Step 4: Run full suite** — `cd backend && .venv/bin/pytest -v` — Expected: all PASS. Also verify: `grep -rn "traceback" backend/app/main.py` shows logging only, not response content.
789
+
790
+ - [ ] **Step 5: Commit** — `feat: admin token gate, clean 500s, restricted CORS`
docs/planning/plans/2026-07-10-flutter-auth.md ADDED
@@ -0,0 +1,625 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Flutter Auth Integration Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Firebase identity in the app — anonymous sign-in after onboarding+name, optional Google login screen ("Or use without login…"), bearer-token transport, real sign-out, quota meter, and legacy-library claim.
6
+
7
+ **Architecture:** An `AuthService` wraps FirebaseAuth (anonymous start, Google link/sign-in, token access). `ApiClient` gains a token provider injected at construction and attaches `Authorization: Bearer` to every request with one 401-retry-after-refresh. `RootGate` gains a login step between onboarding/name and the shell. Profile gets an account section + quota meter.
8
+
9
+ **Tech Stack:** Flutter, firebase_core, firebase_auth, google_sign_in, provider, flutter_test with mocked services (no Firebase in unit tests).
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: `docs/superpowers/specs/2026-07-10-public-distribution-auth-quotas-design.md`. Backend plan (`2026-07-10-backend-auth-quotas.md`) must be deployed for end-to-end flows; unit tests here never hit the network.
14
+ - Login screen comes AFTER onboarding + name screen. Primary: "Continue with Google". Below, small quiet text: "Or use without login…". The typed name stays the greeting; uid is identity.
15
+ - Anonymous users see a persistent profile banner: "Your library isn't backed up — sign in with Google", plus warning copy that data may be lost.
16
+ - Dart style: provider/ChangeNotifier, business logic outside widgets, strict null safety, brand tokens from `brand.dart`/`theme.dart`.
17
+ - Run `cd app && flutter test` after every task; `flutter analyze` must stay clean.
18
+ - The user's standing rule: no commits unless asked in-session; Commit steps are conditional on that permission.
19
+
20
+ **One-time owner setup (NOT part of this plan; do first, manually):** create Firebase project → enable Anonymous + Google providers → `flutterfire configure` (writes `app/lib/firebase_options.dart` + `app/android/app/google-services.json`) → register release SHA-1 → set `FIREBASE_PROJECT_ID` on the HF Space. Tasks below assume `firebase_options.dart` exists.
21
+
22
+ ---
23
+
24
+ ### Task 1: AuthService — testable Firebase wrapper
25
+
26
+ **Files:**
27
+ - Modify: `app/pubspec.yaml` (add `firebase_core: ^3.6.0`, `firebase_auth: ^5.3.1`, `google_sign_in: ^6.2.1`)
28
+ - Create: `app/lib/data/services/auth_service.dart`
29
+ - Create: `app/test/auth_service_test.dart`
30
+ - Modify: `app/lib/main.dart` (Firebase.initializeApp before runApp)
31
+
32
+ **Interfaces:**
33
+ - Produces:
34
+
35
+ ```dart
36
+ /// Wraps FirebaseAuth so the rest of the app never imports firebase directly
37
+ /// (and tests can fake it).
38
+ abstract class AuthService {
39
+ Stream<AuthUser?> get userChanges;
40
+ AuthUser? get currentUser;
41
+ Future<AuthUser> signInAnonymously();
42
+ Future<AuthUser> signInWithGoogle(); // links when currently anonymous
43
+ Future<String?> idToken({bool forceRefresh = false});
44
+ Future<void> signOut();
45
+ }
46
+
47
+ class AuthUser {
48
+ const AuthUser({required this.uid, required this.isAnonymous, this.email, this.displayName, this.photoUrl});
49
+ final String uid;
50
+ final bool isAnonymous;
51
+ final String? email;
52
+ final String? displayName;
53
+ final String? photoUrl;
54
+ }
55
+ ```
56
+
57
+ - [ ] **Step 1: Write the failing test** (against a `FakeAuthService` used by all later tests, plus the linking contract)
58
+
59
+ ```dart
60
+ import 'dart:async';
61
+
62
+ import 'package:flutter_test/flutter_test.dart';
63
+ import 'package:cachy/data/services/auth_service.dart';
64
+
65
+ /// Deterministic in-memory AuthService for widget/unit tests.
66
+ class FakeAuthService implements AuthService {
67
+ AuthUser? _user;
68
+ final _controller = StreamController<AuthUser?>.broadcast();
69
+ String tokenValue = 'fake-token';
70
+
71
+ @override
72
+ Stream<AuthUser?> get userChanges => _controller.stream;
73
+ @override
74
+ AuthUser? get currentUser => _user;
75
+
76
+ @override
77
+ Future<AuthUser> signInAnonymously() async {
78
+ _user = const AuthUser(uid: 'anon-1', isAnonymous: true);
79
+ _controller.add(_user);
80
+ return _user!;
81
+ }
82
+
83
+ @override
84
+ Future<AuthUser> signInWithGoogle() async {
85
+ // Linking keeps the uid when the current user is anonymous.
86
+ final uid = _user?.isAnonymous == true ? _user!.uid : 'google-1';
87
+ _user = AuthUser(uid: uid, isAnonymous: false, email: 'a@b.c', displayName: 'A');
88
+ _controller.add(_user);
89
+ return _user!;
90
+ }
91
+
92
+ @override
93
+ Future<String?> idToken({bool forceRefresh = false}) async =>
94
+ _user == null ? null : tokenValue;
95
+
96
+ @override
97
+ Future<void> signOut() async {
98
+ _user = null;
99
+ _controller.add(null);
100
+ }
101
+ }
102
+
103
+ void main() {
104
+ test('google sign-in after anonymous keeps the uid (link semantics)', () async {
105
+ final auth = FakeAuthService();
106
+ final anon = await auth.signInAnonymously();
107
+ final linked = await auth.signInWithGoogle();
108
+ expect(linked.uid, anon.uid);
109
+ expect(linked.isAnonymous, isFalse);
110
+ });
111
+
112
+ test('no token when signed out', () async {
113
+ final auth = FakeAuthService();
114
+ expect(await auth.idToken(), isNull);
115
+ await auth.signInAnonymously();
116
+ expect(await auth.idToken(), 'fake-token');
117
+ });
118
+ }
119
+ ```
120
+
121
+ Save `FakeAuthService` in the test file now; Task 3 moves it to `app/test/fakes.dart` for reuse.
122
+
123
+ - [ ] **Step 2: Run to verify failure** — `cd app && flutter test test/auth_service_test.dart` — Expected: FAIL, `auth_service.dart` missing.
124
+
125
+ - [ ] **Step 3: Implement `auth_service.dart`**
126
+
127
+ ```dart
128
+ /// Identity layer: Firebase anonymous-first with optional Google upgrade.
129
+ /// The uid is the backend `owner_id`; linking preserves it so no data moves.
130
+ library;
131
+
132
+ import 'package:firebase_auth/firebase_auth.dart' as fb;
133
+ import 'package:google_sign_in/google_sign_in.dart';
134
+
135
+ class AuthUser {
136
+ const AuthUser({
137
+ required this.uid,
138
+ required this.isAnonymous,
139
+ this.email,
140
+ this.displayName,
141
+ this.photoUrl,
142
+ });
143
+ final String uid;
144
+ final bool isAnonymous;
145
+ final String? email;
146
+ final String? displayName;
147
+ final String? photoUrl;
148
+ }
149
+
150
+ abstract class AuthService {
151
+ Stream<AuthUser?> get userChanges;
152
+ AuthUser? get currentUser;
153
+ Future<AuthUser> signInAnonymously();
154
+ Future<AuthUser> signInWithGoogle();
155
+ Future<String?> idToken({bool forceRefresh = false});
156
+ Future<void> signOut();
157
+ }
158
+
159
+ class FirebaseAuthService implements AuthService {
160
+ FirebaseAuthService({fb.FirebaseAuth? auth, GoogleSignIn? google})
161
+ : _auth = auth ?? fb.FirebaseAuth.instance,
162
+ _google = google ?? GoogleSignIn();
163
+
164
+ final fb.FirebaseAuth _auth;
165
+ final GoogleSignIn _google;
166
+
167
+ AuthUser? _map(fb.User? u) => u == null
168
+ ? null
169
+ : AuthUser(
170
+ uid: u.uid,
171
+ isAnonymous: u.isAnonymous,
172
+ email: u.email,
173
+ displayName: u.displayName,
174
+ photoUrl: u.photoURL,
175
+ );
176
+
177
+ @override
178
+ Stream<AuthUser?> get userChanges => _auth.userChanges().map(_map);
179
+
180
+ @override
181
+ AuthUser? get currentUser => _map(_auth.currentUser);
182
+
183
+ @override
184
+ Future<AuthUser> signInAnonymously() async {
185
+ final cred = await _auth.signInAnonymously();
186
+ return _map(cred.user)!;
187
+ }
188
+
189
+ @override
190
+ Future<AuthUser> signInWithGoogle() async {
191
+ final account = await _google.signIn();
192
+ if (account == null) throw fb.FirebaseAuthException(code: 'canceled');
193
+ final gAuth = await account.authentication;
194
+ final credential = fb.GoogleAuthProvider.credential(
195
+ idToken: gAuth.idToken,
196
+ accessToken: gAuth.accessToken,
197
+ );
198
+ final current = _auth.currentUser;
199
+ fb.UserCredential cred;
200
+ if (current != null && current.isAnonymous) {
201
+ try {
202
+ cred = await current.linkWithCredential(credential); // uid preserved
203
+ } on fb.FirebaseAuthException catch (e) {
204
+ if (e.code != 'credential-already-in-use') rethrow;
205
+ // Google account already has a Cachy identity — switch to it.
206
+ cred = await _auth.signInWithCredential(credential);
207
+ }
208
+ } else {
209
+ cred = await _auth.signInWithCredential(credential);
210
+ }
211
+ return _map(cred.user)!;
212
+ }
213
+
214
+ @override
215
+ Future<String?> idToken({bool forceRefresh = false}) =>
216
+ _auth.currentUser?.getIdToken(forceRefresh) ?? Future.value(null);
217
+
218
+ @override
219
+ Future<void> signOut() async {
220
+ await _google.signOut();
221
+ await _auth.signOut();
222
+ }
223
+ }
224
+ ```
225
+
226
+ `main.dart`: before `runApp`, add
227
+
228
+ ```dart
229
+ await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
230
+ ```
231
+
232
+ with imports `package:firebase_core/firebase_core.dart` and `firebase_options.dart`; provide `AuthService` alongside the existing providers: `Provider<AuthService>(create: (_) => FirebaseAuthService())`.
233
+
234
+ - [ ] **Step 4: Run** — `cd app && flutter test test/auth_service_test.dart && flutter analyze` — Expected: PASS, no new analyzer issues.
235
+
236
+ - [ ] **Step 5: Commit** — `feat: AuthService — anonymous-first Firebase identity with Google linking`
237
+
238
+ ---
239
+
240
+ ### Task 2: Bearer token transport in ApiClient (with one 401 retry)
241
+
242
+ **Files:**
243
+ - Modify: `app/lib/data/services/api_client.dart`
244
+ - Create: `app/test/api_client_auth_test.dart`
245
+
246
+ **Interfaces:**
247
+ - Consumes: `AuthService.idToken` (Task 1).
248
+ - Produces: `ApiClient({..., Future<String?> Function({bool forceRefresh})? tokenProvider})`; private `Future<http.Response> _send(...)` used by all verbs; `_ownerHeader` and every `x-owner-id` reference deleted.
249
+
250
+ - [ ] **Step 1: Write the failing test** (MockClient counts auth headers + retry behavior)
251
+
252
+ ```dart
253
+ import 'dart:convert';
254
+
255
+ import 'package:flutter_test/flutter_test.dart';
256
+ import 'package:http/http.dart' as http;
257
+ import 'package:http/testing.dart';
258
+ import 'package:cachy/data/services/api_client.dart';
259
+
260
+ void main() {
261
+ test('attaches bearer token to requests', () async {
262
+ String? seenAuth;
263
+ final mock = MockClient((req) async {
264
+ seenAuth = req.headers['authorization'];
265
+ return http.Response(jsonEncode([]), 200);
266
+ });
267
+ final api = ApiClient(
268
+ baseUrl: 'http://x',
269
+ client: mock,
270
+ tokenProvider: ({bool forceRefresh = false}) async => 'tok-1',
271
+ );
272
+ await api.listCards();
273
+ expect(seenAuth, 'Bearer tok-1');
274
+ });
275
+
276
+ test('one forced-refresh retry on 401', () async {
277
+ var calls = 0;
278
+ final mock = MockClient((req) async {
279
+ calls++;
280
+ if (req.headers['authorization'] == 'Bearer stale') {
281
+ return http.Response('unauthorized', 401);
282
+ }
283
+ return http.Response(jsonEncode([]), 200);
284
+ });
285
+ var fresh = false;
286
+ final api = ApiClient(
287
+ baseUrl: 'http://x',
288
+ client: mock,
289
+ tokenProvider: ({bool forceRefresh = false}) async {
290
+ if (forceRefresh) fresh = true;
291
+ return fresh ? 'fresh' : 'stale';
292
+ },
293
+ );
294
+ final cards = await api.listCards();
295
+ expect(cards, isEmpty);
296
+ expect(calls, 2); // 401 then success — exactly one retry
297
+ });
298
+ }
299
+ ```
300
+
301
+ - [ ] **Step 2: Run to verify failure** — `cd app && flutter test test/api_client_auth_test.dart` — Expected: FAIL, no `tokenProvider` parameter.
302
+
303
+ - [ ] **Step 3: Implement in `api_client.dart`**
304
+
305
+ Constructor gains `this.tokenProvider`; field `final Future<String?> Function({bool forceRefresh})? tokenProvider;`. Replace `_ownerHeader` with:
306
+
307
+ ```dart
308
+ Future<Map<String, String>> _authHeader({bool forceRefresh = false}) async {
309
+ final token = await tokenProvider?.call(forceRefresh: forceRefresh);
310
+ if (token == null || token.isEmpty) return const {};
311
+ return {'authorization': 'Bearer $token'};
312
+ }
313
+
314
+ /// All verbs funnel through here: auth header + one refresh-retry on 401.
315
+ Future<http.Response> _send(
316
+ Future<http.Response> Function(Map<String, String> headers) go, {
317
+ Map<String, String> extra = const {},
318
+ }) async {
319
+ var resp = await go({...extra, ...await _authHeader()});
320
+ if (resp.statusCode == 401 && tokenProvider != null) {
321
+ resp = await go({...extra, ...await _authHeader(forceRefresh: true)});
322
+ }
323
+ return resp;
324
+ }
325
+ ```
326
+
327
+ Then mechanically rewrite each call site, e.g.:
328
+
329
+ ```dart
330
+ Future<List<Card>> listCards({...}) async {
331
+ final resp = await _send((h) => _client.get(_uri('/cards', {...}), headers: h));
332
+ return _decodeList(resp).map(Card.fromJson).toList();
333
+ }
334
+
335
+ Future<CreateCardResult> createCard(String url) async {
336
+ final resp = await _send(
337
+ (h) => _client.post(_uri('/cards'), headers: h, body: jsonEncode({'url': url})),
338
+ extra: const {'content-type': 'application/json'},
339
+ );
340
+ ...
341
+ }
342
+ ```
343
+
344
+ Apply to every method that used `_ownerHeader` (grep: `_ownerHeader` must return zero hits afterwards). `streamCard` (SSE): add the awaited auth header to `request.headers` before `_client.send(request)` (no retry loop needed — the reader screen re-subscribes on error). In `main.dart`/composition root, construct `ApiClient(..., tokenProvider: ({bool forceRefresh = false}) => context.read<AuthService>().idToken(forceRefresh: forceRefresh))` — wire via the existing repository setup (pass AuthService into wherever ApiClient is built today).
345
+
346
+ - [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
347
+
348
+ - [ ] **Step 5: Commit** — `feat: bearer-token transport with single 401 refresh-retry`
349
+
350
+ ---
351
+
352
+ ### Task 3: Login screen after onboarding + RootGate wiring
353
+
354
+ **Files:**
355
+ - Create: `app/lib/ui/features/onboarding/views/login_screen.dart`
356
+ - Modify: `app/lib/ui/core/root_gate.dart` (insert login step after name; read its current step logic first and mirror its pattern)
357
+ - Modify: `app/lib/ui/core/app_controller.dart` (expose `authUser`, `signInWithGoogle()`, `continueAnonymously()`, listening to `AuthService.userChanges`)
358
+ - Create: `app/test/fakes.dart` (move `FakeAuthService` here)
359
+ - Create: `app/test/login_screen_test.dart`
360
+
361
+ **Interfaces:**
362
+ - Consumes: `AuthService` (Task 1).
363
+ - Produces: `LoginScreen({required VoidCallback onDone})`; AppController: `AuthUser? get authUser`, `bool get needsLogin` (seen onboarding + has name + no firebase user), `Future<void> signInWithGoogle()`, `Future<void> continueAnonymously()`.
364
+
365
+ - [ ] **Step 1: Write the failing widget test**
366
+
367
+ ```dart
368
+ import 'package:flutter/material.dart';
369
+ import 'package:flutter_test/flutter_test.dart';
370
+ import 'package:provider/provider.dart';
371
+ import 'package:cachy/data/services/auth_service.dart';
372
+ import 'package:cachy/ui/features/onboarding/views/login_screen.dart';
373
+
374
+ import 'fakes.dart';
375
+
376
+ void main() {
377
+ testWidgets('login screen: Google primary, quiet anonymous path', (tester) async {
378
+ final auth = FakeAuthService();
379
+ var done = 0;
380
+ await tester.pumpWidget(
381
+ Provider<AuthService>.value(
382
+ value: auth,
383
+ child: MaterialApp(home: LoginScreen(onDone: () => done++)),
384
+ ),
385
+ );
386
+ expect(find.text('Continue with Google'), findsOneWidget);
387
+ expect(find.text('Or use without login…'), findsOneWidget);
388
+
389
+ await tester.tap(find.text('Or use without login…'));
390
+ await tester.pumpAndSettle();
391
+ expect(auth.currentUser?.isAnonymous, isTrue);
392
+ expect(done, 1);
393
+ });
394
+ }
395
+ ```
396
+
397
+ - [ ] **Step 2: Run to verify failure** — Expected: FAIL, `login_screen.dart` missing.
398
+
399
+ - [ ] **Step 3: Implement `login_screen.dart`**
400
+
401
+ Visual language mirrors `name_screen.dart` (same radial gradient scaffold, Fraunces headline via theme, `Brand` tokens — no direct GoogleFonts). Core structure:
402
+
403
+ ```dart
404
+ /// Login gate, shown after onboarding + name. Google is primary; anonymous is
405
+ /// a quiet escape hatch with an honest data-loss caveat.
406
+ library;
407
+
408
+ import 'package:flutter/material.dart';
409
+ import 'package:phosphor_flutter/phosphor_flutter.dart';
410
+ import 'package:provider/provider.dart';
411
+
412
+ import '../../../../data/services/auth_service.dart';
413
+ import '../../../core/brand.dart';
414
+ import '../../../core/widgets/responsive_center.dart';
415
+
416
+ class LoginScreen extends StatefulWidget {
417
+ const LoginScreen({super.key, required this.onDone});
418
+ final VoidCallback onDone;
419
+
420
+ @override
421
+ State<LoginScreen> createState() => _LoginScreenState();
422
+ }
423
+
424
+ class _LoginScreenState extends State<LoginScreen> {
425
+ bool _busy = false;
426
+ String? _error;
427
+
428
+ Future<void> _run(Future<void> Function() action) async {
429
+ setState(() { _busy = true; _error = null; });
430
+ try {
431
+ await action();
432
+ if (mounted) widget.onDone();
433
+ } catch (_) {
434
+ if (mounted) {
435
+ setState(() => _error = "Couldn't sign in. Check your connection and try again.");
436
+ }
437
+ } finally {
438
+ if (mounted) setState(() => _busy = false);
439
+ }
440
+ }
441
+
442
+ @override
443
+ Widget build(BuildContext context) {
444
+ final theme = Theme.of(context);
445
+ final scheme = theme.colorScheme;
446
+ final auth = context.read<AuthService>();
447
+ return Scaffold(
448
+ backgroundColor: scheme.surface,
449
+ body: SafeArea(
450
+ child: ResponsiveCenter(
451
+ child: Padding(
452
+ padding: const EdgeInsets.symmetric(horizontal: 28),
453
+ child: Column(
454
+ crossAxisAlignment: CrossAxisAlignment.start,
455
+ children: [
456
+ const SizedBox(height: 48),
457
+ const CachyGlyph(size: 56),
458
+ const SizedBox(height: 32),
459
+ Text('Keep your\nlibrary safe.',
460
+ style: theme.textTheme.displaySmall),
461
+ const SizedBox(height: 16),
462
+ Text(
463
+ 'Sign in so your cards follow you to any device — and survive a reinstall.',
464
+ style: theme.textTheme.bodyLarge?.copyWith(
465
+ color: scheme.onSurfaceVariant, height: 1.5),
466
+ ),
467
+ if (_error != null) ...[
468
+ const SizedBox(height: 16),
469
+ Text(_error!, style: TextStyle(color: scheme.error)),
470
+ ],
471
+ const Spacer(),
472
+ FilledButton.icon(
473
+ onPressed: _busy ? null : () => _run(() async { await auth.signInWithGoogle(); }),
474
+ icon: const PhosphorIcon(PhosphorIconsRegular.googleLogo, size: 20),
475
+ label: const Text('Continue with Google'),
476
+ style: FilledButton.styleFrom(
477
+ minimumSize: const Size.fromHeight(56),
478
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
479
+ ),
480
+ ),
481
+ const SizedBox(height: 14),
482
+ Center(
483
+ child: TextButton(
484
+ onPressed: _busy ? null : () => _run(() async { await auth.signInAnonymously(); }),
485
+ child: Text(
486
+ 'Or use without login…',
487
+ style: theme.textTheme.bodySmall?.copyWith(
488
+ color: scheme.onSurfaceVariant,
489
+ decoration: TextDecoration.underline,
490
+ ),
491
+ ),
492
+ ),
493
+ ),
494
+ const SizedBox(height: 6),
495
+ Center(
496
+ child: Text(
497
+ 'Without an account, your library lives only on this device.',
498
+ textAlign: TextAlign.center,
499
+ style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
500
+ ),
501
+ ),
502
+ const SizedBox(height: 24),
503
+ ],
504
+ ),
505
+ ),
506
+ ),
507
+ ),
508
+ );
509
+ }
510
+ }
511
+ ```
512
+
513
+ `app_controller.dart`: inject `AuthService`, subscribe to `userChanges` (notifyListeners), add the three members from Interfaces. `root_gate.dart`: current gate is onboarding → name → shell; make it onboarding → name → login → shell, keyed on `needsLogin` (a returning signed-in user skips the login screen because `currentUser != null`).
514
+
515
+ - [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
516
+
517
+ - [ ] **Step 5: Commit** — `feat: post-onboarding login screen (Google primary, anonymous quiet path)`
518
+
519
+ ---
520
+
521
+ ### Task 4: Profile account section — banner, real sign-out, quota meter, claim
522
+
523
+ **Files:**
524
+ - Modify: `app/lib/ui/features/profile/views/profile_screen.dart`
525
+ - Modify: `app/lib/data/services/api_client.dart` (+`me()` quota fetch, +`claim(name)`)
526
+ - Modify: `app/lib/ui/core/app_controller.dart` (`logout()` also calls `AuthService.signOut()`)
527
+ - Create: `app/test/profile_account_test.dart`
528
+
529
+ **Interfaces:**
530
+ - Consumes: `AuthService`, `AppController.authUser` (Task 3), backend `GET /me/quota` + `POST /auth/claim`.
531
+ - Produces: `ApiClient.quota() -> Future<QuotaStatus>` where
532
+
533
+ ```dart
534
+ class QuotaStatus {
535
+ const QuotaStatus({required this.cardsUsed, required this.cardsLimit, required this.chatUsed, required this.chatLimit});
536
+ final int cardsUsed, cardsLimit, chatUsed, chatLimit;
537
+ }
538
+ ```
539
+
540
+ and `ApiClient.claimLegacyLibrary(String name) -> Future<int>` (claimed row count; throws ApiException 409 when taken).
541
+
542
+ - [ ] **Step 1: Write the failing test**
543
+
544
+ ```dart
545
+ import 'dart:convert';
546
+
547
+ import 'package:flutter_test/flutter_test.dart';
548
+ import 'package:http/http.dart' as http;
549
+ import 'package:http/testing.dart';
550
+ import 'package:cachy/data/services/api_client.dart';
551
+
552
+ void main() {
553
+ test('quota() parses /me/quota', () async {
554
+ final mock = MockClient((req) async {
555
+ expect(req.url.path, '/me/quota');
556
+ return http.Response(jsonEncode({
557
+ 'cards': {'used': 3, 'limit': 10},
558
+ 'chat': {'used': 1, 'limit': 30},
559
+ 'resets_at': '2026-07-11T00:00:00+00:00',
560
+ }), 200);
561
+ });
562
+ final api = ApiClient(baseUrl: 'http://x', client: mock);
563
+ final q = await api.quota();
564
+ expect(q.cardsUsed, 3);
565
+ expect(q.cardsLimit, 10);
566
+ });
567
+
568
+ test('claimLegacyLibrary returns claimed count', () async {
569
+ final mock = MockClient((req) async => http.Response(jsonEncode({'claimed': 7}), 200));
570
+ final api = ApiClient(baseUrl: 'http://x', client: mock);
571
+ expect(await api.claimLegacyLibrary('Vatsal'), 7);
572
+ });
573
+ }
574
+ ```
575
+
576
+ - [ ] **Step 2: Run to verify failure** — Expected: FAIL, `quota`/`claimLegacyLibrary` undefined.
577
+
578
+ - [ ] **Step 3: Implement**
579
+
580
+ `api_client.dart` additions (using `_send` from Task 2):
581
+
582
+ ```dart
583
+ class QuotaStatus {
584
+ const QuotaStatus({required this.cardsUsed, required this.cardsLimit, required this.chatUsed, required this.chatLimit});
585
+ final int cardsUsed;
586
+ final int cardsLimit;
587
+ final int chatUsed;
588
+ final int chatLimit;
589
+ }
590
+
591
+ Future<QuotaStatus> quota() async {
592
+ final resp = await _send((h) => _client.get(_uri('/me/quota'), headers: h));
593
+ final json = _decodeMap(resp);
594
+ int pick(String kind, String field) =>
595
+ ((json[kind] as Map<String, dynamic>?)?[field] as num?)?.toInt() ?? 0;
596
+ return QuotaStatus(
597
+ cardsUsed: pick('cards', 'used'),
598
+ cardsLimit: pick('cards', 'limit'),
599
+ chatUsed: pick('chat', 'used'),
600
+ chatLimit: pick('chat', 'limit'),
601
+ );
602
+ }
603
+
604
+ Future<int> claimLegacyLibrary(String name) async {
605
+ final resp = await _send(
606
+ (h) => _client.post(_uri('/auth/claim'), headers: h, body: jsonEncode({'name': name})),
607
+ extra: const {'content-type': 'application/json'},
608
+ );
609
+ return (_decodeMap(resp)['claimed'] as num?)?.toInt() ?? 0;
610
+ }
611
+ ```
612
+
613
+ `profile_screen.dart` Account section replaces the current sign-out-only block:
614
+ - Signed-in (Google): row with photo/initial avatar, displayName, email; "Sign out" tile below (existing confirm dialog; copy updated to "Your cards stay safe in your account.").
615
+ - Anonymous: banner tile (primary-tinted container, not a snackbar): title "Your library isn't backed up", subtitle "Sign in with Google — if you uninstall or clear data, your cards are gone.", trailing FilledButton "Sign in" → `context.read<AppController>().signInWithGoogle()`; on success and when `LocalStore.userName` is set, offer the claim dialog: "Restore my old library" → `api.claimLegacyLibrary(name)`; on 409 show "That name was already claimed."
616
+ - Quota meter: under Library section, a `_Tile`-style row "AI usage today" with subtitle `"${q.cardsUsed}/${q.cardsLimit} cards · ${q.chatUsed}/${q.chatLimit} chats"`, loaded via `FutureBuilder(api.quota())`, hidden on error (quota is a nicety, never a blocker).
617
+ - `AppController.logout()`: call `await _auth.signOut()` in addition to the existing `LocalStore.clearUser()`.
618
+
619
+ - [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
620
+
621
+ - [ ] **Step 5: Manual end-to-end check (requires deployed backend + Firebase setup)**
622
+
623
+ Run `cd app && flutter run -d chrome --dart-define=CACHY_API_BASE=http://localhost:8000` with the backend running and `FIREBASE_PROJECT_ID` set. Verify: onboarding → name → login screen appears once → "Or use without login…" enters the shell → profile shows the not-backed-up banner → Google sign-in links (library unchanged) → banner replaced by account row → sign out returns to onboarding gate.
624
+
625
+ - [ ] **Step 6: Commit** — `feat: profile account section — banner, quota meter, legacy claim, real sign-out`
docs/planning/plans/2026-07-10-ui-trust-polish.md ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UI Trust & Polish Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Close the four P1 trust leaks from the 2026-07-10 critique (fake cache-clear, dead Move-to-Folder, raw errors, cold-start cliff) plus the P2 polish items (reduced motion, onboarding brand pass, bounce easing).
6
+
7
+ **Architecture:** Pure Flutter changes, independent of the auth plans. Error mapping happens once in `ApiException`; move-to-folder reuses the existing `/collections/cards/{id}/move` endpoint and `ApiClient.moveCardToCollection`; cold-start becomes a distinct library/share status driven by retry-with-backoff in the repository layer.
8
+
9
+ **Tech Stack:** Flutter, provider, flutter_animate, flutter_test.
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: "UI trust & polish fixes" section of `docs/superpowers/specs/2026-07-10-public-distribution-auth-quotas-design.md`; critique snapshot `.impeccable/critique/2026-07-10T11-11-16Z__app-lib.md`.
14
+ - Brand rules: all type through `brand.dart` tokens (no direct GoogleFonts outside it), motion 150–260ms ease-out, no bounce/overshoot, glass tokens shared.
15
+ - Never surface `e.toString()`, `ApiException(...)` bodies, or server tracebacks in UI copy.
16
+ - `cd app && flutter test && flutter analyze` after every task.
17
+ - No commits unless the user has granted it in-session; Commit steps are conditional on that.
18
+
19
+ ---
20
+
21
+ ### Task 1: Real "Clear offline cache"
22
+
23
+ **Files:**
24
+ - Modify: `app/lib/data/services/local_store.dart`
25
+ - Modify: `app/lib/ui/features/profile/views/profile_screen.dart:251-256`
26
+ - Create: `app/test/local_store_test.dart`
27
+
28
+ **Interfaces:**
29
+ - Produces: `Future<int> LocalStore.clearCardCache()` — removes every cached card + index, returns removed count.
30
+
31
+ - [ ] **Step 1: Write the failing test**
32
+
33
+ ```dart
34
+ import 'package:flutter_test/flutter_test.dart';
35
+ import 'package:shared_preferences/shared_preferences.dart';
36
+ import 'package:cachy/data/services/local_store.dart';
37
+
38
+ void main() {
39
+ TestWidgetsFlutterBinding.ensureInitialized();
40
+
41
+ test('clearCardCache removes all cached cards and the index', () async {
42
+ SharedPreferences.setMockInitialValues({});
43
+ final store = await LocalStore.open();
44
+ await store.cacheCard('c1', {'id': 'c1'});
45
+ await store.cacheCard('c2', {'id': 'c2'});
46
+ expect(store.cachedCardIds(), hasLength(2));
47
+
48
+ final removed = await store.clearCardCache();
49
+ expect(removed, 2);
50
+ expect(store.cachedCardIds(), isEmpty);
51
+ expect(store.readCard('c1'), isNull);
52
+ });
53
+ }
54
+ ```
55
+
56
+ - [ ] **Step 2: Run to verify failure** — `cd app && flutter test test/local_store_test.dart` — Expected: FAIL, `clearCardCache` undefined.
57
+
58
+ - [ ] **Step 3: Implement**
59
+
60
+ `local_store.dart`, in the Card cache section:
61
+
62
+ ```dart
63
+ /// Remove every cached card and the index. Returns how many were removed.
64
+ Future<int> clearCardCache() async {
65
+ final ids = cachedCardIds();
66
+ for (final id in ids) {
67
+ await _prefs.remove('$_cardPrefix$id');
68
+ }
69
+ await _prefs.remove(_indexKey);
70
+ return ids.length;
71
+ }
72
+ ```
73
+
74
+ `profile_screen.dart` `_confirmClear`, replace the lying success block:
75
+
76
+ ```dart
77
+ if (ok == true && mounted) {
78
+ final removed = await context.read<LocalStore>().clearCardCache();
79
+ if (mounted) {
80
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(
81
+ content: Text(removed == 0
82
+ ? 'Nothing cached yet'
83
+ : 'Cleared $removed offline ${removed == 1 ? 'card' : 'cards'}'),
84
+ ));
85
+ }
86
+ }
87
+ ```
88
+
89
+ If `LocalStore` isn't in the provider graph, expose it via `CardRepository` (e.g. `context.read<CardRepository>().store.clearCardCache()`) — check how the repository holds it and use the existing path; do not add a new provider if one route already exists.
90
+
91
+ - [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
92
+
93
+ - [ ] **Step 5: Commit** — `fix: Clear offline cache actually clears (and reports the count)`
94
+
95
+ ---
96
+
97
+ ### Task 2: Friendly error mapping at the ApiException boundary
98
+
99
+ **Files:**
100
+ - Modify: `app/lib/data/services/api_client.dart` (ApiException gains `friendlyMessage`)
101
+ - Modify: `app/lib/ui/features/actions/view_models/actions_view_model.dart:73` and every VM assigning `e.toString()` to a user-visible error field (grep `toString()` under `app/lib/ui/**/view_models/` and `app/lib/ui/**/views/` for snackbar/error uses)
102
+ - Create: `app/test/api_exception_test.dart`
103
+
104
+ **Interfaces:**
105
+ - Produces: `String ApiException.friendlyMessage` and `String friendlyError(Object e)` helper (top-level in api_client.dart) — the ONLY strings VMs may store in user-visible error fields.
106
+
107
+ - [ ] **Step 1: Write the failing test**
108
+
109
+ ```dart
110
+ import 'package:flutter_test/flutter_test.dart';
111
+ import 'package:cachy/data/services/api_client.dart';
112
+
113
+ void main() {
114
+ test('friendly messages never leak bodies or tracebacks', () {
115
+ expect(ApiException(500, '{"detail":"X","traceback":"Trace..."}').friendlyMessage,
116
+ 'Something went wrong on our side. Try again in a moment.');
117
+ expect(ApiException(429, '{"error":"quota"}').friendlyMessage,
118
+ "You've hit today's limit. It resets at midnight UTC.");
119
+ expect(ApiException(401, 'x').friendlyMessage,
120
+ 'Session expired — please sign in again.');
121
+ expect(ApiException(404, 'x').friendlyMessage,
122
+ "That card isn't there anymore.");
123
+ for (final code in [400, 401, 404, 429, 500, 503]) {
124
+ final msg = ApiException(code, 'traceback secret').friendlyMessage;
125
+ expect(msg.contains('traceback'), isFalse);
126
+ expect(msg.contains('secret'), isFalse);
127
+ }
128
+ });
129
+
130
+ test('friendlyError handles non-Api exceptions', () {
131
+ expect(friendlyError(Exception('SocketException: conn refused')),
132
+ "Can't reach Cachy. Check your connection.");
133
+ });
134
+ }
135
+ ```
136
+
137
+ - [ ] **Step 2: Run to verify failure** — Expected: FAIL, `friendlyMessage` undefined.
138
+
139
+ - [ ] **Step 3: Implement**
140
+
141
+ In `api_client.dart`:
142
+
143
+ ```dart
144
+ class ApiException implements Exception {
145
+ ApiException(this.statusCode, this.message);
146
+ final int statusCode;
147
+ final String message; // raw body — for logs only, never for UI
148
+
149
+ /// What users see. Raw bodies (which may include server details) never
150
+ /// leave the data layer.
151
+ String get friendlyMessage => switch (statusCode) {
152
+ 401 || 403 => 'Session expired — please sign in again.',
153
+ 404 => "That card isn't there anymore.",
154
+ 429 => "You've hit today's limit. It resets at midnight UTC.",
155
+ >= 500 => 'Something went wrong on our side. Try again in a moment.',
156
+ _ => "That didn't work. Try again.",
157
+ };
158
+
159
+ @override
160
+ String toString() => 'ApiException($statusCode): $message';
161
+ }
162
+
163
+ /// UI-safe message for any thrown object.
164
+ String friendlyError(Object e) => switch (e) {
165
+ ApiException api => api.friendlyMessage,
166
+ _ => "Can't reach Cachy. Check your connection.",
167
+ };
168
+ ```
169
+
170
+ Swap every user-visible assignment, e.g. `actions_view_model.dart:73`: `_error = e.toString();` → `_error = friendlyError(e);` (keep a `debugPrint('$e')` or logger call if the original context logged). Do the same wherever `SnackBar(content: Text('...$error'))` shows a VM error built from `toString()` (library bulk delete already shows `vm.error` — that's now friendly automatically).
171
+
172
+ - [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze`; then `grep -rn "toString()" app/lib/ui | grep -i "error\|snack"` — Expected: tests PASS; grep only shows non-UI/logging uses.
173
+
174
+ - [ ] **Step 5: Commit** — `fix: friendly error copy everywhere — raw exceptions never reach UI`
175
+
176
+ ---
177
+
178
+ ### Task 3: Cold-start "Waking Cachy up" state
179
+
180
+ **Files:**
181
+ - Modify: `app/lib/ui/features/library/view_models/library_view_model.dart` (new status + retry loop)
182
+ - Modify: `app/lib/ui/features/library/views/library_screen.dart` (`_body` renders the waking state)
183
+ - Create: `app/test/library_wakeup_test.dart`
184
+
185
+ **Interfaces:**
186
+ - Consumes: `friendlyError` (Task 2).
187
+ - Produces: `LibraryStatus.waking` enum value; `LibraryViewModel.load()` retries up to 6 times / 10s apart on connection-shaped failures before settling on `error`; `int get wakeAttempt`.
188
+
189
+ - [ ] **Step 1: Write the failing test**
190
+
191
+ Read `library_view_model.dart` first for its repository interface, then adapt this shape (fake repo that fails N times then succeeds):
192
+
193
+ ```dart
194
+ import 'package:flutter_test/flutter_test.dart';
195
+ import 'package:cachy/ui/features/library/view_models/library_view_model.dart';
196
+
197
+ import 'fakes.dart'; // FakeCardRepository: fails with SocketException-ish twice, then returns []
198
+
199
+ void main() {
200
+ test('connection failure enters waking state, then recovers', () async {
201
+ final repo = FakeCardRepository(failuresBeforeSuccess: 2);
202
+ final vm = LibraryViewModel(repository: repo, wakeRetryDelay: Duration.zero);
203
+ final seen = <LibraryStatus>[];
204
+ vm.addListener(() => seen.add(vm.status));
205
+ await vm.load();
206
+ expect(seen, contains(LibraryStatus.waking));
207
+ expect(vm.status, anyOf(LibraryStatus.empty, LibraryStatus.ready));
208
+ });
209
+
210
+ test('persistent failure lands on error after max attempts', () async {
211
+ final repo = FakeCardRepository(failuresBeforeSuccess: 99);
212
+ final vm = LibraryViewModel(repository: repo, wakeRetryDelay: Duration.zero);
213
+ await vm.load();
214
+ expect(vm.status, LibraryStatus.error);
215
+ });
216
+ }
217
+ ```
218
+
219
+ Write `FakeCardRepository` in `app/test/fakes.dart` implementing only the members `LibraryViewModel` actually calls (check the VM; typically `list()` and the offline flag).
220
+
221
+ - [ ] **Step 2: Run to verify failure** — Expected: FAIL, `LibraryStatus.waking` undefined.
222
+
223
+ - [ ] **Step 3: Implement**
224
+
225
+ In `library_view_model.dart`: add `waking` to `LibraryStatus`; constructor param `this.wakeRetryDelay = const Duration(seconds: 10)`; wrap the load's fetch:
226
+
227
+ ```dart
228
+ static const _maxWakeAttempts = 6;
229
+ int _wakeAttempt = 0;
230
+ int get wakeAttempt => _wakeAttempt;
231
+
232
+ Future<List<model.Card>> _fetchWithWake() async {
233
+ for (_wakeAttempt = 0; ; _wakeAttempt++) {
234
+ try {
235
+ return await repository.list();
236
+ } catch (e) {
237
+ final connectionShaped = e is! ApiException; // socket/timeouts, not HTTP
238
+ if (!connectionShaped || _wakeAttempt >= _maxWakeAttempts) rethrow;
239
+ _status = LibraryStatus.waking;
240
+ notifyListeners();
241
+ await Future<void>.delayed(wakeRetryDelay);
242
+ }
243
+ }
244
+ }
245
+ ```
246
+
247
+ and call `_fetchWithWake()` where `load()` currently calls the repository (keep the existing offline-cache fallback: if the cache has cards while waking fails, show cached cards with the offline chip instead — reuse the current offline path).
248
+
249
+ `library_screen.dart` `_body`, add before the error case:
250
+
251
+ ```dart
252
+ case LibraryStatus.waking:
253
+ return _scrollable(
254
+ EmptyState(
255
+ showGlyph: true,
256
+ title: 'Waking Cachy up…',
257
+ message: 'The free server naps when idle. First load takes about '
258
+ '30 seconds — your library is on its way.',
259
+ ),
260
+ );
261
+ ```
262
+
263
+ - [ ] **Step 4: Share flow cold-start copy**
264
+
265
+ The share pipeline already degrades to `ShareStatus.queuedOffline` on connection failure (`share_screen.dart:129-149`) — that state doubles as the cold-start path for captures. Update its copy so a sleeping server doesn't read as "you're offline":
266
+
267
+ - Title: `'Saved offline'` → `'Saved — will process shortly'`
268
+ - Body: `"We'll process this reel as soon as you're back online."` → `"We'll process this as soon as Cachy is reachable — the free server can take ~30s to wake up."`
269
+
270
+ - [ ] **Step 5: Run** — `cd app && flutter test && flutter analyze` — Expected: PASS.
271
+
272
+ - [ ] **Step 6: Commit** — `feat: cold-start waking state with auto-retry instead of instant failure`
273
+
274
+ ---
275
+
276
+ ### Task 4: Move to Folder — replace both stubs
277
+
278
+ **Files:**
279
+ - Modify: `app/lib/ui/features/library/view_models/library_view_model.dart` (`bulkMove`)
280
+ - Create: `app/lib/ui/features/collections/views/folder_picker_sheet.dart`
281
+ - Modify: `app/lib/ui/core/home_shell.dart:483-485` and `app/lib/ui/features/library/views/library_screen.dart:315-319` (call the picker; also dedupe `_confirmBulkDelete` into one shared helper `confirmBulkDelete(BuildContext, LibraryViewModel)` living in `library_view_model.dart`'s file or a small `library_dialogs.dart`)
282
+ - Create: `app/test/bulk_move_test.dart`
283
+
284
+ **Interfaces:**
285
+ - Consumes: `ApiClient.moveCardToCollection` + `listCollections`/`createCollection` (already exist).
286
+ - Produces: `Future<void> LibraryViewModel.bulkMove(String? collectionId)` — moves all selected cards, clears selection, refreshes; `Future<void> showFolderPicker(BuildContext context, LibraryViewModel vm)` — adaptive sheet listing folders + "New folder…" row.
287
+
288
+ - [ ] **Step 1: Write the failing test**
289
+
290
+ ```dart
291
+ import 'package:flutter_test/flutter_test.dart';
292
+ import 'package:cachy/ui/features/library/view_models/library_view_model.dart';
293
+
294
+ import 'fakes.dart'; // FakeCardRepository records moveCardToCollection calls
295
+
296
+ void main() {
297
+ test('bulkMove moves every selected card and clears selection', () async {
298
+ final repo = FakeCardRepository(cards: ['a', 'b', 'c']);
299
+ final vm = LibraryViewModel(repository: repo);
300
+ await vm.load();
301
+ vm.toggleSelected('a');
302
+ vm.toggleSelected('c');
303
+
304
+ await vm.bulkMove('folder-1');
305
+
306
+ expect(repo.moves, {'a': 'folder-1', 'c': 'folder-1'});
307
+ expect(vm.selectionActive, isFalse);
308
+ });
309
+ }
310
+ ```
311
+
312
+ Match the VM's real selection API names (`toggleSelected`/`selectionActive` — read the VM first and mirror; extend `FakeCardRepository` with a `moves` map).
313
+
314
+ - [ ] **Step 2: Run to verify failure** — Expected: FAIL, `bulkMove` undefined.
315
+
316
+ - [ ] **Step 3: Implement**
317
+
318
+ `library_view_model.dart`:
319
+
320
+ ```dart
321
+ /// Move every selected card into [collectionId] (null = remove from folders).
322
+ Future<void> bulkMove(String? collectionId) async {
323
+ final ids = List<String>.from(selectedIds);
324
+ try {
325
+ for (final id in ids) {
326
+ await repository.moveCardToCollection(id, collectionId);
327
+ }
328
+ clearSelection();
329
+ await refresh();
330
+ } catch (e) {
331
+ _error = friendlyError(e);
332
+ notifyListeners();
333
+ }
334
+ }
335
+ ```
336
+
337
+ (If the repository lacks `moveCardToCollection`, add a one-line passthrough to `api.moveCardToCollection`.)
338
+
339
+ `folder_picker_sheet.dart` — adaptive modal (reuse `showAdaptiveModal`) listing collections from `ApiClient.listCollections()`, one `ListTile` per folder (folder icon, name), a divider, then "New folder…" which prompts a name via `AlertDialog` + `createCollection`, then moves. Selecting any row: `await vm.bulkMove(entry.id); Navigator.pop(ctx);` with a confirmation snackbar `Moved N cards to "<name>"`.
340
+
341
+ Replace both stubs: `onMoveToFolder: () => showFolderPicker(context, vm)`. Delete the duplicated `_confirmBulkDelete` from `home_shell.dart` and `library_screen.dart`; both call the new shared `confirmBulkDelete`.
342
+
343
+ - [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze`; then `grep -rn "coming soon" app/lib` — Expected: tests PASS, grep empty.
344
+
345
+ - [ ] **Step 5: Commit** — `feat: bulk Move to Folder (replaces coming-soon stubs); dedupe bulk-delete dialog`
346
+
347
+ ---
348
+
349
+ ### Task 5: Reduced motion honored everywhere
350
+
351
+ **Files:**
352
+ - Modify: `app/lib/ui/core/theme.dart` (motion gate helper)
353
+ - Modify: all 16 `.animate()` call sites + `AnimatedScale` durations (grep `\.animate(` and `Motion.fast`/`Motion.spring` under `app/lib`)
354
+ - Create: `app/test/reduced_motion_test.dart`
355
+
356
+ **Interfaces:**
357
+ - Produces: `extension MotionGate on BuildContext { bool get motionEnabled; Duration gated(Duration d); }` in theme.dart.
358
+
359
+ - [ ] **Step 1: Write the failing test**
360
+
361
+ ```dart
362
+ import 'package:flutter/material.dart';
363
+ import 'package:flutter_test/flutter_test.dart';
364
+ import 'package:cachy/ui/core/theme.dart';
365
+
366
+ void main() {
367
+ testWidgets('motionEnabled follows MediaQuery.disableAnimations', (tester) async {
368
+ late bool enabled;
369
+ late Duration gated;
370
+ await tester.pumpWidget(MediaQuery(
371
+ data: const MediaQueryData(disableAnimations: true),
372
+ child: Builder(builder: (context) {
373
+ enabled = context.motionEnabled;
374
+ gated = context.gated(Motion.medium);
375
+ return const SizedBox();
376
+ }),
377
+ ));
378
+ expect(enabled, isFalse);
379
+ expect(gated, Duration.zero);
380
+ });
381
+ }
382
+ ```
383
+
384
+ - [ ] **Step 2: Run to verify failure** — Expected: FAIL, extension undefined.
385
+
386
+ - [ ] **Step 3: Implement**
387
+
388
+ `theme.dart`:
389
+
390
+ ```dart
391
+ /// Reduced-motion gate: animations collapse to zero duration when the OS asks.
392
+ extension MotionGate on BuildContext {
393
+ bool get motionEnabled => !MediaQuery.of(this).disableAnimations;
394
+ Duration gated(Duration d) => motionEnabled ? d : Duration.zero;
395
+ }
396
+ ```
397
+
398
+ Then sweep call sites:
399
+ - flutter_animate chains: `.animate()` → wrap the whole chain: `motionEnabled ? child.animate()...fadeIn(...) : child` OR simpler global switch in `main.dart` root build: `Animate.restartOnHotReload = true;` is unrelated — instead set `child.animate(autoPlay: context.motionEnabled)` where supported, else the conditional wrap. Use the conditional wrap; it's explicit and testable.
400
+ - `AnimatedScale`/`AnimatedContainer` durations: `duration: Motion.fast` → `duration: context.gated(Motion.fast)`.
401
+ - Also change `Motion.spring = Curves.easeOutBack` → `Curves.easeOutCubic` (bounce ban) and in `app/web/index.html` replace `cubic-bezier(0.34, 1.56, 0.64, 1)` with `cubic-bezier(0.22, 1, 0.36, 1)`.
402
+
403
+ - [ ] **Step 4: Run** — `cd app && flutter test && flutter analyze`; then `grep -rn "easeOutBack" app/` — Expected: PASS, grep empty.
404
+
405
+ - [ ] **Step 5: Commit** — `fix: honor reduced-motion everywhere; retire bounce easing`
406
+
407
+ ---
408
+
409
+ ### Task 6: Onboarding brand pass + stale comments
410
+
411
+ **Files:**
412
+ - Modify: `app/lib/ui/features/onboarding/views/onboarding_screen.dart`
413
+ - Modify: `app/lib/ui/features/onboarding/views/name_screen.dart`
414
+ - Modify: `app/lib/ui/features/library/views/library_screen.dart:1-7` (doc comment)
415
+
416
+ **Interfaces:** none new — visual conformance only.
417
+
418
+ - [ ] **Step 1: Apply the brand pass**
419
+
420
+ `onboarding_screen.dart`:
421
+ - `_LogoBadge`: replace the lightning-in-a-box with the real mark: `Row(children: [const CachyGlyph(size: 30), const SizedBox(width: 10), Text('cachy', style: Brand.wordmarkStyle(20, color: scheme.onSurface))])` — delete the Container/lightning entirely.
422
+ - Every `GoogleFonts.fraunces(... fontWeight: FontWeight.w800 ...)` headline → `theme.textTheme.displaySmall` / `displayMedium` (Brand's w600 serif), keeping the accent-colored `TextSpan`s; drop the glow `Shadow` on "Captured." (calm over clever). Remove the `google_fonts` import.
423
+ - File doc comment: delete "adapted from Insightr (demo)" wording — describe what it is now.
424
+
425
+ `name_screen.dart`: headline `GoogleFonts.fraunces(...)` → `theme.textTheme.displaySmall`; remove the glow shadow and the `google_fonts` import.
426
+
427
+ `library_screen.dart` header comment: "Two segments — Cards and To-do" → "Three segments — Cards, Concepts, Catalog".
428
+
429
+ - [ ] **Step 2: Verify**
430
+
431
+ Run: `cd app && flutter analyze && flutter test` — Expected: clean, all PASS. Then `grep -rn "GoogleFonts" app/lib | grep -v core/brand.dart` — Expected: empty.
432
+
433
+ - [ ] **Step 3: Visual check** — `cd app && flutter run -d chrome` , clear browser storage to re-trigger onboarding; confirm the glyph mark, w600 headlines, no glow.
434
+
435
+ - [ ] **Step 4: Commit** — `polish: onboarding on-brand (glyph mark, w600 serif, no glow); fix stale comments`
docs/planning/plans/2026-07-10-v2-on-device-ai.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # V2 On-Device AI + Distribution Implementation Plan (Roadmap)
2
+
3
+ > **For agentic workers:** This is a V2 roadmap plan. Prerequisites: V1 plans (`2026-07-10-backend-auth-quotas.md`, `2026-07-10-flutter-auth.md`) executed and deployed. Before executing any task here, RE-VALIDATE the runtime choice (MediaPipe LLM Inference / `flutter_gemma` / model availability) — the on-device LLM ecosystem changes monthly. Then expand each task into full TDD steps via superpowers:writing-plans.
4
+
5
+ **Goal:** Quota-exhausted users with an installed local model get structured cards generated on their own phone — zero server AI spend. Plus Play Store readiness and sustainability hooks.
6
+
7
+ **Spec:** `docs/superpowers/specs/2026-07-10-v2-on-device-ai-design.md`
8
+
9
+ ## Phase A — Backend bundle round-trip (independent, testable now-ish)
10
+
11
+ ### Task A1: Persist raw bundle on degraded jobs
12
+ - `cards.raw_bundle: TEXT NULL` column + additive migration entry.
13
+ - Worker: when `job.degraded`, store the extraction bundle text on the card before paragraph fallback.
14
+ - Test: degraded job → `raw_bundle` set; normal job → NULL.
15
+
16
+ ### Task A2: `GET /cards/{id}/bundle`
17
+ - Owner-scoped (OwnerDep). 200 `{bundle, transcript, caption}` when stored; 404 otherwise.
18
+ - Test: owner gets bundle; other uid 404; non-degraded card 404.
19
+
20
+ ### Task A3: `POST /cards/{id}/structure`
21
+ - Owner-scoped. Body = client-generated structured card JSON.
22
+ - Validate with the SAME Pydantic models the LLM path uses (`structuring` validation) — reject invalid with 422.
23
+ - On accept: replace paragraph content, clear `raw_bundle`, re-run `_embed_card`, bump card state.
24
+ - Test: valid payload upgrades card + clears bundle; schema-garbage 422; foreign owner 404.
25
+
26
+ ## Phase B — Flutter local AI
27
+
28
+ ### Task B1: `LocalAiService` interface + fake
29
+ - Mirror the `AuthService` pattern: abstract interface, `FakeLocalAiService` for tests.
30
+ - API: `status` (notInstalled|downloading(progress)|ready|error), `download()`, `delete()`, `Future<Map<String,dynamic>?> structureBundle(String bundle, {String transcript, String caption})` (null = generation failed/invalid JSON).
31
+
32
+ ### Task B2: Model download manager
33
+ - Resumable download of Gemma 3 1B int4 (~550 MB) to app storage, sha256 check, Wi-Fi-recommended warning dialog.
34
+ - State machine unit-tested against the fake HTTP layer.
35
+
36
+ ### Task B3: Inference integration
37
+ - MediaPipe LLM Inference via platform channel (or `flutter_gemma` if healthy at build time). Android only; feature-gate everywhere else.
38
+ - Prompt: short instruction + one few-shot example + strict "JSON only" suffix; client-side JSON parse + block-schema validation; invalid → return null.
39
+
40
+ ### Task B4: Wire the degrade path
41
+ - Card returns `quota_degraded` → if `LocalAiService.status == ready`: fetch `/bundle`, run `structureBundle`, POST `/structure`, refresh card. Progress UI in reader ("Generating on your phone…"), cancellable.
42
+ - Any failure → paragraph card remains, no error dialog (silent grace, log only).
43
+ - Widget test with fakes: degraded card + ready model → upgraded card; model returns null → paragraph kept.
44
+
45
+ ### Task B5: Profile "Offline AI" section
46
+ - Download/enable/disable/delete UI, size on disk, honest copy ("~550 MB, runs on your phone, slower than cloud").
47
+ - Quota chip second state: "Generating on your phone".
48
+
49
+ ## Phase C — Distribution & sustainability
50
+
51
+ ### Task C1: Privacy policy page at `/privacy` on the Space; link from Profile → About.
52
+ ### Task C2: Play Store submission — Data Safety form, target API check, store listing (screenshots from web build), $25 account.
53
+ ### Task C3: Sponsors/Ko-fi link in Profile → About ("Keep Cachy free"); apply GitHub Student Pack + Cerebras/Groq edu credits.
54
+ ### Task C4 (calendar ~1 month post-V1): delete `/auth/claim` + `claims` table + client claim flow.
55
+
56
+ ## Phase D — Private media (thumbnails/keyframes off the public HF dataset)
57
+
58
+ Closes a real privacy gap: onboarding promises "Only you see your cards" but thumbnails currently sit in a **public** HF dataset repo. Spec: `docs/superpowers/specs/2026-07-10-v2-on-device-ai-design.md` Part 3.
59
+
60
+ ### Task D1: Flip the HF dataset repo to private
61
+ - Manual, one-time: HF dataset settings → private. No code change. Verify with an unauthenticated raw-URL fetch attempt (must fail after the flip).
62
+
63
+ ### Task D2: Owner-checked media proxy endpoint
64
+ - `GET /media/{card_id}/{filename}` — `OwnerDep`-gated (card's `owner_id` must match caller), streams bytes from the private HF dataset via `hf_api_key` (`hf_hub_download` or HF HTTP API), correct `content-type` by extension, `Cache-Control: private, max-age=3600`.
65
+ - Test: 404 for non-owned cards, 200 + correct bytes for owned cards, 401 unauthenticated.
66
+
67
+ ### Task D3: Emit proxy paths instead of raw HF URLs
68
+ - Update wherever media refs are written into card JSON (`store/media.py` / `worker.py`) to emit `/media/{card_id}/{filename}` instead of the absolute HF dataset URL.
69
+ - Confirm `ApiClient.resolveMedia` needs no change (it already joins bare paths onto `baseUrl`; only absolute-URL passthrough becomes dead code for new cards).
70
+ - Regression: existing cards written before this change carry old absolute HF URLs — keep a fallback branch (client or backend) so pre-migration cards still resolve, or run a one-time backfill rewriting stored refs to the new scheme.
71
+
72
+ ## Order & gates
73
+
74
+ - A before B (B needs the endpoints). C anytime after V1. D anytime after V1 (independent of A/B/C) — recommended right after V2's on-device work, per user sequencing preference; could move earlier since it's a live privacy gap, revisit if that becomes urgent.
75
+ - Gate before B3: benchmark chosen model on one real mid-range Android phone — if structuring takes >2 min or JSON validity <~70%, drop to paragraph-only and re-evaluate model choice.
76
+ - Gate before D1: confirm no other consumer (embed codes shared externally, cached CDN links, etc.) depends on the media repo being public before flipping it private.
docs/planning/specs/2026-07-10-public-distribution-auth-quotas-design.md ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cachy Public Distribution: Auth, Quotas, Backend Hardening
2
+
3
+ **Date:** 2026-07-10
4
+ **Status:** Approved
5
+ **Scope:** V1 for distributing Cachy to non-technical users (GitHub APK + hosted web) while shared free-tier AI keys survive. On-device model, Play Store, payments, email auth are explicitly out of scope (V2+).
6
+
7
+ ## Problem
8
+
9
+ Cachy today identifies users by a self-declared name sent as the `x-owner-id` header. The backend trusts it blindly:
10
+
11
+ - Anyone can read any user's library by guessing a name (`curl -H "x-owner-id: Vatsal"`).
12
+ - Name collisions silently merge libraries.
13
+ - Per-user quotas are unenforceable (attacker rotates header values), so shared free-tier API keys (Cerebras, Groq, Gemini pool) cannot be protected.
14
+ - `/admin/stats`, `/debug/jobs`, `/debug/kill_stuck` are unauthenticated; 500 responses leak tracebacks; CORS is `*`.
15
+
16
+ ## Decision Summary
17
+
18
+ | Decision | Choice |
19
+ |---|---|
20
+ | Identity | Firebase Auth: anonymous sign-in on first launch, optional upgrade to Google Sign-In via `linkWithCredential` (same uid, data preserved) |
21
+ | Token transport | `Authorization: Bearer <Firebase ID token>` on every API request |
22
+ | Backend verification | `firebase-admin` `verify_id_token`; `owner_id` = verified `uid` |
23
+ | Quota policy | Per-user daily quotas; past card quota → degrade to existing paragraph-fallback path (card still saves), never hard-fail |
24
+ | Quota storage | `usage(owner_id, day, kind, count)` table in existing DB (SQLite / Neon) |
25
+ | Abuse defense | Per-IP daily cap on card creation (same usage table, keyed by IP) |
26
+ | Admin/debug routes | Gated by `X-Admin-Token` matching env secret |
27
+ | Migration | Temporary `/auth/claim` endpoint: authed user claims rows whose `owner_id` equals their old display name, first-claim-wins; removed after ~1 month |
28
+
29
+ ### Why Firebase Auth (alternatives considered)
30
+
31
+ - **Firebase Auth (chosen):** anonymous→Google linking is a native feature; free unlimited for anonymous + Google providers; backend verification is one call. Google lock-in acceptable — stack is already Google-centric (Gemini keys, Android target).
32
+ - **Supabase Auth:** comparable free tier but anonymous→permanent linking is manual, and it adds a new vendor.
33
+ - **Hand-rolled JWT (Google Identity Services direct):** no new dependency but we own token issuance, refresh, revocation — auth bugs become breaches. Rejected.
34
+
35
+ ## Architecture
36
+
37
+ ### 1. Client auth flow (Flutter)
38
+
39
+ - Packages: `firebase_auth`, `google_sign_in` (+ `firebase_core`).
40
+ - **First launch:** onboarding (including the existing name screen) runs first; the login screen comes after it:
41
+ - Primary button: "Continue with Google" (`signInWithCredential`).
42
+ - Below, small quiet text link: "Or use without login…" → `signInAnonymously()`.
43
+ - Either path yields a stable Firebase `uid`.
44
+ - The name entered during onboarding is kept for both paths — it stays the display/greeting name (local + stored server-side as profile field), but is no longer the identity; `uid` is.
45
+ - **Anonymous upgrade:** Profile screen shows a persistent banner while anonymous: "Your library isn't backed up — sign in with Google." Tapping runs `linkWithCredential(GoogleAuthProvider credential)` — uid unchanged, no data migration.
46
+ - **Warning copy:** anonymous users are told data may be lost if the app is uninstalled / data cleared.
47
+ - Display name: everyone types a name during onboarding (kept as greeting/profile name); Google linking may additionally surface the Google profile name/avatar. Name is never identity.
48
+
49
+ ### 2. Token transport (ApiClient)
50
+
51
+ - `_ownerHeader` in `app/lib/data/services/api_client.dart` is replaced by an auth header provider: every request attaches `Authorization: Bearer <ID token>` from `FirebaseAuth.instance.currentUser.getIdToken()` (SDK caches + auto-refreshes; cheap to call per request).
52
+ - SSE stream request (`streamCard`) attaches the same header.
53
+
54
+ ### 3. Backend verification (FastAPI)
55
+
56
+ - New dependency `get_owner(request) -> str`:
57
+ - Extracts bearer token, verifies via `firebase_admin.auth.verify_id_token` (public JWKS; needs only the Firebase project ID configured via env).
58
+ - Returns `uid`; raises 401 on missing/invalid/expired token.
59
+ - Every route currently reading `x-owner-id` swaps to `Depends(get_owner)`. The `owner_id` column semantics are unchanged — only its source becomes trustworthy.
60
+ - `x-owner-id` header support is removed entirely (no fallback); the legacy name only survives as the body parameter of the temporary `/auth/claim` endpoint below.
61
+
62
+ ### 4. Migration of existing name-based data
63
+
64
+ - `POST /auth/claim {name: str}` (authenticated): re-points all rows with `owner_id == name` to the caller's `uid`, if that name has not already been claimed. First-claim-wins; a `claims` table records name→uid to prevent double claims.
65
+ - Client: after first sign-in, if a legacy local `userName` exists, offer "Restore my old library" which calls this endpoint.
66
+ - Endpoint is temporary; delete after ~1 month.
67
+
68
+ ### 5. Quotas
69
+
70
+ - Table: `usage(owner_id TEXT, day TEXT, kind TEXT, count INT, PRIMARY KEY(owner_id, day, kind))`. Day = UTC date string.
71
+ - FastAPI dependency factory `spend_quota(kind, daily_limit)` applied to expensive routes:
72
+ - `POST /cards` (AI card creation): **10/day**
73
+ - Card chat + library chat + rabbithole: **30/day** combined
74
+ - `GET /connections?refresh=true`: **3/day**
75
+ - Limits are env-configurable (`QUOTA_CARDS_PER_DAY`, etc.).
76
+ - **Degrade, don't fail (cards):** past quota, `POST /cards` still succeeds but the job row is flagged `degraded=1`; the worker skips LLM structuring and uses the existing paragraph-fallback path. This flag is also the V2 hook — a device with an installed local model can fetch the raw bundle and structure it client-side.
77
+ - **Chat past quota:** 429 with structured body `{error: "quota", kind, used, limit, resets_at}`; UI renders "recharges tomorrow" state.
78
+ - Quota status: expensive-route responses include `quota: {used, limit}`; plus `GET /me/quota` for the profile meter.
79
+ - **Anon-farming defense:** card creation also increments a per-IP counter (same table, `owner_id = "ip:<addr>"`), capped at e.g. 30/day/IP (env-configurable). Prevents scripted fresh anonymous uids from draining keys.
80
+
81
+ ### 6. Backend hardening
82
+
83
+ - `/admin/stats`, `/debug/jobs`, `/debug/kill_stuck`: require `X-Admin-Token` header equal to `ADMIN_TOKEN` env secret (404/401 otherwise).
84
+ - Global 500 handler: full traceback to server logs only; client gets `{"detail": "internal error"}`.
85
+ - CORS: `allow_origins` restricted to the hosted Space origin + `http://localhost:*` dev origins (native APK traffic is unaffected by CORS).
86
+
87
+ ### 7. UI changes (Flutter)
88
+
89
+ - **Login screen** (new, shown after onboarding/name screen): Google button + small "Or use without login…" link; brand styling consistent with `brand.dart` tokens.
90
+ - **Profile screen:** account section — avatar/name/email when linked; "Sign in with Google" banner when anonymous; quota meter ("7/10 AI cards today"); sign out.
91
+ - **Share/pipeline UI:** quota chip; degraded card shows "AI recharges tomorrow".
92
+
93
+ ### 8. Error handling
94
+
95
+ - 401 from API → client forces token refresh once, retries; still 401 → return to login screen.
96
+ - Firebase unreachable at launch → app opens read-only from repository cache with "reconnecting" banner.
97
+ - Quota 429 → friendly states everywhere; raw errors never shown.
98
+ - `verify_id_token` failures log the reason server-side (expired vs malformed vs wrong project).
99
+
100
+ ### 9. Testing
101
+
102
+ - `backend/tests/` is deleted on the current branch — restore the pytest harness first.
103
+ - Backend: `get_owner` (valid/expired/forged/missing token, mocked verifier); quota spend/rollover/degrade flag; per-IP cap; admin-token gate; `/auth/claim` first-claim-wins.
104
+ - Flutter: auth controller transitions (anonymous → linked); quota meter widget; 401-retry logic in ApiClient.
105
+
106
+ ## UI trust & polish fixes (from 2026-07-10 whole-app critique, score 26/40)
107
+
108
+ Ship with this release; full report in `.impeccable/critique/2026-07-10T11-11-16Z__app-lib.md`.
109
+
110
+ **P1 — trust (all four required):**
111
+ 1. **Real cache clear**: `_confirmClear` (profile_screen.dart) currently shows "Offline cache cleared" without clearing anything. Wire to the local store's actual clear, or remove the tile.
112
+ 2. **Move to Folder**: implement the bulk-selection "Move to Folder" action (collections + move endpoint already exist); replace both "coming soon" stubs (home_shell.dart, library_screen.dart). Dedupe the duplicated bulk-delete dialog while there.
113
+ 3. **Friendly errors**: map `ApiException` → human strings at the repository boundary; never surface `e.toString()` in snackbars/error states (pairs with server-side traceback removal above).
114
+ 4. **Cold-start state**: HF Space sleeps; first connect must show "Waking Cachy up — first load takes ~30s" with auto-retry (library + share view models) instead of "Can't reach Cachy".
115
+
116
+ **P2 — polish:**
117
+ 5. **Reduced motion**: honor `MediaQuery.disableAnimations` via one shared gate covering all flutter_animate/AnimatedScale usages.
118
+ 6. **Onboarding brand pass**: replace lightning `_LogoBadge` with CachyGlyph, Fraunces w800 → w600, route type through `Brand` instead of direct GoogleFonts (onboarding_screen.dart, name_screen.dart). This screen precedes the new login screen, so it's touched anyway.
119
+ 7. **Motion curve**: `Motion.spring` easeOutBack → easeOutCubic (and the matching bounce cubic-bezier in app/web/index.html splash).
120
+
121
+ **Minors (fold in where files are already open):** stale doc comments (library_screen.dart header), audit stray hex literals outside tokens, dev password constant gets a `ponytail:` note or moves behind the admin token.
122
+
123
+ ## One-time owner setup (~30 min)
124
+
125
+ 1. Create Firebase project; enable Anonymous + Google sign-in providers.
126
+ 2. Android: add `google-services.json`, register release-keystore SHA-1.
127
+ 3. Web: Firebase JS config in Flutter web init.
128
+ 4. HF Space env: `FIREBASE_PROJECT_ID` (token verification needs no service-account secret for `verify_id_token` with JWKS), `ADMIN_TOKEN`, quota env vars.
129
+
130
+ ## Out of scope (V2+)
131
+
132
+ - On-device quantized model (opt-in "offline AI" past quota) — the `degraded` job flag + raw-bundle fetch is the designed extension point.
133
+ - Play Store / App Store distribution, payments, email/password auth, multi-device conflict resolution beyond what Firebase uid sync gives for free.
docs/planning/specs/2026-07-10-v2-on-device-ai-design.md ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cachy V2: On-Device AI + Distribution Growth
2
+
3
+ **Date:** 2026-07-10
4
+ **Status:** Draft (V2 — build after the V1 auth/quota release ships and stabilizes)
5
+ **Depends on:** `2026-07-10-public-distribution-auth-quotas-design.md` (V1) — specifically the `degraded` job flag, quota system, and Firebase identity.
6
+
7
+ ## Problem
8
+
9
+ V1 caps each user at N AI cards/day so shared free-tier keys survive. Past quota, cards degrade to paragraph fallback. Power users want more without the developer paying for it. V1's answer was "wait until tomorrow"; V2's answer is: **bring your own compute** — an optional quantized model running on the user's phone.
10
+
11
+ Also collected here: the rest of the deferred V2 items (store distribution, sustainability, claim-endpoint removal) so V2 has one source of truth.
12
+
13
+ ## Part 1 — On-device AI (headline)
14
+
15
+ ### Decision summary
16
+
17
+ | Decision | Choice | Why |
18
+ |---|---|---|
19
+ | Split point | Server extracts (download, whisper, OCR → text bundle); device structures (bundle → card JSON) | Extraction needs yt-dlp/ffmpeg — impossible on phone. Structuring is pure text→JSON — exactly what a small LLM can do |
20
+ | Trigger | Quota-degraded cards, when local model installed & enabled | Reuses V1's `degraded` flag; zero new quota logic |
21
+ | Runtime | MediaPipe LLM Inference API (Android), via `flutter_gemma` or direct platform channel | Google-maintained, handles quantized Gemma models on-device, no NDK build of llama.cpp needed. Re-validate at build time — this space moves fast |
22
+ | Model | Gemma 3 1B, int4 quantized (~550 MB) | Best small-model JSON-following per size; MediaPipe-native. Fallback candidate: Qwen2.5-1.5B-Instruct q4 |
23
+ | Platforms | Android APK only | Web can't (practically); iOS not distributed yet |
24
+ | Opt-in UX | Profile → "Offline AI" section: explicit download (~550 MB warning, Wi-Fi recommended), enable/disable toggle, delete model | Never surprise-download half a gigabyte |
25
+ | Output guard | Client validates generated JSON against the card block schema; invalid → paragraph fallback (same as server) | Small models fail JSON sometimes; the card must always render |
26
+
27
+ ### Architecture
28
+
29
+ 1. **Server persists the bundle for degraded jobs.** Worker, on a `degraded` job, stores the extraction bundle (transcript + OCR text + caption — text only, a few KB) on the card row (`cards.raw_bundle` TEXT, nullable) instead of discarding it after paragraph fallback.
30
+ 2. **New endpoint** `GET /cards/{id}/bundle` (auth: owner only) → `{"bundle": str, "transcript": str, "caption": str}`; 404 when no stored bundle.
31
+ 3. **New endpoint** `POST /cards/{id}/structure` (auth: owner only) → accepts client-generated `{blocks: [...], one_liner, tags, ...}`, validated server-side with the **same** Pydantic validation the LLM path uses (never trust device output), replaces the paragraph card's content, clears `raw_bundle`, re-embeds.
32
+ 4. **Flutter `LocalAiService`:** model download manager (resumable, checksum), inference wrapper (`structureBundle(bundle) -> Map?`), prompt tuned for 1B models (short system prompt, few-shot single example, strict JSON instruction).
33
+ 5. **Flow:** card comes back `quota_degraded` → app checks Local AI enabled → fetches bundle → structures on-device (progress UI: "Generating on your phone…") → POSTs result → card upgrades in place. Failure at any step → paragraph card stays (user never worse off).
34
+
35
+ ### UX rules
36
+
37
+ - Quota chip gains a second state: past quota + model installed → "Generating on your phone" instead of "AI recharges tomorrow".
38
+ - Generation runs foreground-visible (reader shows progress), cancellable. No silent battery drain.
39
+ - Settings shows model size on disk, last-used, delete button.
40
+
41
+ ### Explicitly rejected
42
+
43
+ - Shipping the whole pipeline on-device (yt-dlp on Android: no).
44
+ - Auto-downloading the model for everyone.
45
+ - Trusting device JSON without server-side validation (auth'd users could inject arbitrary blocks otherwise).
46
+
47
+ ## Part 2 — Distribution & sustainability (V2 grab-bag)
48
+
49
+ 1. **Play Store**: $25 one-time, release signing already exists; needs privacy policy page (host on the HF Space at `/privacy`), Data Safety form (declares: account IDs, user content stored server-side), target API compliance. Do after auth ships — store review with anonymous+Google auth is straightforward.
50
+ 2. **Sustainability hooks**: GitHub Sponsors / Ko-fi link in Profile → About ("Keep Cachy free"); apply for GitHub Student Pack + Cerebras/Groq startup/edu credits to raise free ceilings.
51
+ 3. **Cleanup task**: delete `/auth/claim` endpoint + `claims` table ~1 month after V1 auth ships (calendar it).
52
+ 4. **Deferred still**: email/password auth, payments, iOS, multi-device conflict resolution. Not in V2 either unless users demand.
53
+
54
+ ## Part 3 — Private media (thumbnails/keyframes off the public HF dataset)
55
+
56
+ ### Problem
57
+
58
+ Thumbnails and keyframes for every user's saved content currently live in a **public** HF dataset repo (`hf_media_repo` in `config.py`). The onboarding name screen promises "Your library stays private. Only you see your cards" — false for images today. Backend currently returns HF dataset URLs directly to the client (`resolveMedia` in `api_client.dart` joins bare paths, or passes through absolute URLs).
59
+
60
+ ### Decision
61
+
62
+ Flip the HF dataset repo to **private**, add an owner-checked proxy endpoint, keep everything else (HF storage, `hf_api_key`) as-is. No new vendor — R2/S3 migration stays a "later, if bandwidth ever hurts" option, not part of this task (ponytail: don't add infrastructure for a problem that doesn't exist yet at this scale).
63
+
64
+ ### Architecture
65
+
66
+ 1. **Flip `hf_media_repo` to private** in the HF dataset settings (one-time manual step, no code).
67
+ 2. **New endpoint** `GET /media/{card_id}/{filename}` — `OwnerDep`-gated (must own the card, verified via `CardRow.owner_id`), streams bytes from the private HF dataset using the existing `hf_api_key` (server-side `hf_hub_download` or the HF `hfh` HTTP API), sets correct `content-type` from the file extension.
68
+ 3. **Backend media writer** (wherever thumbnails/keyframes are currently persisted, likely `store/media.py`) stores media under a `{card_id}/...` path scheme if not already, so the proxy route can validate ownership before any HF fetch.
69
+ 4. **Client**: `ApiClient.resolveMedia` continues to just join bare paths onto `baseUrl` — no change needed if the backend now returns `/media/{card_id}/{filename}` instead of absolute HF URLs (check `structuring.py`/`worker.py` for where media refs are written into card JSON; update to emit the proxy path instead of the raw HF URL).
70
+ 5. **Caching**: proxy response gets `Cache-Control: private, max-age=3600` (browser/client caches per-session; still requires re-auth on a fresh session, avoiding stale public exposure).
71
+
72
+ ### Testing
73
+
74
+ - Backend: proxy route 404s for non-owned cards; 200 + correct bytes for owned cards; unauthenticated request 401.
75
+ - Manual: confirm the HF dataset repo is actually private (attempt an unauthenticated raw HF URL fetch — must fail) after the flip.
76
+ - Regression: existing cards' media still resolves after the path-scheme change (migrate old absolute-URL refs or keep a fallback branch in `resolveMedia`/backend for pre-migration cards).
77
+
78
+ ## Success criteria
79
+
80
+ - A quota-exhausted user with the model installed gets a structured (not paragraph) card, fully offline of AI providers, in <2 min on a mid-range phone.
81
+ - Server AI spend for that card: zero.
82
+ - Users without the model see zero change.
83
+ - No thumbnail or keyframe is fetchable without a valid owner session (public dataset browsing no longer exposes any user's media).
84
+
85
+ ## Testing
86
+
87
+ - Backend: bundle persisted only for degraded jobs; `/bundle` owner-scoped 404/200; `/structure` rejects schema-invalid payloads, upgrades card, clears bundle.
88
+ - Flutter: `LocalAiService` faked in tests (interface like `AuthService`); download-manager state machine (idle→downloading→ready→error) unit-tested; JSON-invalid model output → paragraph kept.
89
+ - Manual: one real mid-range Android device before release (emulators lie about inference speed).