Vatxzz commited on
Commit
1db8f6f
Β·
1 Parent(s): 1da41b8

Natural click-driven presenter tour

Browse files

Rework Present mode so every screen change is preceded by a visible cursor
landing and tap on the real widget, and follow the guided narrative
(home β†’ card β†’ concepts β†’ catalog β†’ nav bar β†’ graph/chat β†’ add).

- PresenterController: point→tap→act gesture choreography; navigation routed
through a visible tap on the nav control; rewritten kTour script; speech
watchdog so a missing web 'end' event can no longer hang the tour.
- PresenterSpotlight: hold the target through the 150ms geometry-poll jitter
(fixes the snapping glide); always render the cursor; add a tap ripple.
- AgentBus: onLibraryTab / onOpenCatalogItem hooks.
- Screens register real tap targets (nav tabs, top icons, plus, first card,
reader blocks/insight/references/action-bar); Concepts & Catalog now flip
the real library tab instead of a full-screen swap.
- .gitignore: scope the bare 'presenter' rule to /presenter/ so the Flutter
feature dir is tracked (it never was) while the root prototype stays ignored.

.gitignore CHANGED
@@ -45,4 +45,6 @@ graph/
45
  context.md
46
  i-want-you-to-spicy-dusk.md
47
  CACHY_OVERVIEW.md
48
- presenter
 
 
 
45
  context.md
46
  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/
app/lib/data/services/api_client.dart CHANGED
@@ -144,6 +144,27 @@ class ApiClient {
144
  return steps.whereType<Map<String, dynamic>>().toList();
145
  }
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  // ------------------------------------------------------------------------- //
148
  // Cards
149
  // ------------------------------------------------------------------------- //
 
144
  return steps.whereType<Map<String, dynamic>>().toList();
145
  }
146
 
147
+ /// Relay a Present-mode event to the backend so it lands in the same
148
+ /// colour-coded pipeline log `start.py` shows β€” `debugPrint` alone only
149
+ /// reaches the terminal via the browser's DWDS log forwarding, which is
150
+ /// flaky, so this is the reliable path. Fire-and-forget: a failed relay
151
+ /// must never interrupt a live presentation, so errors are swallowed here.
152
+ void presenterLog(String message, {String level = 'info'}) {
153
+ unawaited(_postPresenterLog(message, level));
154
+ }
155
+
156
+ Future<void> _postPresenterLog(String message, String level) async {
157
+ try {
158
+ await _client.post(
159
+ _uri('/presenter/log'),
160
+ headers: {'content-type': 'application/json', ..._ownerHeader},
161
+ body: jsonEncode({'level': level, 'message': message}),
162
+ );
163
+ } catch (_) {
164
+ // Relay is best-effort; debugPrint is the fallback trail.
165
+ }
166
+ }
167
+
168
  // ------------------------------------------------------------------------- //
169
  // Cards
170
  // ------------------------------------------------------------------------- //
app/lib/ui/core/home_shell.dart CHANGED
@@ -13,10 +13,8 @@ import 'package:provider/provider.dart';
13
 
14
  import '../features/actions/views/actions_screen.dart';
15
  import '../features/capture/views/capture_sheet.dart';
16
- import '../features/catalog/views/catalog_screen.dart';
17
  import '../features/collections/views/collections_screen.dart';
18
  import '../features/concepts/views/concept_detail_screen.dart';
19
- import '../features/concepts/views/concepts_screen.dart';
20
  import '../features/feed/views/connections_screen.dart';
21
  import '../features/feed/views/knowledge_feed_screen.dart';
22
  import '../features/graph/views/graph_screen.dart';
@@ -89,9 +87,21 @@ class _HomeShellState extends State<HomeShell> {
89
  PresenterController? _presenter;
90
  Widget? _presenterScreen; // GraphScreen/SearchScreen shown during the tour
91
 
 
 
 
 
 
 
 
 
 
 
92
  void _startPresenting() {
93
  final repo = context.read<CardRepository>();
94
  final bus = context.read<AgentBus>()
 
 
95
  ..onNavigate = _goToView
96
  ..onOpenCard = _openCardInPresenter
97
  ..onCreateCard = _createCardInPresenter
@@ -99,6 +109,9 @@ class _HomeShellState extends State<HomeShell> {
99
  ..onOpenRabbitHole = _openRabbitHoleInPresenter
100
  ..onOpenCardChat = _openCardChatInPresenter
101
  ..onOpenLibraryChat = _openLibraryChatInPresenter;
 
 
 
102
  final controller = PresenterController(repository: repo, bus: bus);
103
  setState(() => _presenter = controller);
104
  controller.addListener(_onPresenterChanged);
@@ -156,8 +169,12 @@ class _HomeShellState extends State<HomeShell> {
156
  setState(() => _presenterScreen = ReaderScreen(cardId: result.cardId));
157
  }
158
  return result.cardId;
159
- } catch (_) {
160
- return null; // live demo: don't crash the tour if the pipeline is down
 
 
 
 
161
  }
162
  }
163
 
@@ -165,6 +182,12 @@ class _HomeShellState extends State<HomeShell> {
165
  if (_presenter?.phase == PresenterPhase.done) {
166
  final c = _presenter;
167
  c?.removeListener(_onPresenterChanged);
 
 
 
 
 
 
168
  setState(() {
169
  _presenter = null;
170
  _presenterScreen = null;
@@ -195,15 +218,23 @@ class _HomeShellState extends State<HomeShell> {
195
  case 'search':
196
  _presenterScreen = const SearchScreen();
197
  case 'catalog':
198
- _presenterScreen = const CatalogScreen();
 
 
 
 
 
199
  case 'concepts':
200
- _presenterScreen = const ConceptsScreen();
 
 
201
  case 'connections':
202
  _presenterScreen = const ConnectionsScreen();
203
  case 'library':
204
  default:
205
  _presenterScreen = null;
206
  _index = 0;
 
207
  }
208
  });
209
  }
@@ -274,13 +305,23 @@ class _HomeShellState extends State<HomeShell> {
274
  floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
275
  floatingActionButton: selecting
276
  ? null
277
- : _CaptureButton(onTap: () => showCaptureSheet(context)),
 
 
 
278
  bottomNavigationBar: selecting
279
  ? _SelectionNavSlot(
280
  vm: vm,
281
  onConfirmDelete: () => _confirmBulkDelete(context, vm),
282
  )
283
- : _GlassNav(index: _index, onSelect: _select),
 
 
 
 
 
 
 
284
  );
285
  }
286
 
@@ -329,10 +370,13 @@ class _HomeShellState extends State<HomeShell> {
329
  Positioned.fill(
330
  child: Row(
331
  children: [
332
- _GlassRail(
333
- index: _index,
334
- onSelect: _select,
335
- onCapture: () => showCaptureSheet(context),
 
 
 
336
  ),
337
  Expanded(
338
  child: Stack(
@@ -357,7 +401,10 @@ class _HomeShellState extends State<HomeShell> {
357
  List<Widget> _presenterLayers(BuildContext context) {
358
  if (_presenter != null) {
359
  return [
360
- PresenterSpotlight(controller: _presenter!),
 
 
 
361
  PresenterOverlay(controller: _presenter!),
362
  ];
363
  }
@@ -517,10 +564,18 @@ class _GlassRail extends StatelessWidget {
517
  // ── Mobile floating pill nav ───────────────────────────────────────────────── //
518
 
519
  class _GlassNav extends StatelessWidget {
520
- const _GlassNav({required this.index, required this.onSelect});
 
 
 
 
521
  final int index;
522
  final ValueChanged<int> onSelect;
523
 
 
 
 
 
524
  @override
525
  Widget build(BuildContext context) {
526
  final b = Theme.of(context).brightness;
@@ -562,10 +617,13 @@ class _GlassNav extends StatelessWidget {
562
  children: [
563
  for (var i = 0; i < _navItems.length; i++)
564
  Expanded(
565
- child: _NavBtn(
566
- def: _navItems[i],
567
- selected: index == i,
568
- onTap: () => onSelect(i),
 
 
 
569
  ),
570
  ),
571
  ],
 
13
 
14
  import '../features/actions/views/actions_screen.dart';
15
  import '../features/capture/views/capture_sheet.dart';
 
16
  import '../features/collections/views/collections_screen.dart';
17
  import '../features/concepts/views/concept_detail_screen.dart';
 
18
  import '../features/feed/views/connections_screen.dart';
19
  import '../features/feed/views/knowledge_feed_screen.dart';
20
  import '../features/graph/views/graph_screen.dart';
 
87
  PresenterController? _presenter;
88
  Widget? _presenterScreen; // GraphScreen/SearchScreen shown during the tour
89
 
90
+ // Spotlight anchor for the nav (bottom pill / side rail β€” only one mounted).
91
+ final GlobalKey _navKey = GlobalKey();
92
+
93
+ // Per-tab anchors so the presenter's cursor lands on the exact tab it taps,
94
+ // and the plus button so "add something" reads as a real click.
95
+ static const _navSpotlightIds = ['nav.home', 'nav.folders', 'nav.todo', 'nav.feed', 'nav.you'];
96
+ final List<GlobalKey> _navItemKeys =
97
+ List.generate(_navSpotlightIds.length, (_) => GlobalKey());
98
+ final GlobalKey _plusKey = GlobalKey();
99
+
100
  void _startPresenting() {
101
  final repo = context.read<CardRepository>();
102
  final bus = context.read<AgentBus>()
103
+ ..registerSpotlight('nav', _navKey)
104
+ ..registerSpotlight('home.plus', _plusKey)
105
  ..onNavigate = _goToView
106
  ..onOpenCard = _openCardInPresenter
107
  ..onCreateCard = _createCardInPresenter
 
109
  ..onOpenRabbitHole = _openRabbitHoleInPresenter
110
  ..onOpenCardChat = _openCardChatInPresenter
111
  ..onOpenLibraryChat = _openLibraryChatInPresenter;
112
+ for (var i = 0; i < _navSpotlightIds.length; i++) {
113
+ bus.registerSpotlight(_navSpotlightIds[i], _navItemKeys[i]);
114
+ }
115
  final controller = PresenterController(repository: repo, bus: bus);
116
  setState(() => _presenter = controller);
117
  controller.addListener(_onPresenterChanged);
 
169
  setState(() => _presenterScreen = ReaderScreen(cardId: result.cardId));
170
  }
171
  return result.cardId;
172
+ } catch (e, st) {
173
+ // Live demo: don't crash the tour if the pipeline is down, but the
174
+ // failure shouldn't vanish silently either.
175
+ debugPrint('[Presenter] create_card($url) failed: $e');
176
+ debugPrintStack(stackTrace: st, label: '[Presenter] create_card');
177
+ return null;
178
  }
179
  }
180
 
 
182
  if (_presenter?.phase == PresenterPhase.done) {
183
  final c = _presenter;
184
  c?.removeListener(_onPresenterChanged);
185
+ final bus = context.read<AgentBus>()
186
+ ..unregisterSpotlight('nav', _navKey)
187
+ ..unregisterSpotlight('home.plus', _plusKey);
188
+ for (var i = 0; i < _navSpotlightIds.length; i++) {
189
+ bus.unregisterSpotlight(_navSpotlightIds[i], _navItemKeys[i]);
190
+ }
191
  setState(() {
192
  _presenter = null;
193
  _presenterScreen = null;
 
218
  case 'search':
219
  _presenterScreen = const SearchScreen();
220
  case 'catalog':
221
+ // Concepts and Catalog are the library's own top tabs β€” flip the real
222
+ // tab (via the hook the library registers) instead of overlaying a
223
+ // separate screen, so it reads as a tap on the segment.
224
+ _presenterScreen = null;
225
+ _index = 0;
226
+ context.read<AgentBus>().onLibraryTab?.call(2);
227
  case 'concepts':
228
+ _presenterScreen = null;
229
+ _index = 0;
230
+ context.read<AgentBus>().onLibraryTab?.call(1);
231
  case 'connections':
232
  _presenterScreen = const ConnectionsScreen();
233
  case 'library':
234
  default:
235
  _presenterScreen = null;
236
  _index = 0;
237
+ context.read<AgentBus>().onLibraryTab?.call(0);
238
  }
239
  });
240
  }
 
305
  floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
306
  floatingActionButton: selecting
307
  ? null
308
+ : KeyedSubtree(
309
+ key: _plusKey,
310
+ child: _CaptureButton(onTap: () => showCaptureSheet(context)),
311
+ ),
312
  bottomNavigationBar: selecting
313
  ? _SelectionNavSlot(
314
  vm: vm,
315
  onConfirmDelete: () => _confirmBulkDelete(context, vm),
316
  )
317
+ : KeyedSubtree(
318
+ key: _navKey,
319
+ child: _GlassNav(
320
+ index: _index,
321
+ onSelect: _select,
322
+ itemKeys: _navItemKeys,
323
+ ),
324
+ ),
325
  );
326
  }
327
 
 
370
  Positioned.fill(
371
  child: Row(
372
  children: [
373
+ KeyedSubtree(
374
+ key: _navKey,
375
+ child: _GlassRail(
376
+ index: _index,
377
+ onSelect: _select,
378
+ onCapture: () => showCaptureSheet(context),
379
+ ),
380
  ),
381
  Expanded(
382
  child: Stack(
 
401
  List<Widget> _presenterLayers(BuildContext context) {
402
  if (_presenter != null) {
403
  return [
404
+ PresenterSpotlight(
405
+ controller: _presenter!,
406
+ bus: context.read<AgentBus>(),
407
+ ),
408
  PresenterOverlay(controller: _presenter!),
409
  ];
410
  }
 
564
  // ── Mobile floating pill nav ───────────────────────────────────────────────── //
565
 
566
  class _GlassNav extends StatelessWidget {
567
+ const _GlassNav({
568
+ required this.index,
569
+ required this.onSelect,
570
+ this.itemKeys = const [],
571
+ });
572
  final int index;
573
  final ValueChanged<int> onSelect;
574
 
575
+ /// Per-tab spotlight anchors so the presenter's cursor lands on the exact
576
+ /// tab it taps. Empty when not presenting.
577
+ final List<GlobalKey> itemKeys;
578
+
579
  @override
580
  Widget build(BuildContext context) {
581
  final b = Theme.of(context).brightness;
 
617
  children: [
618
  for (var i = 0; i < _navItems.length; i++)
619
  Expanded(
620
+ child: KeyedSubtree(
621
+ key: i < itemKeys.length ? itemKeys[i] : null,
622
+ child: _NavBtn(
623
+ def: _navItems[i],
624
+ selected: index == i,
625
+ onTap: () => onSelect(i),
626
+ ),
627
  ),
628
  ),
629
  ],
app/lib/ui/core/safe_notifier.dart ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// A [ChangeNotifier] that silently ignores `notifyListeners()` once disposed.
2
+ ///
3
+ /// View models kick off async work (a network turn, a bootstrap) and then
4
+ /// `notifyListeners()` when it resolves. If the screen is torn down first β€”
5
+ /// the user pops it, or Present mode navigates on before the request
6
+ /// finishes β€” that late notify hits a disposed notifier and throws
7
+ /// "used after being disposed". Guarding every call site is error-prone;
8
+ /// guarding the notifier once is not.
9
+ library;
10
+
11
+ import 'package:flutter/foundation.dart';
12
+
13
+ mixin SafeNotifier on ChangeNotifier {
14
+ bool _disposed = false;
15
+
16
+ /// Whether [dispose] has run β€” useful for bailing out of in-flight async
17
+ /// work before it touches other disposed state.
18
+ bool get disposed => _disposed;
19
+
20
+ @override
21
+ void dispose() {
22
+ _disposed = true;
23
+ super.dispose();
24
+ }
25
+
26
+ @override
27
+ void notifyListeners() {
28
+ if (_disposed) return;
29
+ super.notifyListeners();
30
+ }
31
+ }
app/lib/ui/features/concepts/view_models/concepts_view_model.dart CHANGED
@@ -6,10 +6,11 @@ import 'package:flutter/foundation.dart';
6
 
7
  import '../../../../data/repositories/card_repository.dart';
8
  import '../../../../domain/models/concept.dart';
 
9
 
10
  enum ConceptsStatus { idle, loading, ready, error, empty }
11
 
12
- class ConceptsViewModel extends ChangeNotifier {
13
  ConceptsViewModel({required CardRepository repository})
14
  : _repository = repository {
15
  _repository.addListener(_onRepoChange);
 
6
 
7
  import '../../../../data/repositories/card_repository.dart';
8
  import '../../../../domain/models/concept.dart';
9
+ import '../../../core/safe_notifier.dart';
10
 
11
  enum ConceptsStatus { idle, loading, ready, error, empty }
12
 
13
+ class ConceptsViewModel extends ChangeNotifier with SafeNotifier {
14
  ConceptsViewModel({required CardRepository repository})
15
  : _repository = repository {
16
  _repository.addListener(_onRepoChange);
app/lib/ui/features/feed/views/knowledge_feed_screen.dart CHANGED
@@ -59,8 +59,10 @@ class _FeedViewState extends State<_FeedView> {
59
  int _page = 0;
60
 
61
  // Agent driving: expose feed paging to the presenter agent while mounted.
 
62
  AgentBus? _bus;
63
  FeedAgentHooks? _hooks;
 
64
 
65
  int get _total {
66
  if (!mounted) return 0;
@@ -82,12 +84,14 @@ class _FeedViewState extends State<_FeedView> {
82
  );
83
  _hooks = hooks;
84
  _bus!.attachFeed(hooks);
 
85
  }
86
 
87
  @override
88
  void dispose() {
89
  final h = _hooks;
90
  if (h != null) _bus?.detachFeed(h);
 
91
  _controller.dispose();
92
  _focusNode.dispose();
93
  super.dispose();
@@ -191,6 +195,7 @@ class _FeedViewState extends State<_FeedView> {
191
  return ScrollConfiguration(
192
  behavior: const _FeedScrollBehavior(),
193
  child: PageView.builder(
 
194
  controller: _controller,
195
  scrollDirection: Axis.vertical,
196
  itemCount: vm.items.length,
 
59
  int _page = 0;
60
 
61
  // Agent driving: expose feed paging to the presenter agent while mounted.
62
+ // The page key gives its spotlight the pager's real bounds.
63
  AgentBus? _bus;
64
  FeedAgentHooks? _hooks;
65
+ final _pageKey = GlobalKey();
66
 
67
  int get _total {
68
  if (!mounted) return 0;
 
84
  );
85
  _hooks = hooks;
86
  _bus!.attachFeed(hooks);
87
+ _bus!.registerSpotlight('feed.page', _pageKey);
88
  }
89
 
90
  @override
91
  void dispose() {
92
  final h = _hooks;
93
  if (h != null) _bus?.detachFeed(h);
94
+ _bus?.unregisterSpotlight('feed.page', _pageKey);
95
  _controller.dispose();
96
  _focusNode.dispose();
97
  super.dispose();
 
195
  return ScrollConfiguration(
196
  behavior: const _FeedScrollBehavior(),
197
  child: PageView.builder(
198
+ key: _pageKey,
199
  controller: _controller,
200
  scrollDirection: Axis.vertical,
201
  itemCount: vm.items.length,
app/lib/ui/features/graph/views/graph_screen.dart CHANGED
@@ -166,9 +166,11 @@ class _GraphScreenState extends State<GraphScreen>
166
  bool _showConcepts = true;
167
 
168
  // Agent driving: the presenter agent operates the graph through these hooks
169
- // while this screen is mounted (see AgentBus).
 
170
  AgentBus? _bus;
171
  GraphAgentHooks? _hooks;
 
172
 
173
  @override
174
  void initState() {
@@ -193,12 +195,14 @@ class _GraphScreenState extends State<GraphScreen>
193
  );
194
  _hooks = hooks;
195
  _bus!.attachGraph(hooks);
 
196
  }
197
 
198
  @override
199
  void dispose() {
200
  final h = _hooks;
201
  if (h != null) _bus?.detachGraph(h);
 
202
  _ticker.dispose();
203
  super.dispose();
204
  }
@@ -968,6 +972,7 @@ class _GraphScreenState extends State<GraphScreen>
968
  final size =
969
  Size(constraints.maxWidth, constraints.maxHeight);
970
  return ClipRect(
 
971
  child: GestureDetector(
972
  onScaleStart: (d) => _onScaleStart(d, size),
973
  onScaleUpdate: (d) => _onScaleUpdate(d, size),
 
166
  bool _showConcepts = true;
167
 
168
  // Agent driving: the presenter agent operates the graph through these hooks
169
+ // while this screen is mounted (see AgentBus). The canvas key gives its
170
+ // spotlight the real canvas bounds.
171
  AgentBus? _bus;
172
  GraphAgentHooks? _hooks;
173
+ final _canvasKey = GlobalKey();
174
 
175
  @override
176
  void initState() {
 
195
  );
196
  _hooks = hooks;
197
  _bus!.attachGraph(hooks);
198
+ _bus!.registerSpotlight('graph.canvas', _canvasKey);
199
  }
200
 
201
  @override
202
  void dispose() {
203
  final h = _hooks;
204
  if (h != null) _bus?.detachGraph(h);
205
+ _bus?.unregisterSpotlight('graph.canvas', _canvasKey);
206
  _ticker.dispose();
207
  super.dispose();
208
  }
 
972
  final size =
973
  Size(constraints.maxWidth, constraints.maxHeight);
974
  return ClipRect(
975
+ key: _canvasKey,
976
  child: GestureDetector(
977
  onScaleStart: (d) => _onScaleStart(d, size),
978
  onScaleUpdate: (d) => _onScaleUpdate(d, size),
app/lib/ui/features/library/view_models/library_chat_view_model.dart CHANGED
@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
7
 
8
  import '../../../../data/repositories/card_repository.dart';
9
  import '../../../../data/services/api_client.dart';
 
10
 
11
  class LibraryChatMessage {
12
  const LibraryChatMessage({required this.role, required this.content});
@@ -17,7 +18,7 @@ class LibraryChatMessage {
17
  Map<String, String> toWire() => {'role': role, 'content': content};
18
  }
19
 
20
- class LibraryChatViewModel extends ChangeNotifier {
21
  LibraryChatViewModel({required CardRepository repository})
22
  : _repository = repository;
23
 
 
7
 
8
  import '../../../../data/repositories/card_repository.dart';
9
  import '../../../../data/services/api_client.dart';
10
+ import '../../../core/safe_notifier.dart';
11
 
12
  class LibraryChatMessage {
13
  const LibraryChatMessage({required this.role, required this.content});
 
18
  Map<String, String> toWire() => {'role': role, 'content': content};
19
  }
20
 
21
+ class LibraryChatViewModel extends ChangeNotifier with SafeNotifier {
22
  LibraryChatViewModel({required CardRepository repository})
23
  : _repository = repository;
24
 
app/lib/ui/features/library/view_models/library_view_model.dart CHANGED
@@ -9,10 +9,11 @@ import 'package:flutter/foundation.dart';
9
  import '../../../../data/repositories/card_repository.dart';
10
  import '../../../../domain/models/card.dart';
11
  import '../../../../domain/models/enums.dart';
 
12
 
13
  enum LibraryStatus { idle, loading, ready, error, empty }
14
 
15
- class LibraryViewModel extends ChangeNotifier {
16
  LibraryViewModel({required CardRepository repository})
17
  : _repository = repository {
18
  _repository.addListener(_onRepoChange);
 
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);
app/lib/ui/features/library/views/library_screen.dart CHANGED
@@ -33,6 +33,7 @@ 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';
 
36
  import '../../reader/views/reader_screen.dart';
37
  import '../../search/views/search_screen.dart';
38
  import '../view_models/library_view_model.dart';
@@ -50,18 +51,62 @@ class LibraryScreen extends StatelessWidget {
50
  }
51
  }
52
 
53
- class _LibraryView extends StatelessWidget {
54
  const _LibraryView();
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  @override
57
  Widget build(BuildContext context) {
58
  final vm = context.watch<LibraryViewModel>();
59
  final scheme = Theme.of(context).colorScheme;
60
  final isDesktop = MediaQuery.sizeOf(context).width >= Insets.desktop;
61
 
62
- return DefaultTabController(
63
- length: 3,
64
- child: Scaffold(
65
  backgroundColor: Colors.transparent,
66
  appBar: AppBar(
67
  titleSpacing: Insets.page,
@@ -76,6 +121,7 @@ class _LibraryView extends StatelessWidget {
76
  ),
77
  ),
78
  IconButton(
 
79
  tooltip: 'Graph',
80
  icon: const PhosphorIcon(PhosphorIconsRegular.graph),
81
  onPressed: () => Navigator.of(context).push(
@@ -83,6 +129,7 @@ class _LibraryView extends StatelessWidget {
83
  ),
84
  ),
85
  IconButton(
 
86
  tooltip: 'Chat',
87
  icon: const PhosphorIcon(PhosphorIconsRegular.chats),
88
  onPressed: () => Navigator.of(context).push(
@@ -90,6 +137,7 @@ class _LibraryView extends StatelessWidget {
90
  ),
91
  ),
92
  IconButton(
 
93
  tooltip: 'Search',
94
  icon: const PhosphorIcon(PhosphorIconsRegular.magnifyingGlass),
95
  onPressed: () => Navigator.of(context).push(
@@ -113,6 +161,7 @@ class _LibraryView extends StatelessWidget {
113
  borderRadius: BorderRadius.circular(12),
114
  ),
115
  child: TabBar(
 
116
  dividerColor: Colors.transparent,
117
  indicator: BoxDecoration(
118
  color: scheme.surface,
@@ -127,10 +176,10 @@ class _LibraryView extends StatelessWidget {
127
  unselectedLabelColor: scheme.onSurfaceVariant,
128
  labelStyle: Brand.label(size: 12, weight: FontWeight.w700),
129
  unselectedLabelStyle: Brand.label(size: 12, weight: FontWeight.w500),
130
- tabs: const [
131
- Tab(text: 'CARDS'),
132
- Tab(text: 'CONCEPTS'),
133
- Tab(text: 'CATALOG'),
134
  ],
135
  ),
136
  ),
@@ -139,15 +188,15 @@ class _LibraryView extends StatelessWidget {
139
  ),
140
  ),
141
  ),
142
- body: const TabBarView(
143
- children: [
 
144
  _CardsTab(),
145
  ConceptsScreen(),
146
  CatalogScreen(),
147
  ],
148
  ),
149
- ),
150
- );
151
  }
152
  }
153
 
@@ -167,12 +216,27 @@ class _CardsTabState extends State<_CardsTab> {
167
  /// The grid index that most recently received keyboard focus.
168
  int _lastFocusedIndex = 0;
169
 
 
 
 
 
 
 
170
  @override
171
  void initState() {
172
  super.initState();
173
  _fraction = context.read<AppController>().splitPaneFraction;
174
  }
175
 
 
 
 
 
 
 
 
 
 
176
  /// Grows or shrinks [_focusNodes] to exactly [count] nodes.
177
  /// New nodes are equipped with a listener that updates [_lastFocusedIndex].
178
  void _ensureFocusNodes(int count) {
@@ -192,6 +256,10 @@ class _CardsTabState extends State<_CardsTab> {
192
 
193
  @override
194
  void dispose() {
 
 
 
 
195
  for (final node in _focusNodes) {
196
  node.dispose();
197
  }
@@ -426,6 +494,7 @@ class _CardsTabState extends State<_CardsTab> {
426
  child: FocusTraversalGroup(
427
  policy: OrderedTraversalPolicy(),
428
  child: GridView.builder(
 
429
  padding: const EdgeInsets.fromLTRB(Insets.page, 8, Insets.page, 96),
430
  physics: const AlwaysScrollableScrollPhysics(),
431
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
@@ -444,7 +513,7 @@ class _CardsTabState extends State<_CardsTab> {
444
  (Platform.isMacOS ||
445
  Platform.isWindows ||
446
  Platform.isLinux);
447
- return CardTile(
448
  focusNode: _focusNodes[i],
449
  card: card,
450
  api: api,
@@ -493,6 +562,11 @@ class _CardsTabState extends State<_CardsTab> {
493
  },
494
  onDelete: () => vm.delete(card.cardId),
495
  );
 
 
 
 
 
496
  },
497
  ),
498
  ),
 
33
  import '../../concepts/views/concepts_screen.dart';
34
  import '../../graph/views/graph_screen.dart';
35
  import '../../library/views/library_chat_screen.dart';
36
+ import '../../presenter/agent_bus.dart';
37
  import '../../reader/views/reader_screen.dart';
38
  import '../../search/views/search_screen.dart';
39
  import '../view_models/library_view_model.dart';
 
51
  }
52
  }
53
 
54
+ class _LibraryView extends StatefulWidget {
55
  const _LibraryView();
56
 
57
+ @override
58
+ State<_LibraryView> createState() => _LibraryViewState();
59
+ }
60
+
61
+ class _LibraryViewState extends State<_LibraryView>
62
+ with SingleTickerProviderStateMixin {
63
+ late final TabController _tabs = TabController(length: 3, vsync: this);
64
+
65
+ // Presenter spotlight anchors: the top segments and top-bar icons the agent
66
+ // taps live here. Registered while mounted; the presenter's cursor resolves
67
+ // them to real geometry.
68
+ final _conceptsTabKey = GlobalKey();
69
+ final _catalogTabKey = GlobalKey();
70
+ final _graphKey = GlobalKey();
71
+ final _chatKey = GlobalKey();
72
+ final _searchKey = GlobalKey();
73
+ AgentBus? _bus;
74
+
75
+ @override
76
+ void didChangeDependencies() {
77
+ super.didChangeDependencies();
78
+ if (_bus != null) return;
79
+ _bus = context.read<AgentBus>()
80
+ ..onLibraryTab = (i) {
81
+ if (i >= 0 && i < _tabs.length) _tabs.animateTo(i);
82
+ }
83
+ ..registerSpotlight('library.tab.concepts', _conceptsTabKey)
84
+ ..registerSpotlight('library.tab.catalog', _catalogTabKey)
85
+ ..registerSpotlight('top.graph', _graphKey)
86
+ ..registerSpotlight('top.chat', _chatKey)
87
+ ..registerSpotlight('top.search', _searchKey);
88
+ }
89
+
90
+ @override
91
+ void dispose() {
92
+ _bus
93
+ ?..onLibraryTab = null
94
+ ..unregisterSpotlight('library.tab.concepts', _conceptsTabKey)
95
+ ..unregisterSpotlight('library.tab.catalog', _catalogTabKey)
96
+ ..unregisterSpotlight('top.graph', _graphKey)
97
+ ..unregisterSpotlight('top.chat', _chatKey)
98
+ ..unregisterSpotlight('top.search', _searchKey);
99
+ _tabs.dispose();
100
+ super.dispose();
101
+ }
102
+
103
  @override
104
  Widget build(BuildContext context) {
105
  final vm = context.watch<LibraryViewModel>();
106
  final scheme = Theme.of(context).colorScheme;
107
  final isDesktop = MediaQuery.sizeOf(context).width >= Insets.desktop;
108
 
109
+ return Scaffold(
 
 
110
  backgroundColor: Colors.transparent,
111
  appBar: AppBar(
112
  titleSpacing: Insets.page,
 
121
  ),
122
  ),
123
  IconButton(
124
+ key: _graphKey,
125
  tooltip: 'Graph',
126
  icon: const PhosphorIcon(PhosphorIconsRegular.graph),
127
  onPressed: () => Navigator.of(context).push(
 
129
  ),
130
  ),
131
  IconButton(
132
+ key: _chatKey,
133
  tooltip: 'Chat',
134
  icon: const PhosphorIcon(PhosphorIconsRegular.chats),
135
  onPressed: () => Navigator.of(context).push(
 
137
  ),
138
  ),
139
  IconButton(
140
+ key: _searchKey,
141
  tooltip: 'Search',
142
  icon: const PhosphorIcon(PhosphorIconsRegular.magnifyingGlass),
143
  onPressed: () => Navigator.of(context).push(
 
161
  borderRadius: BorderRadius.circular(12),
162
  ),
163
  child: TabBar(
164
+ controller: _tabs,
165
  dividerColor: Colors.transparent,
166
  indicator: BoxDecoration(
167
  color: scheme.surface,
 
176
  unselectedLabelColor: scheme.onSurfaceVariant,
177
  labelStyle: Brand.label(size: 12, weight: FontWeight.w700),
178
  unselectedLabelStyle: Brand.label(size: 12, weight: FontWeight.w500),
179
+ tabs: [
180
+ const Tab(text: 'CARDS'),
181
+ Tab(key: _conceptsTabKey, text: 'CONCEPTS'),
182
+ Tab(key: _catalogTabKey, text: 'CATALOG'),
183
  ],
184
  ),
185
  ),
 
188
  ),
189
  ),
190
  ),
191
+ body: TabBarView(
192
+ controller: _tabs,
193
+ children: const [
194
  _CardsTab(),
195
  ConceptsScreen(),
196
  CatalogScreen(),
197
  ],
198
  ),
199
+ );
 
200
  }
201
  }
202
 
 
216
  /// The grid index that most recently received keyboard focus.
217
  int _lastFocusedIndex = 0;
218
 
219
+ // Presenter agent: expose the grid's scroll so the agent can browse the
220
+ // library while narrating.
221
+ final _gridScroll = ScrollController();
222
+ final _firstCardKey = GlobalKey();
223
+ AgentBus? _bus;
224
+
225
  @override
226
  void initState() {
227
  super.initState();
228
  _fraction = context.read<AppController>().splitPaneFraction;
229
  }
230
 
231
+ @override
232
+ void didChangeDependencies() {
233
+ super.didChangeDependencies();
234
+ if (_bus != null) return;
235
+ _bus = context.read<AgentBus>()
236
+ ..registerScrollable('library', _gridScroll)
237
+ ..registerSpotlight('card.first', _firstCardKey);
238
+ }
239
+
240
  /// Grows or shrinks [_focusNodes] to exactly [count] nodes.
241
  /// New nodes are equipped with a listener that updates [_lastFocusedIndex].
242
  void _ensureFocusNodes(int count) {
 
256
 
257
  @override
258
  void dispose() {
259
+ _bus
260
+ ?..unregisterScrollable('library', _gridScroll)
261
+ ..unregisterSpotlight('card.first', _firstCardKey);
262
+ _gridScroll.dispose();
263
  for (final node in _focusNodes) {
264
  node.dispose();
265
  }
 
494
  child: FocusTraversalGroup(
495
  policy: OrderedTraversalPolicy(),
496
  child: GridView.builder(
497
+ controller: _gridScroll,
498
  padding: const EdgeInsets.fromLTRB(Insets.page, 8, Insets.page, 96),
499
  physics: const AlwaysScrollableScrollPhysics(),
500
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
 
513
  (Platform.isMacOS ||
514
  Platform.isWindows ||
515
  Platform.isLinux);
516
+ final tile = CardTile(
517
  focusNode: _focusNodes[i],
518
  card: card,
519
  api: api,
 
562
  },
563
  onDelete: () => vm.delete(card.cardId),
564
  );
565
+ // Anchor the first tile so the presenter's cursor lands on a
566
+ // real card before opening it.
567
+ return i == 0
568
+ ? KeyedSubtree(key: _firstCardKey, child: tile)
569
+ : tile;
570
  },
571
  ),
572
  ),
app/lib/ui/features/presenter/agent_bus.dart ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// The bridge that lets the presenting agent actually *operate* the app.
2
+ ///
3
+ /// Cachy has no central controller registry β€” each feature keeps its live UI
4
+ /// state private inside its own `State`. Rather than rewire every screen into a
5
+ /// global controller, the agent talks to screens through this bus:
6
+ ///
7
+ /// * the host shell registers [onNavigate] / [onOpenCard] / [onCreateCard] so
8
+ /// the agent can move between views and open cards, and
9
+ /// * interactive screens (graph, feed, search) register a small set of
10
+ /// imperative *hooks* while they're mounted, which the agent invokes to
11
+ /// drive them live (focus a graph node, advance the feed, run a search).
12
+ ///
13
+ /// A screen attaches its hooks in `initState`/`didChangeDependencies` and
14
+ /// detaches them in `dispose`, so the agent only ever drives what's on screen.
15
+ library;
16
+
17
+ import 'package:flutter/widgets.dart';
18
+
19
+ import '../../../domain/models/graph.dart';
20
+
21
+ /// A single thing the agent can do: a verb ([type]) plus its arguments.
22
+ ///
23
+ /// The vocabulary is shared with the backend `/presenter/ask` endpoint, which
24
+ /// returns these to answer audience questions by *performing* them.
25
+ @immutable
26
+ class AgentAction {
27
+ const AgentAction(this.type, [this.args = const {}]);
28
+
29
+ /// One of: navigate, create_card, search, open_card, graph_focus,
30
+ /// graph_open, graph_wander, graph_reset, feed_next, feed_prev, wait.
31
+ final String type;
32
+ final Map<String, dynamic> args;
33
+
34
+ String? get view => args['view'] as String?;
35
+ String? get query => args['query'] as String?;
36
+ String? get url => args['url'] as String?;
37
+
38
+ factory AgentAction.fromJson(Map<String, dynamic> json) => AgentAction(
39
+ (json['do'] as String?)?.trim() ?? 'none',
40
+ Map<String, dynamic>.from(json)..remove('do'),
41
+ );
42
+ }
43
+
44
+ /// One beat of narration: optionally [say] something, optionally do [action].
45
+ /// Beats run in order; within a beat speech starts first and the action fires a
46
+ /// moment later *while* the agent is still talking β€” the way a human presenter
47
+ /// narrates over their own clicks.
48
+ @immutable
49
+ class AgentBeat {
50
+ const AgentBeat({this.say, this.action, this.view, this.focus});
51
+
52
+ final String? say;
53
+ final AgentAction? action;
54
+
55
+ /// Convenience for tour beats that just switch view before speaking.
56
+ final String? view;
57
+
58
+ /// Optional spotlight target id (see [AgentBus.spotlightRect]); when absent
59
+ /// the target is derived from the action type.
60
+ final String? focus;
61
+
62
+ factory AgentBeat.fromJson(Map<String, dynamic> json) {
63
+ final rawAction = json['action'];
64
+ return AgentBeat(
65
+ say: (json['say'] as String?)?.trim(),
66
+ action: rawAction is Map<String, dynamic>
67
+ ? AgentAction.fromJson(rawAction)
68
+ : null,
69
+ );
70
+ }
71
+ }
72
+
73
+ /// Imperative controls a mounted [graph screen] exposes to the agent.
74
+ class GraphAgentHooks {
75
+ GraphAgentHooks({
76
+ required this.nodes,
77
+ required this.isReady,
78
+ required this.focus,
79
+ required this.open,
80
+ required this.wander,
81
+ required this.reset,
82
+ required this.filterCluster,
83
+ required this.toggleConcepts,
84
+ });
85
+
86
+ /// Snapshot of the currently loaded graph nodes (empty until loaded).
87
+ final List<GraphNode> Function() nodes;
88
+
89
+ /// True once graph data is loaded and the screen can be driven.
90
+ final bool Function() isReady;
91
+
92
+ /// Center + select a node and dive into its neighborhood (ego view).
93
+ final Future<void> Function(String nodeId) focus;
94
+
95
+ /// Open a node's underlying card/concept.
96
+ final Future<void> Function(String nodeId) open;
97
+
98
+ /// Reheat the physics + drift the view so the layout visibly comes alive.
99
+ final Future<void> Function() wander;
100
+
101
+ /// Exit ego/local mode and recenter.
102
+ final void Function() reset;
103
+
104
+ /// Apply a cluster filter so only one community stays visible.
105
+ final Future<void> Function() filterCluster;
106
+
107
+ /// Toggle the concept hub nodes on/off.
108
+ final Future<void> Function() toggleConcepts;
109
+ }
110
+
111
+ /// Imperative controls a mounted feed exposes to the agent.
112
+ class FeedAgentHooks {
113
+ FeedAgentHooks({
114
+ required this.next,
115
+ required this.prev,
116
+ required this.count,
117
+ required this.shuffle,
118
+ });
119
+
120
+ final void Function() next;
121
+ final void Function() prev;
122
+ final int Function() count;
123
+
124
+ /// Reshuffle the feed into a fresh set of moments.
125
+ final void Function() shuffle;
126
+ }
127
+
128
+ /// Imperative controls a mounted search screen exposes to the agent.
129
+ class SearchAgentHooks {
130
+ SearchAgentHooks({required this.run, required this.filter});
131
+
132
+ /// Type a query into the search box and run it.
133
+ final void Function(String query) run;
134
+
135
+ /// Apply the first available content-type filter to the current results.
136
+ final void Function() filter;
137
+ }
138
+
139
+ /// Imperative controls a mounted reader exposes to the agent, so it can show
140
+ /// that cards are interactive (checkable steps and checklists) live.
141
+ class ReaderAgentHooks {
142
+ ReaderAgentHooks({
143
+ required this.isReady,
144
+ required this.toggleCheck,
145
+ required this.toggleStep,
146
+ });
147
+
148
+ /// True once the card is loaded.
149
+ final bool Function() isReady;
150
+
151
+ /// Toggle the first checklist item; returns true if there was one.
152
+ final Future<bool> Function() toggleCheck;
153
+
154
+ /// Mark the first step complete; returns true if there was one.
155
+ final Future<bool> Function() toggleStep;
156
+ }
157
+
158
+ /// Shared channel between the [PresenterController] agent and the app.
159
+ ///
160
+ /// Provided app-wide (see `main.dart`). The shell sets the navigation callbacks;
161
+ /// feature screens attach/detach their hooks as they mount/unmount.
162
+ class AgentBus extends ChangeNotifier {
163
+ // ── Shell-provided navigation ─────────────────────────────────────────── //
164
+
165
+ /// Switch the visible view. Recognized: library, feed, graph, search,
166
+ /// collections, actions, profile, catalog, concepts, connections.
167
+ void Function(String view)? onNavigate;
168
+
169
+ /// Open a card in the reader (kept under the agent glyph, not pushed).
170
+ Future<void> Function(String cardId)? onOpenCard;
171
+
172
+ /// Submit a URL to the live pipeline and open the resulting card.
173
+ Future<String?> Function(String url)? onCreateCard;
174
+
175
+ /// Open a concept's detail (auto-defines if it has no definition yet).
176
+ Future<void> Function(String conceptId, String name)? onOpenConcept;
177
+
178
+ /// Open the rabbit-hole explorer for a card, seeded with a topic.
179
+ Future<void> Function(String cardId, String seed)? onOpenRabbitHole;
180
+
181
+ /// Open the grounded per-card chat, seeded with a question it auto-asks.
182
+ Future<void> Function(String cardId, String title, String seed)?
183
+ onOpenCardChat;
184
+
185
+ /// Open the whole-library chat, seeded with a question it auto-asks.
186
+ Future<void> Function(String seed)? onOpenLibraryChat;
187
+
188
+ /// Switch the library's top segment (0 = Cards, 1 = Concepts, 2 = Catalog).
189
+ /// Concepts and Catalog are tabs inside the library, not separate screens β€”
190
+ /// the agent flips the real tab so the switch reads as a tap on the tab, not
191
+ /// a full-screen swap.
192
+ void Function(int index)? onLibraryTab;
193
+
194
+ /// Open the first catalog item's detail and generate its info (the catalog
195
+ /// "Fetch info" demo). No-op if the catalog is empty.
196
+ Future<void> Function()? onOpenCatalogItem;
197
+
198
+ // ── Screen-provided hooks (present only while that screen is mounted) ──── //
199
+
200
+ GraphAgentHooks? graph;
201
+ FeedAgentHooks? feed;
202
+ SearchAgentHooks? search;
203
+ ReaderAgentHooks? reader;
204
+
205
+ void attachGraph(GraphAgentHooks hooks) => graph = hooks;
206
+ void detachGraph(GraphAgentHooks hooks) {
207
+ if (identical(graph, hooks)) graph = null;
208
+ }
209
+
210
+ void attachFeed(FeedAgentHooks hooks) => feed = hooks;
211
+ void detachFeed(FeedAgentHooks hooks) {
212
+ if (identical(feed, hooks)) feed = null;
213
+ }
214
+
215
+ void attachSearch(SearchAgentHooks hooks) => search = hooks;
216
+ void detachSearch(SearchAgentHooks hooks) {
217
+ if (identical(search, hooks)) search = null;
218
+ }
219
+
220
+ void attachReader(ReaderAgentHooks hooks) => reader = hooks;
221
+ void detachReader(ReaderAgentHooks hooks) {
222
+ if (identical(reader, hooks)) reader = null;
223
+ }
224
+
225
+ // ── Spotlight target registry ─────────────────────────────────────────── //
226
+ //
227
+ // Screens tag the widget the agent acts on (search field, graph canvas, nav
228
+ // bar…) with a GlobalKey and register it here while mounted. The spotlight
229
+ // layer resolves live geometry from the key, so the guided-focus hole hugs
230
+ // the real widget instead of a guessed screen region.
231
+
232
+ final Map<String, GlobalKey> _spotlightKeys = {};
233
+
234
+ void registerSpotlight(String id, GlobalKey key) => _spotlightKeys[id] = key;
235
+
236
+ void unregisterSpotlight(String id, GlobalKey key) {
237
+ if (identical(_spotlightKeys[id], key)) _spotlightKeys.remove(id);
238
+ }
239
+
240
+ /// Current global-coordinate bounds of a registered target, or null while it
241
+ /// isn't mounted / laid out.
242
+ ///
243
+ /// A GlobalKey can still resolve a `currentContext` for an element that has
244
+ /// been deactivated (mid screen-swap during Present mode) β€” calling
245
+ /// `findRenderObject()` on it throws "Cannot get renderObject of inactive
246
+ /// element". Guard on `context.mounted` and catch defensively so a
247
+ /// transitioning target just reads as "not visible yet" instead of crashing
248
+ /// the frame.
249
+ Rect? spotlightRect(String id) {
250
+ final context = _spotlightKeys[id]?.currentContext;
251
+ if (context == null || !context.mounted) return null;
252
+ try {
253
+ final box = context.findRenderObject();
254
+ if (box is! RenderBox || !box.attached || !box.hasSize) return null;
255
+ return box.localToGlobal(Offset.zero) & box.size;
256
+ } catch (_) {
257
+ return null;
258
+ }
259
+ }
260
+
261
+ // ── Scrollable registry ───────────────────────────────────────────────── //
262
+ //
263
+ // Screens with a meaningful scroll surface (library grid, reader body)
264
+ // register their ScrollController here while mounted, so the agent can
265
+ // scroll the app the way a person would while presenting.
266
+
267
+ final Map<String, ScrollController> _scrollables = {};
268
+
269
+ void registerScrollable(String id, ScrollController controller) =>
270
+ _scrollables[id] = controller;
271
+
272
+ void unregisterScrollable(String id, ScrollController controller) {
273
+ if (identical(_scrollables[id], controller)) _scrollables.remove(id);
274
+ }
275
+
276
+ /// Smoothly scroll a registered surface by [dy] pixels (negative = up),
277
+ /// clamped to its extent. No-op while the surface isn't mounted.
278
+ Future<void> scrollBy(String id, double dy) async {
279
+ final c = _scrollables[id];
280
+ if (c == null || !c.hasClients) return;
281
+ final target =
282
+ (c.offset + dy).clamp(0.0, c.position.maxScrollExtent);
283
+ await c.animateTo(
284
+ target,
285
+ duration: const Duration(milliseconds: 900),
286
+ curve: Curves.easeInOutCubic,
287
+ );
288
+ }
289
+ }
app/lib/ui/features/presenter/presenter_controller.dart ADDED
@@ -0,0 +1,1102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Drives the in-app "Present" mode: a self-navigating, spoken tour that
2
+ /// actually *operates* Cachy live β€” creating a card, running a search, scrolling
3
+ /// the feed, driving the knowledge graph β€” while narrating. The audience (or the
4
+ /// presenter) can tap the agent glyph any time to ask a question or hand it a
5
+ /// task; the agent stops the tour, performs it for real, asks if anything else
6
+ /// is needed, and then resumes the tour where it left off.
7
+ ///
8
+ /// Speech uses the browser's Web Speech API via `flutter_tts` (free, any OS, no
9
+ /// install). Question answering runs server-side (`/presenter/ask`), which
10
+ /// returns a sequence of [AgentBeat]s (say + do) so answers are *performed*, not
11
+ /// just spoken. Everything the agent can do goes through the [AgentBus].
12
+ library;
13
+
14
+ import 'dart:async';
15
+
16
+ import 'package:flutter/foundation.dart';
17
+ import 'package:flutter_tts/flutter_tts.dart';
18
+
19
+ import '../../../data/repositories/card_repository.dart';
20
+ import '../../../domain/models/card.dart' as model;
21
+ import '../../../domain/models/concept.dart';
22
+ import '../../../domain/models/graph.dart';
23
+ import 'agent_bus.dart';
24
+
25
+ enum PresenterPhase { idle, speaking, thinking, acting, done }
26
+
27
+ /// The scripted tour. Each beat may switch [view], [say] a line, and/or run an
28
+ /// [action]; speech starts first and the action fires while the agent is still
29
+ /// talking, the way a human narrates over their own clicks. Lines are written
30
+ /// to be *spoken* β€” contractions, transitions, reactions β€” and avoid acronyms
31
+ /// so both the voice and the on-screen caption read naturally.
32
+ const List<AgentBeat> kTour = [
33
+ // ── 1. Home ──────────────────────────────────────────────────────────────
34
+ AgentBeat(
35
+ view: 'library',
36
+ say: "Hey! I'm Cachy's presenter, and instead of a slide deck I'll just "
37
+ "drive the app for real. This is live β€” tap me any time, ask anything, "
38
+ "and I'll show you.",
39
+ ),
40
+ AgentBeat(
41
+ focus: 'content',
42
+ say: "So this is home. Every reel, short, or article you share into Cachy "
43
+ "comes back as a knowledge card. Let me scroll down through the shelf "
44
+ "so you can see the collection... and back up to the top.",
45
+ action: AgentAction('scroll', {'target': 'library', 'dy': 720, 'sweep': true}),
46
+ ),
47
+
48
+ // ── 2. Open the first card β†’ the reader ──────────────────────────────────
49
+ AgentBeat(
50
+ focus: 'card.first',
51
+ say: "Let me open this first card so you can see what's actually inside "
52
+ "one.",
53
+ action: AgentAction('open_card', {'query': 'auto'}),
54
+ ),
55
+ AgentBeat(
56
+ focus: 'reader.blocks',
57
+ say: "This is the good part. From any source online, Cachy pulls out proper "
58
+ "structured notes β€” a one-liner, a summary, then clean blocks: steps, "
59
+ "key facts, callouts. Let me scroll through them.",
60
+ action: AgentAction('scroll', {'target': 'reader', 'dy': 640, 'sweep': true}),
61
+ ),
62
+ AgentBeat(
63
+ focus: 'reader.insight',
64
+ say: "Further down there's a 'Going deeper' section β€” that's the AI adding "
65
+ "the analysis the source only hinted at.",
66
+ action: AgentAction('point'),
67
+ ),
68
+ AgentBeat(
69
+ focus: 'reader.references',
70
+ say: "Then references β€” books, films, products the card mentions. Here's "
71
+ "the trick: press and hold any reference and it's saved straight to "
72
+ "your catalog.",
73
+ action: AgentAction('point'),
74
+ ),
75
+ AgentBeat(
76
+ focus: 'reader.ask',
77
+ say: "Down on the bottom strip: Ask lets you chat with just this card, and "
78
+ "next to it is whatever action fits the content β€” share it, turn it "
79
+ "into a shopping list, drop it on a map.",
80
+ action: AgentAction('point'),
81
+ ),
82
+ AgentBeat(
83
+ focus: 'reader.more',
84
+ say: "And the three dots hide the rest β€” copy, share, shopping list, and "
85
+ "more.",
86
+ action: AgentAction('point'),
87
+ ),
88
+ AgentBeat(
89
+ focus: 'reader.insight',
90
+ say: "One more from in here β€” 'Dive deeper' opens a rabbit hole, pulling a "
91
+ "thread from the card out into wider knowledge. Watch.",
92
+ action: AgentAction('rabbit_hole', {'query': 'auto'}),
93
+ ),
94
+
95
+ // ── 3. Concepts (top segment) ────────────────────────────────────────────
96
+ AgentBeat(
97
+ view: 'concepts',
98
+ focus: 'content',
99
+ say: "Back up top there are three segments. This one's Concepts β€” shared "
100
+ "ideas Cachy mines across your cards and stitches together. Let me "
101
+ "open one and define it on the spot.",
102
+ action: AgentAction('define_concept', {'query': 'auto'}),
103
+ ),
104
+
105
+ // ── 4. Catalog (top segment) ─────────────────────────────────────────────
106
+ AgentBeat(
107
+ view: 'catalog',
108
+ focus: 'catalog.item',
109
+ say: "Next segment: the catalog. Remember those references you can hold to "
110
+ "save? They land here. Tap one and the AI fills in everything it can "
111
+ "find about it.",
112
+ action: AgentAction('catalog_item'),
113
+ ),
114
+
115
+ // ── 5. Nav bar, one by one ───────────────────────────────────────────────
116
+ AgentBeat(
117
+ view: 'collections',
118
+ focus: 'folders.create',
119
+ say: "Now the bottom bar. Folders β€” your cards get auto-sorted in here, and "
120
+ "you can make your own. Let me create one.",
121
+ action: AgentAction('create_collection', {'name': 'Demo Folder'}),
122
+ ),
123
+ AgentBeat(
124
+ focus: 'content',
125
+ say: "...and drop a card into it. Done.",
126
+ action: AgentAction('move_card'),
127
+ ),
128
+ AgentBeat(
129
+ view: 'actions',
130
+ focus: 'todo.item',
131
+ say: "To-do. When you mark something as 'track in actions' inside a card, "
132
+ "its tasks show up here. Let me follow one in.",
133
+ action: AgentAction('follow_actions'),
134
+ ),
135
+ AgentBeat(
136
+ focus: 'todo.item',
137
+ say: "Tap an item and it expands to every task from that one note β€” and "
138
+ "Cachy keeps nudging you until they're done.",
139
+ action: AgentAction('toggle_action_item'),
140
+ ),
141
+ AgentBeat(
142
+ view: 'feed',
143
+ focus: 'feed.page',
144
+ say: "Feed. Think of it like your Instagram feed, but it's your own saved "
145
+ "knowledge β€” one-liners you can flick through. Let me scroll a few.",
146
+ action: AgentAction('feed_next'),
147
+ ),
148
+ AgentBeat(
149
+ focus: 'feed.page',
150
+ say: "Keep going, or shuffle for a fresh set.",
151
+ action: AgentAction('feed_shuffle'),
152
+ ),
153
+ AgentBeat(
154
+ view: 'profile',
155
+ focus: 'content',
156
+ say: "Last tab is You β€” settings and your library stats. Nothing fancy, "
157
+ "it's just home base.",
158
+ action: AgentAction('point'),
159
+ ),
160
+
161
+ // ── 6. Top icons: Graph, Chat ────────────────────────────────────────────
162
+ AgentBeat(
163
+ view: 'library',
164
+ say: "Back home for a second β€” up top there are two more.",
165
+ ),
166
+ AgentBeat(
167
+ focus: 'top.graph',
168
+ say: "The graph. Every card and concept is a node, linked by meaning. Watch "
169
+ "the connections β€” and let me pull into one node's neighborhood.",
170
+ action: AgentAction('graph_wander'),
171
+ ),
172
+ AgentBeat(
173
+ focus: 'graph.canvas',
174
+ action: AgentAction('graph_focus', {'query': 'auto'}),
175
+ ),
176
+ AgentBeat(
177
+ view: 'library',
178
+ say: "And the chat.",
179
+ ),
180
+ AgentBeat(
181
+ focus: 'top.chat',
182
+ say: "This one talks to your whole library at once β€” ask anything across "
183
+ "everything you've saved and it answers from your own notes.",
184
+ action: AgentAction('library_chat', {
185
+ 'query': 'What are the main themes across everything saved here?',
186
+ }),
187
+ ),
188
+
189
+ // ── 7. Adding something to Cachy ─────────────────────────────────────────
190
+ AgentBeat(
191
+ view: 'library',
192
+ say: "Last thing β€” let's add something new, live.",
193
+ ),
194
+ AgentBeat(
195
+ focus: 'home.plus',
196
+ say: "I'll hit the plus button, paste in a link, and Cachy runs the whole "
197
+ "pipeline β€” fetching the page, pulling the text, and shaping it into a "
198
+ "card while we watch.",
199
+ action: AgentAction('create_card', {
200
+ 'url': 'https://en.wikipedia.org/wiki/Spaced_repetition',
201
+ }),
202
+ ),
203
+ AgentBeat(
204
+ focus: 'reader.blocks',
205
+ say: "And there it is β€” a brand-new card, built from nothing. That's Cachy. "
206
+ "I'm not going anywhere though β€” tap me, ask me anything, or hand me a "
207
+ "task.",
208
+ action: AgentAction('scroll', {'target': 'reader', 'dy': 560, 'sweep': true}),
209
+ ),
210
+ ];
211
+
212
+ class PresenterController extends ChangeNotifier {
213
+ PresenterController({required CardRepository repository, required AgentBus bus})
214
+ : _repo = repository,
215
+ _bus = bus {
216
+ _initTts();
217
+ }
218
+
219
+ final CardRepository _repo;
220
+ final AgentBus _bus;
221
+ final FlutterTts _tts = FlutterTts();
222
+
223
+ PresenterPhase phase = PresenterPhase.idle;
224
+
225
+ /// The line currently being spoken / last spoken β€” shown as an on-screen
226
+ /// caption beside the glyph.
227
+ String caption = '';
228
+
229
+ /// Spotlight target the agent is currently presenting: a widget id
230
+ /// registered on the [AgentBus] (e.g. 'search.field', 'graph.canvas'),
231
+ /// a pseudo-region ('content', 'nav') the spotlight resolves to a coarse
232
+ /// rect, or null for no spotlight. Set for the *whole* beat β€” narration and
233
+ /// action β€” so the light behaves like a tour guide's pointer, gliding from
234
+ /// target to target instead of flashing per action.
235
+ String? focusId;
236
+
237
+ /// True for the brief moment the agent "taps" the focused target β€” the
238
+ /// overlay renders a tap ripple on the cursor. Every screen change / effect
239
+ /// is preceded by one of these so the audience sees the click that caused it.
240
+ bool tapping = false;
241
+
242
+ bool _active = false;
243
+ int _tourIndex = 0;
244
+ final List<String> _questionQueue = [];
245
+
246
+ // Cached library snapshot for resolving fuzzy targets ("open the coffee card").
247
+ List<model.Card>? _cards;
248
+ List<ConceptEntry>? _conceptCache;
249
+
250
+ // The card the agent is currently demonstrating (opened/created), so reader,
251
+ // chat, and rabbit-hole actions act on a coherent subject.
252
+ String? _currentCardId;
253
+
254
+ // The collection the agent most recently created, so it can move a card into
255
+ // it on the next beat.
256
+ String? _lastCollectionId;
257
+
258
+ // A generation token distinguishes a natural utterance end from one we cut
259
+ // short (to handle a barge-in). Each utterance bumps it; the awaiter checks
260
+ // whether its token still matches when the completer resolves.
261
+ int _gen = 0;
262
+ Completer<void>? _speakCompleter;
263
+
264
+ bool get isActive => _active;
265
+
266
+ bool _voicePicked = false;
267
+
268
+ Future<void> _initTts() async {
269
+ _tts.setCompletionHandler(_finishUtterance);
270
+ _tts.setCancelHandler(_finishUtterance);
271
+ _tts.setErrorHandler((_) => _finishUtterance());
272
+ try {
273
+ // Rate scale differs by engine: the browser's Web Speech API treats 1.0
274
+ // as normal speed (0.5 is half-speed β€” that's what felt sluggish), while
275
+ // the native mobile engines treat ~0.5 as normal.
276
+ await _tts.setSpeechRate(kIsWeb ? 1.0 : 0.52);
277
+ await _tts.setPitch(1.0);
278
+ await _tts.setVolume(1.0);
279
+ await _selectNaturalVoice();
280
+ } catch (e, st) {
281
+ // Voice params are best-effort; speech still works with defaults.
282
+ _logError('TTS init', e, st);
283
+ }
284
+ }
285
+
286
+ /// Pick the most natural-sounding English voice the platform offers, instead
287
+ /// of the robotic default. Web voices load asynchronously, so this is retried
288
+ /// before the first utterance (see [_speak]).
289
+ Future<void> _selectNaturalVoice() async {
290
+ try {
291
+ final raw = await _tts.getVoices;
292
+ if (raw is! List) return;
293
+ final voices = raw
294
+ .whereType<Map>()
295
+ .map((v) => v.map((k, val) => MapEntry('$k', '$val')))
296
+ .toList();
297
+ if (voices.isEmpty) return; // not loaded yet β€” try again next utterance
298
+ final english = voices
299
+ .where((v) => (v['locale'] ?? '').toLowerCase().startsWith('en'))
300
+ .toList();
301
+ final pool = english.isNotEmpty ? english : voices;
302
+
303
+ int score(Map<String, String> v) {
304
+ final name = (v['name'] ?? '').toLowerCase();
305
+ if (name.contains('natural')) return 6; // Edge/Azure "… Natural"
306
+ if (name.contains('google')) return 5; // Chrome's higher-quality set
307
+ if (name.contains('samantha') || name.contains('ava')) return 4; // Apple
308
+ if (name.contains('aria') || name.contains('jenny')) return 4; // MS
309
+ if (name.contains('female')) return 2;
310
+ return 1;
311
+ }
312
+
313
+ pool.sort((a, b) => score(b).compareTo(score(a)));
314
+ final best = pool.first;
315
+ await _tts.setVoice({
316
+ 'name': best['name'] ?? '',
317
+ 'locale': best['locale'] ?? 'en-US',
318
+ });
319
+ _voicePicked = true;
320
+ } catch (e, st) {
321
+ // Default voice is fine if selection isn't supported.
322
+ _logError('voice selection', e, st);
323
+ }
324
+ }
325
+
326
+ void _setPhase(PresenterPhase p, {String? caption}) {
327
+ phase = p;
328
+ if (caption != null) this.caption = caption;
329
+ notifyListeners();
330
+ }
331
+
332
+ /// Log a presenter error into the backend's pipeline console (`start.py`'s
333
+ /// terminal) β€” errors during Present mode are largely swallowed so one bad
334
+ /// action/network call doesn't kill a live demo, but that means they'd
335
+ /// otherwise vanish silently. Relayed to `/presenter/log` because
336
+ /// `debugPrint` alone depends on the browser's DWDS forwarding actually
337
+ /// reaching the terminal, which isn't reliable; also mirrored to
338
+ /// `debugPrint` for anyone watching the browser console instead.
339
+ void _logError(String where, Object error, [StackTrace? st]) {
340
+ final line = '$where failed: $error';
341
+ debugPrint('[Presenter] $line');
342
+ if (st != null) debugPrintStack(stackTrace: st, label: '[Presenter] $where');
343
+ _repo.api.presenterLog(line, level: 'error');
344
+ }
345
+
346
+ /// Trace an action the agent is about to take (navigate, run a search,
347
+ /// scroll, open a card…) into the same pipeline console β€” so the log reads
348
+ /// as a live transcript of the demo, not just a silence broken by errors.
349
+ void _logInfo(String message) {
350
+ debugPrint('[Presenter] $message');
351
+ _repo.api.presenterLog(message);
352
+ }
353
+
354
+ /// A component the agent expected to act on never showed up in time (screen
355
+ /// didn't mount, data never loaded) β€” the action just gets skipped rather
356
+ /// than throwing, so this is the only trace that it happened.
357
+ void _logWarn(String message) {
358
+ debugPrint('[Presenter] $message');
359
+ _repo.api.presenterLog(message, level: 'warning');
360
+ }
361
+
362
+ /// Run [fn], logging and swallowing any error under [label] instead of
363
+ /// letting it escape β€” used to tag individual screen-hook calls (graph,
364
+ /// feed, search, reader, the shell's onOpen* callbacks) so a failure names
365
+ /// exactly which hook broke, not just the outer action type.
366
+ Future<void> _try(String label, FutureOr<void> Function() fn) async {
367
+ try {
368
+ await fn();
369
+ } catch (e, st) {
370
+ _logError(label, e, st);
371
+ }
372
+ }
373
+
374
+ void _finishUtterance() {
375
+ final c = _speakCompleter;
376
+ if (c != null && !c.isCompleted) c.complete();
377
+ }
378
+
379
+ /// Speak [text]; resolve true if it finished naturally, false if superseded by
380
+ /// a barge-in.
381
+ Future<bool> _speak(String text) async {
382
+ if (text.trim().isEmpty) return true;
383
+ if (!_voicePicked) await _selectNaturalVoice(); // web voices load late
384
+ final myGen = ++_gen;
385
+ _setPhase(PresenterPhase.speaking, caption: text);
386
+ final completer = Completer<void>();
387
+ _speakCompleter = completer;
388
+ try {
389
+ await _tts.speak(text);
390
+ } catch (e, st) {
391
+ // Web without a user gesture, or an unavailable engine, can throw here β€”
392
+ // don't let it break the beat; treat the utterance as finished.
393
+ _logError('tts.speak', e, st);
394
+ _finishUtterance();
395
+ }
396
+ // Watchdog: on web the browser sometimes never fires the "end" event, which
397
+ // would hang the whole tour on this completer forever. Cap the wait at a
398
+ // generous estimate of how long the line takes to read so the beat always
399
+ // advances.
400
+ final words = text.trim().split(RegExp(r'\s+')).length;
401
+ final capMs = (words * 420 + 2500).clamp(4000, 30000);
402
+ await Future.any<void>([
403
+ completer.future,
404
+ Future<void>.delayed(Duration(milliseconds: capMs)),
405
+ ]);
406
+ return _gen == myGen && _active;
407
+ }
408
+
409
+ Future<void> _stopSpeaking() async {
410
+ _gen++; // supersede whatever is (or was) speaking
411
+ await _tts.stop();
412
+ _finishUtterance();
413
+ }
414
+
415
+ // ── Public control ──────────────────────────────────────────────────────── //
416
+
417
+ /// Begin the tour from the top.
418
+ Future<void> start() async {
419
+ if (_active) return;
420
+ _active = true;
421
+ _tourIndex = 0;
422
+ // A heartbeat the moment Present mode loads β€” confirms the log relay is
423
+ // wired before any error would need it.
424
+ _repo.api.presenterLog('Present mode started');
425
+ unawaited(_warmCards());
426
+ await _runTour();
427
+ }
428
+
429
+ /// The audience/presenter submitted a question or task. Interrupts the tour,
430
+ /// gets handled, then the tour resumes.
431
+ Future<void> ask(String question) async {
432
+ final q = question.trim();
433
+ if (q.isEmpty || !_active) return;
434
+ _questionQueue.add(q);
435
+ if (phase == PresenterPhase.speaking) {
436
+ await _stopSpeaking(); // tour/answer loop detects the barge-in and drains
437
+ } else if (phase == PresenterPhase.idle) {
438
+ await _handleQuestions();
439
+ if (_active) _setPhase(PresenterPhase.idle, caption: _idlePrompt);
440
+ }
441
+ // If acting/thinking, the current loop drains the queue when it next checks.
442
+ }
443
+
444
+ /// End the presentation and silence any speech.
445
+ Future<void> stop() async {
446
+ _active = false;
447
+ _questionQueue.clear();
448
+ await _stopSpeaking();
449
+ _setFocus(null);
450
+ _setPhase(PresenterPhase.done);
451
+ _repo.api.presenterLog('Present mode ended');
452
+ }
453
+
454
+ // ── Tour loop ─────────────────────────────────────────────────────────── //
455
+
456
+ static const String _idlePrompt = 'Ask me anything, or tap to wrap up.';
457
+
458
+ Future<void> _runTour() async {
459
+ while (_active && _tourIndex < kTour.length) {
460
+ final completed = await _runBeat(kTour[_tourIndex]);
461
+ if (!_active) return;
462
+ if (!completed || _questionQueue.isNotEmpty) {
463
+ await _handleQuestions();
464
+ } else {
465
+ // A short breath between beats so the tour doesn't feel read off a
466
+ // teleprompter.
467
+ await _settle(ms: 450);
468
+ }
469
+ _tourIndex++;
470
+ }
471
+ if (!_active) return;
472
+ _setFocus(null);
473
+ _setPhase(PresenterPhase.idle, caption: _idlePrompt);
474
+ // Idle floor: any later question is handled by ask().
475
+ }
476
+
477
+ /// Answer queued questions/tasks by performing them, then offer more before
478
+ /// returning to the tour's natural flow.
479
+ Future<void> _handleQuestions() async {
480
+ while (_active && _questionQueue.isNotEmpty) {
481
+ final q = _questionQueue.removeAt(0);
482
+ _setFocus(null);
483
+ _setPhase(PresenterPhase.thinking, caption: 'Thinking…');
484
+ List<AgentBeat> beats;
485
+ try {
486
+ final raw = await _repo.api.presenterAsk(q);
487
+ beats = raw.map(AgentBeat.fromJson).toList();
488
+ } catch (e, st) {
489
+ _logError('ask("$q")', e, st);
490
+ beats = const [
491
+ AgentBeat(say: "I couldn't reach the backend to answer that."),
492
+ ];
493
+ }
494
+ if (!_active) return;
495
+ for (final beat in beats) {
496
+ final completed = await _runBeat(beat);
497
+ if (!_active) return;
498
+ if (!completed) break; // a fresh barge-in β€” loop drains it next
499
+ }
500
+ if (!_active) return;
501
+ if (_questionQueue.isEmpty) {
502
+ // Offer the floor; if they stay quiet we resume the tour.
503
+ await _runBeat(const AgentBeat(
504
+ say: "Anything else? Otherwise I'll carry on.",
505
+ ));
506
+ }
507
+ }
508
+ }
509
+
510
+ /// Run one beat: switch view, light the spotlight target, then speak *while*
511
+ /// performing the action β€” narration leads, the "hands" follow half a second
512
+ /// later, like a human demoing. Returns false if speech was interrupted by a
513
+ /// barge-in (so the caller can drain questions).
514
+ Future<bool> _runBeat(AgentBeat beat) async {
515
+ if (!_active) return false;
516
+ // Navigation is itself a visible tap on the nav control that reaches it,
517
+ // so a screen change never appears out of nowhere.
518
+ if (beat.view != null) {
519
+ await _gesture(_navTargetFor(beat.view!), () async {
520
+ _logInfo('navigate β†’ ${beat.view}');
521
+ _bus.onNavigate?.call(beat.view!);
522
+ await _settle();
523
+ });
524
+ if (!_active) return false;
525
+ }
526
+ // Light the target the narration is about, so the cursor glides onto it
527
+ // while the agent talks.
528
+ _setFocus(beat.focus ??
529
+ (beat.action != null ? _targetFor(beat.action!.type) : null));
530
+ Future<bool>? speech;
531
+ if (beat.say != null && beat.say!.isNotEmpty) {
532
+ speech = _speak(beat.say!);
533
+ }
534
+ if (beat.action != null) {
535
+ if (speech != null) {
536
+ // Let the narration lead before the hand moves.
537
+ await Future<void>.delayed(const Duration(milliseconds: 550));
538
+ } else {
539
+ _setPhase(PresenterPhase.acting);
540
+ }
541
+ if (_active) {
542
+ // Tap the lit target, then fire the effect β€” the click precedes the
543
+ // change the audience sees.
544
+ await _tap();
545
+ try {
546
+ await _execute(beat.action!);
547
+ } catch (e, st) {
548
+ // A live action failing shouldn't kill the presentation.
549
+ _logError('action "${beat.action!.type}"', e, st);
550
+ }
551
+ }
552
+ }
553
+ final spoken = speech == null || await speech;
554
+ if (!spoken) return false;
555
+ return _active && _questionQueue.isEmpty;
556
+ }
557
+
558
+ void _setFocus(String? id) {
559
+ if (focusId == id) return;
560
+ focusId = id;
561
+ tapping = false; // moving to a new target cancels any in-flight tap
562
+ notifyListeners();
563
+ }
564
+
565
+ void _setTapping(bool v) {
566
+ if (tapping == v) return;
567
+ tapping = v;
568
+ notifyListeners();
569
+ }
570
+
571
+ /// A visible "click" on the currently-focused target: pulse the tap ripple,
572
+ /// hold briefly, then release. Callers fire the real effect right after so
573
+ /// the audience sees the tap that caused the change. No-op if nothing's lit.
574
+ Future<void> _tap() async {
575
+ if (focusId == null || !_active) return;
576
+ _setTapping(true);
577
+ await _settle(ms: 220);
578
+ _setTapping(false);
579
+ }
580
+
581
+ /// Glide the cursor to [targetId], tap it, then run [effect] β€” the full
582
+ /// point-tap-act gesture used for navigation, so a screen change reads as a
583
+ /// tap on the nav control that triggered it rather than a magic swap.
584
+ Future<void> _gesture(String? targetId, FutureOr<void> Function() effect) async {
585
+ _setFocus(targetId);
586
+ await _settle(ms: 620); // let the cursor glide onto the target
587
+ if (!_active) return;
588
+ await _tap();
589
+ if (!_active) return;
590
+ await effect();
591
+ }
592
+
593
+ /// The nav control the agent taps to reach [view]: a bottom-nav tab, one of
594
+ /// the library's top segments (Concepts/Catalog), or a top-bar icon
595
+ /// (Graph/Chat/Search). Falls back to the coarse 'nav' region.
596
+ String _navTargetFor(String view) => switch (view) {
597
+ 'library' => 'nav.home',
598
+ 'collections' => 'nav.folders',
599
+ 'actions' => 'nav.todo',
600
+ 'feed' => 'nav.feed',
601
+ 'profile' => 'nav.you',
602
+ 'concepts' => 'library.tab.concepts',
603
+ 'catalog' => 'library.tab.catalog',
604
+ 'graph' => 'top.graph',
605
+ 'search' => 'top.search',
606
+ 'connections' || 'chat' => 'top.chat',
607
+ _ => 'nav',
608
+ };
609
+
610
+ /// Map an action to the spotlight target it visibly affects, so the light
611
+ /// guides the audience to the right widget (with coarse fallbacks).
612
+ String? _targetFor(String type) => switch (type) {
613
+ 'search' => 'search.field',
614
+ 'search_filter' => 'search.filters',
615
+ 'navigate' => 'nav',
616
+ 'graph_focus' ||
617
+ 'graph_open' ||
618
+ 'graph_wander' ||
619
+ 'graph_reset' ||
620
+ 'graph_cluster' ||
621
+ 'graph_concepts' =>
622
+ 'graph.canvas',
623
+ 'feed_next' || 'feed_prev' || 'feed_shuffle' => 'feed.page',
624
+ 'open_card' => 'card.first',
625
+ 'create_card' => 'home.plus',
626
+ 'reader_toggle' => 'reader.blocks',
627
+ 'rabbit_hole' => 'reader.insight',
628
+ 'card_chat' => 'reader.ask',
629
+ 'catalog_item' => 'catalog.item',
630
+ 'create_collection' => 'folders.create',
631
+ 'follow_actions' || 'toggle_action_item' => 'todo.item',
632
+ 'library_chat' => 'top.chat',
633
+ // 'point' and 'scroll' rely on the beat's explicit focus.
634
+ 'point' || 'scroll' || 'wait' => null,
635
+ _ => 'content',
636
+ };
637
+
638
+ // ── Action executor ───────────────────────────────────────────────────── //
639
+
640
+ Future<void> _execute(AgentAction a) async {
641
+ _logInfo('action β†’ ${a.type}${a.args.isEmpty ? '' : ' ${a.args}'}');
642
+ switch (a.type) {
643
+ case 'navigate':
644
+ if (a.view != null) {
645
+ _bus.onNavigate?.call(a.view!);
646
+ await _settle();
647
+ }
648
+ case 'wait':
649
+ final ms = (a.args['ms'] as num?)?.toInt() ?? 800;
650
+ await Future<void>.delayed(Duration(milliseconds: ms));
651
+ case 'scroll':
652
+ await _scrollSurface(a);
653
+ case 'create_card':
654
+ final url = a.url;
655
+ if (url != null && url.isNotEmpty) {
656
+ final id = await _bus.onCreateCard?.call(url);
657
+ if (id != null && id.isNotEmpty) _currentCardId = id;
658
+ await _settle(ms: 600);
659
+ }
660
+ case 'search':
661
+ await _runSearch(a.query);
662
+ case 'search_filter':
663
+ await _searchFilter();
664
+ case 'open_card':
665
+ await _openCard(a.query);
666
+ case 'reader_toggle':
667
+ await _readerToggle();
668
+ case 'graph_focus':
669
+ await _graphFocus(a.query, open: false);
670
+ case 'graph_open':
671
+ await _graphFocus(a.query, open: true);
672
+ case 'graph_wander':
673
+ await _graphWander();
674
+ case 'graph_reset':
675
+ await _ensureGraph();
676
+ await _try('graph.reset', () => _bus.graph?.reset());
677
+ await _settle(ms: 700);
678
+ case 'graph_cluster':
679
+ await _ensureGraph();
680
+ await _try('graph.filterCluster', () => _bus.graph?.filterCluster());
681
+ case 'graph_concepts':
682
+ await _ensureGraph();
683
+ await _try('graph.toggleConcepts', () => _bus.graph?.toggleConcepts());
684
+ case 'feed_next':
685
+ await _feedStep(forward: true);
686
+ case 'feed_prev':
687
+ await _feedStep(forward: false);
688
+ case 'feed_shuffle':
689
+ await _feedShuffle();
690
+ case 'open_concept':
691
+ case 'define_concept':
692
+ await _demoConcept(a.query);
693
+ case 'create_collection':
694
+ await _createCollection(a.args['name'] as String?);
695
+ case 'move_card':
696
+ await _moveCard();
697
+ case 'follow_actions':
698
+ await _followActions();
699
+ case 'toggle_action_item':
700
+ await _toggleActionItem();
701
+ case 'card_chat':
702
+ await _cardChat(a.query);
703
+ case 'library_chat':
704
+ await _libraryChat(a.query);
705
+ case 'rabbit_hole':
706
+ await _rabbitHole(a.query);
707
+ case 'open_connections':
708
+ _bus.onNavigate?.call('connections');
709
+ await _settle(ms: 700);
710
+ case 'catalog_item':
711
+ await _try('onOpenCatalogItem', () => _bus.onOpenCatalogItem?.call());
712
+ await _settle(ms: 900);
713
+ case 'point':
714
+ // Explain-only beat: the tap pulse already fired in _runBeat; nothing
715
+ // to execute. The lit target is what the audience is looking at.
716
+ break;
717
+ default:
718
+ break;
719
+ }
720
+ }
721
+
722
+ /// Scroll a registered surface ('library' grid, 'reader' body) by `dy`
723
+ /// pixels; with `sweep: true` it glides down, pauses, and glides back β€” the
724
+ /// browsing gesture a human makes while talking over a screen.
725
+ Future<void> _scrollSurface(AgentAction a) async {
726
+ final target = (a.args['target'] as String?) ?? 'library';
727
+ final dy = (a.args['dy'] as num?)?.toDouble() ?? 600;
728
+ await _try('scroll($target)', () => _bus.scrollBy(target, dy));
729
+ if (a.args['sweep'] == true) {
730
+ await _settle(ms: 600);
731
+ await _try('scroll($target, back)', () => _bus.scrollBy(target, -dy));
732
+ }
733
+ }
734
+
735
+ Future<void> _runSearch(String? query) async {
736
+ _bus.onNavigate?.call('search');
737
+ final ready =
738
+ await _waitFor(() => _bus.search != null, what: 'search screen', timeoutMs: 4000);
739
+ if (!ready) return;
740
+ final q = _resolveSearchQuery(query);
741
+ await _try('search.run("$q")', () => _bus.search?.run(q));
742
+ await _settle(ms: 900);
743
+ }
744
+
745
+ Future<void> _searchFilter() async {
746
+ _bus.onNavigate?.call('search');
747
+ final ready =
748
+ await _waitFor(() => _bus.search != null, what: 'search screen', timeoutMs: 4000);
749
+ if (!ready) return;
750
+ await _try('search.filter', () => _bus.search?.filter());
751
+ await _settle(ms: 700);
752
+ }
753
+
754
+ Future<void> _openCard(String? query) async {
755
+ final cards = await _warmCards();
756
+ final match = _bestCard(query, cards);
757
+ if (match != null) {
758
+ _currentCardId = match.cardId;
759
+ await _try('onOpenCard(${match.cardId})',
760
+ () => _bus.onOpenCard?.call(match.cardId));
761
+ await _settle(ms: 500);
762
+ }
763
+ }
764
+
765
+ Future<void> _readerToggle() async {
766
+ final card = await _cardForDemo();
767
+ if (card == null) return;
768
+ _currentCardId = card.cardId;
769
+ await _try(
770
+ 'onOpenCard(${card.cardId})', () => _bus.onOpenCard?.call(card.cardId));
771
+ final ready =
772
+ await _waitFor(() => _bus.reader?.isReady() == true, what: 'reader');
773
+ final r = _bus.reader;
774
+ // No nested _speak here: actions may now run concurrently with narration,
775
+ // and a second utterance would orphan the first one's completer.
776
+ if (!ready || r == null) return;
777
+ var did = false;
778
+ await _try('reader.toggleCheck', () async => did = await r.toggleCheck());
779
+ if (!did) await _try('reader.toggleStep', () => r.toggleStep());
780
+ await _settle(ms: 600);
781
+ }
782
+
783
+ Future<void> _graphFocus(String? query, {required bool open}) async {
784
+ await _ensureGraph();
785
+ final g = _bus.graph;
786
+ if (g == null) return;
787
+ final nodes = g.nodes();
788
+ if (nodes.isEmpty) return;
789
+ final target = query == null || query.isEmpty || query == 'auto'
790
+ ? _mostConnected(nodes)
791
+ : _bestNode(query, nodes);
792
+ if (target == null) return;
793
+ if (open) {
794
+ if (target.isCard) _currentCardId = target.id;
795
+ await _try('graph.open(${target.id})', () => g.open(target.id));
796
+ } else {
797
+ await _try('graph.focus(${target.id})', () => g.focus(target.id));
798
+ }
799
+ await _settle(ms: 400);
800
+ }
801
+
802
+ Future<void> _graphWander() async {
803
+ await _ensureGraph();
804
+ await _try('graph.wander', () => _bus.graph?.wander());
805
+ }
806
+
807
+ Future<void> _feedStep({required bool forward}) async {
808
+ _bus.onNavigate?.call('feed');
809
+ final ready =
810
+ await _waitFor(() => _bus.feed != null, what: 'feed screen', timeoutMs: 4000);
811
+ final f = _bus.feed;
812
+ if (!ready || f == null) return;
813
+ await _try(forward ? 'feed.next' : 'feed.prev',
814
+ () => forward ? f.next() : f.prev());
815
+ await _settle(ms: 900);
816
+ }
817
+
818
+ Future<void> _feedShuffle() async {
819
+ _bus.onNavigate?.call('feed');
820
+ final ready =
821
+ await _waitFor(() => _bus.feed != null, what: 'feed screen', timeoutMs: 4000);
822
+ if (!ready) return;
823
+ await _try('feed.shuffle', () => _bus.feed?.shuffle());
824
+ await _settle(ms: 1200);
825
+ }
826
+
827
+ Future<void> _ensureGraph() async {
828
+ _bus.onNavigate?.call('graph');
829
+ await _waitFor(() => _bus.graph?.isReady() == true,
830
+ what: 'graph screen', timeoutMs: 9000);
831
+ }
832
+
833
+ Future<void> _demoConcept(String? query) async {
834
+ final concepts = await _warmConcepts();
835
+ if (concepts.isEmpty) return; // nothing mined yet β€” skip quietly
836
+ final match = (query == null || query.isEmpty || query == 'auto')
837
+ ? concepts.first
838
+ : (_bestBy(query, concepts, (c) => c.name) ?? concepts.first);
839
+ try {
840
+ await _repo.defineConcept(match.id);
841
+ } catch (e, st) {
842
+ // best-effort; the detail screen still opens and can define on demand.
843
+ _logError('defineConcept(${match.id})', e, st);
844
+ }
845
+ await _try('onOpenConcept(${match.id})',
846
+ () => _bus.onOpenConcept?.call(match.id, match.name));
847
+ await _settle(ms: 700);
848
+ }
849
+
850
+ Future<void> _createCollection(String? name) async {
851
+ final n = (name == null || name.isEmpty) ? 'Demo Folder' : name;
852
+ try {
853
+ final col = await _repo.createCollection(n);
854
+ _lastCollectionId = col.id;
855
+ } catch (e, st) {
856
+ _logError('createCollection("$n")', e, st);
857
+ }
858
+ _bus.onNavigate?.call('collections');
859
+ await _settle(ms: 900);
860
+ }
861
+
862
+ Future<void> _moveCard() async {
863
+ final card = await _cardForDemo();
864
+ if (card == null) return;
865
+ var colId = _lastCollectionId;
866
+ if (colId == null) {
867
+ try {
868
+ final col = await _repo.createCollection('Demo Folder');
869
+ colId = col.id;
870
+ _lastCollectionId = colId;
871
+ } catch (e, st) {
872
+ _logError('createCollection (for move)', e, st);
873
+ return;
874
+ }
875
+ }
876
+ try {
877
+ await _repo.moveCardToCollection(card.cardId, colId);
878
+ } catch (e, st) {
879
+ _logError('moveCardToCollection(${card.cardId})', e, st);
880
+ }
881
+ _bus.onNavigate?.call('collections');
882
+ await _settle(ms: 900);
883
+ }
884
+
885
+ Future<void> _followActions() async {
886
+ final card = await _cardWithActions();
887
+ if (card == null || card.actionItems.items.isEmpty) {
888
+ // Nothing to follow β€” still show the hub, but don't nest a _speak while
889
+ // the beat's narration may be running.
890
+ _bus.onNavigate?.call('actions');
891
+ await _settle(ms: 700);
892
+ return;
893
+ }
894
+ _currentCardId = card.cardId;
895
+ try {
896
+ await _repo.patchActionItems(
897
+ card.cardId,
898
+ model.ActionItems(followed: true, items: card.actionItems.items).toJson(),
899
+ );
900
+ } catch (e, st) {
901
+ _logError('patchActionItems (follow, ${card.cardId})', e, st);
902
+ }
903
+ _bus.onNavigate?.call('actions');
904
+ await _settle(ms: 900);
905
+ }
906
+
907
+ Future<void> _toggleActionItem() async {
908
+ final card = await _cardWithActions();
909
+ if (card == null || card.actionItems.items.isEmpty) return;
910
+ final first = card.actionItems.items.first;
911
+ final updated = model.ActionItems(
912
+ followed: true,
913
+ items: [
914
+ for (final it in card.actionItems.items)
915
+ it.id == first.id
916
+ ? model.ActionItem(id: it.id, text: it.text, done: !it.done)
917
+ : it,
918
+ ],
919
+ );
920
+ try {
921
+ await _repo.patchActionItems(card.cardId, updated.toJson());
922
+ } catch (e, st) {
923
+ _logError('patchActionItems (toggle, ${card.cardId})', e, st);
924
+ }
925
+ _bus.onNavigate?.call('actions');
926
+ await _settle(ms: 800);
927
+ }
928
+
929
+ Future<void> _cardChat(String? query) async {
930
+ final card = await _cardForDemo();
931
+ if (card == null) return;
932
+ final seed = (query == null || query.isEmpty)
933
+ ? 'What is the single most useful takeaway here?'
934
+ : query;
935
+ final title =
936
+ card.base.oneLiner.isNotEmpty ? card.base.oneLiner : 'This card';
937
+ await _try('onOpenCardChat(${card.cardId})',
938
+ () => _bus.onOpenCardChat?.call(card.cardId, title, seed));
939
+ await _settle(ms: 1000);
940
+ }
941
+
942
+ Future<void> _libraryChat(String? query) async {
943
+ final seed = (query == null || query.isEmpty)
944
+ ? 'What are the main themes across everything saved here?'
945
+ : query;
946
+ await _try('onOpenLibraryChat', () => _bus.onOpenLibraryChat?.call(seed));
947
+ await _settle(ms: 1000);
948
+ }
949
+
950
+ Future<void> _rabbitHole(String? query) async {
951
+ final card = await _cardForDemo();
952
+ if (card == null) return;
953
+ final seed = (query == null || query.isEmpty || query == 'auto')
954
+ ? (card.base.oneLiner.isNotEmpty ? card.base.oneLiner : 'Tell me more')
955
+ : query;
956
+ await _try('onOpenRabbitHole(${card.cardId})',
957
+ () => _bus.onOpenRabbitHole?.call(card.cardId, seed));
958
+ await _settle(ms: 1000);
959
+ }
960
+
961
+ // ── Target resolution ─────────────────────────────────────────────────── //
962
+
963
+ Future<List<model.Card>> _warmCards() async {
964
+ if (_cards != null) return _cards!;
965
+ try {
966
+ _cards = await _repo.list();
967
+ } catch (e, st) {
968
+ _logError('warmCards (repo.list)', e, st);
969
+ _cards = const [];
970
+ }
971
+ return _cards!;
972
+ }
973
+
974
+ Future<List<ConceptEntry>> _warmConcepts() async {
975
+ if (_conceptCache != null) return _conceptCache!;
976
+ try {
977
+ _conceptCache = await _repo.concepts();
978
+ } catch (e, st) {
979
+ _logError('warmConcepts (repo.concepts)', e, st);
980
+ _conceptCache = const [];
981
+ }
982
+ return _conceptCache!;
983
+ }
984
+
985
+ /// The card the agent should demonstrate: the one it's currently on, else the
986
+ /// first in the library.
987
+ Future<model.Card?> _cardForDemo() async {
988
+ final cards = await _warmCards();
989
+ if (cards.isEmpty) return null;
990
+ if (_currentCardId != null) {
991
+ for (final c in cards) {
992
+ if (c.cardId == _currentCardId) return c;
993
+ }
994
+ }
995
+ return cards.first;
996
+ }
997
+
998
+ /// The first card that actually has to-do items (for the Actions demo), else
999
+ /// any card.
1000
+ Future<model.Card?> _cardWithActions() async {
1001
+ final cards = await _warmCards();
1002
+ for (final c in cards) {
1003
+ if (c.actionItems.items.isNotEmpty) return c;
1004
+ }
1005
+ return cards.isEmpty ? null : cards.first;
1006
+ }
1007
+
1008
+ String _resolveSearchQuery(String? query) {
1009
+ if (query != null && query.isNotEmpty && query != 'auto') return query;
1010
+ final cards = _cards ?? const [];
1011
+ for (final c in cards) {
1012
+ if (c.base.tags.isNotEmpty) return c.base.tags.first;
1013
+ }
1014
+ if (cards.isNotEmpty) {
1015
+ final words = cards.first.base.oneLiner.split(RegExp(r'\s+'));
1016
+ final word = words.firstWhere((w) => w.length > 4, orElse: () => 'idea');
1017
+ return word.replaceAll(RegExp(r'[^A-Za-z]'), '');
1018
+ }
1019
+ return 'idea';
1020
+ }
1021
+
1022
+ model.Card? _bestCard(String? query, List<model.Card> cards) {
1023
+ if (cards.isEmpty) return null;
1024
+ if (query == null || query.isEmpty) return cards.first;
1025
+ String label(model.Card c) =>
1026
+ '${c.base.oneLiner} ${c.source.caption} ${c.base.tags.join(' ')}';
1027
+ return _bestBy(query, cards, label) ?? cards.first;
1028
+ }
1029
+
1030
+ GraphNode? _bestNode(String query, List<GraphNode> nodes) =>
1031
+ _bestBy(query, nodes, (n) => '${n.label} ${n.tags.join(' ')}');
1032
+
1033
+ GraphNode? _mostConnected(List<GraphNode> nodes) {
1034
+ final sorted = [...nodes]..sort((a, b) => b.degree.compareTo(a.degree));
1035
+ return sorted.isEmpty ? null : sorted.first;
1036
+ }
1037
+
1038
+ /// Simple token-overlap fuzzy match β€” good enough to point the agent at the
1039
+ /// right card/node from a spoken query.
1040
+ T? _bestBy<T>(String query, List<T> items, String Function(T) labelOf) {
1041
+ final qTokens = _tokens(query);
1042
+ if (qTokens.isEmpty) return null;
1043
+ T? best;
1044
+ var bestScore = 0.0;
1045
+ for (final item in items) {
1046
+ final label = labelOf(item).toLowerCase();
1047
+ final lTokens = _tokens(label);
1048
+ var score = 0.0;
1049
+ for (final t in qTokens) {
1050
+ if (lTokens.contains(t)) {
1051
+ score += 2;
1052
+ } else if (label.contains(t)) {
1053
+ score += 1;
1054
+ }
1055
+ }
1056
+ if (score > bestScore) {
1057
+ bestScore = score;
1058
+ best = item;
1059
+ }
1060
+ }
1061
+ return bestScore > 0 ? best : null;
1062
+ }
1063
+
1064
+ Set<String> _tokens(String s) => s
1065
+ .toLowerCase()
1066
+ .split(RegExp(r'[^a-z0-9]+'))
1067
+ .where((t) => t.length > 2)
1068
+ .toSet();
1069
+
1070
+ // ── Timing helpers ────────────────────────────────────────────────────── //
1071
+
1072
+ /// Give a freshly-navigated screen a beat to mount/animate in.
1073
+ Future<void> _settle({int ms = 320}) =>
1074
+ Future<void>.delayed(Duration(milliseconds: ms));
1075
+
1076
+ /// Poll [ready] until true or [timeoutMs] elapses; returns whether it
1077
+ /// became ready. A screen/hook that never mounts doesn't throw β€” the
1078
+ /// dependent action just quietly no-ops β€” so this is the only trace that a
1079
+ /// component failed to show up in time, logged under [what].
1080
+ Future<bool> _waitFor(
1081
+ bool Function() ready, {
1082
+ required String what,
1083
+ int timeoutMs = 5000,
1084
+ }) async {
1085
+ final deadline = DateTime.now().add(Duration(milliseconds: timeoutMs));
1086
+ while (_active && !ready() && DateTime.now().isBefore(deadline)) {
1087
+ await Future<void>.delayed(const Duration(milliseconds: 120));
1088
+ }
1089
+ final ok = ready();
1090
+ if (!ok && _active) {
1091
+ _logWarn('$what never became ready after ${timeoutMs}ms β€” skipped');
1092
+ }
1093
+ return ok;
1094
+ }
1095
+
1096
+ @override
1097
+ void dispose() {
1098
+ _active = false;
1099
+ _tts.stop();
1100
+ super.dispose();
1101
+ }
1102
+ }
app/lib/ui/features/presenter/presenter_overlay.dart ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// The "Present" mode surface: a Siri-like glowing glyph that floats in a
2
+ /// corner while the agent drives the app. It pulses in the agent's current phase
3
+ /// colour, shows a live caption of what's being said/done, and β€” when tapped β€”
4
+ /// expands into a panel where you can hand the agent a question or a task.
5
+ library;
6
+
7
+ import 'dart:ui';
8
+
9
+ import 'package:flutter/material.dart';
10
+
11
+ import '../../core/theme.dart';
12
+ import 'presenter_controller.dart';
13
+
14
+ class PresenterOverlay extends StatefulWidget {
15
+ const PresenterOverlay({super.key, required this.controller});
16
+ final PresenterController controller;
17
+
18
+ @override
19
+ State<PresenterOverlay> createState() => _PresenterOverlayState();
20
+ }
21
+
22
+ class _PresenterOverlayState extends State<PresenterOverlay>
23
+ with SingleTickerProviderStateMixin {
24
+ late final AnimationController _pulse = AnimationController(
25
+ vsync: this,
26
+ duration: const Duration(milliseconds: 2200),
27
+ )..repeat(reverse: true);
28
+
29
+ final _field = TextEditingController();
30
+ final _focus = FocusNode();
31
+ bool _expanded = false;
32
+
33
+ @override
34
+ void dispose() {
35
+ _pulse.dispose();
36
+ _field.dispose();
37
+ _focus.dispose();
38
+ super.dispose();
39
+ }
40
+
41
+ void _toggle() {
42
+ setState(() => _expanded = !_expanded);
43
+ if (_expanded) {
44
+ WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus());
45
+ }
46
+ }
47
+
48
+ void _submit() {
49
+ final q = _field.text.trim();
50
+ if (q.isEmpty) return;
51
+ _field.clear();
52
+ widget.controller.ask(q);
53
+ _focus.requestFocus();
54
+ }
55
+
56
+ @override
57
+ Widget build(BuildContext context) {
58
+ final width = MediaQuery.sizeOf(context).width;
59
+ final bottomInset = MediaQuery.of(context).padding.bottom;
60
+ // Clear the floating pill nav on mobile; the side rail leaves the corner free.
61
+ final bottom = bottomInset + (width >= Insets.desktop ? 24 : 92);
62
+
63
+ return Positioned(
64
+ right: 16,
65
+ bottom: bottom,
66
+ child: AnimatedBuilder(
67
+ animation: widget.controller,
68
+ builder: (context, _) {
69
+ return Column(
70
+ crossAxisAlignment: CrossAxisAlignment.end,
71
+ mainAxisSize: MainAxisSize.min,
72
+ children: [
73
+ if (_expanded)
74
+ _Panel(
75
+ controller: widget.controller,
76
+ field: _field,
77
+ focus: _focus,
78
+ onSubmit: _submit,
79
+ onEnd: () => widget.controller.stop(),
80
+ )
81
+ else if (widget.controller.caption.isNotEmpty)
82
+ _CaptionBubble(text: widget.controller.caption),
83
+ const SizedBox(height: 12),
84
+ _Glyph(
85
+ phase: widget.controller.phase,
86
+ pulse: _pulse,
87
+ onTap: _toggle,
88
+ expanded: _expanded,
89
+ ),
90
+ ],
91
+ );
92
+ },
93
+ ),
94
+ );
95
+ }
96
+ }
97
+
98
+ // ── The glowing orb ─────────────────────────────────────────────────────── //
99
+
100
+ class _Glyph extends StatelessWidget {
101
+ const _Glyph({
102
+ required this.phase,
103
+ required this.pulse,
104
+ required this.onTap,
105
+ required this.expanded,
106
+ });
107
+
108
+ final PresenterPhase phase;
109
+ final Animation<double> pulse;
110
+ final VoidCallback onTap;
111
+ final bool expanded;
112
+
113
+ @override
114
+ Widget build(BuildContext context) {
115
+ final color = _phaseColor(context, phase);
116
+ final busy = phase == PresenterPhase.speaking ||
117
+ phase == PresenterPhase.thinking ||
118
+ phase == PresenterPhase.acting;
119
+
120
+ return Semantics(
121
+ button: true,
122
+ label: 'Presenter agent β€” tap to ask or hand it a task',
123
+ child: MouseRegion(
124
+ cursor: SystemMouseCursors.click,
125
+ child: GestureDetector(
126
+ onTap: onTap,
127
+ child: AnimatedBuilder(
128
+ animation: pulse,
129
+ builder: (context, _) {
130
+ // A soft double-halo that breathes; wider when the agent is busy.
131
+ final t = pulse.value;
132
+ final haloBlur = (busy ? 30.0 : 20.0) + t * 14;
133
+ final haloSpread = (busy ? 8.0 : 4.0) + t * 6;
134
+ return Container(
135
+ width: 64,
136
+ height: 64,
137
+ decoration: BoxDecoration(
138
+ shape: BoxShape.circle,
139
+ gradient: RadialGradient(
140
+ colors: [
141
+ Color.lerp(color, Colors.white, 0.35)!,
142
+ color,
143
+ Color.lerp(color, Colors.black, 0.25)!,
144
+ ],
145
+ stops: const [0.0, 0.55, 1.0],
146
+ center: const Alignment(-0.3, -0.4),
147
+ ),
148
+ boxShadow: [
149
+ BoxShadow(
150
+ color: color.withValues(alpha: 0.55),
151
+ blurRadius: haloBlur,
152
+ spreadRadius: haloSpread,
153
+ ),
154
+ BoxShadow(
155
+ color: color.withValues(alpha: 0.25),
156
+ blurRadius: haloBlur * 2,
157
+ spreadRadius: haloSpread * 1.6,
158
+ ),
159
+ ],
160
+ ),
161
+ child: Center(
162
+ child: Icon(
163
+ expanded ? Icons.keyboard_arrow_down_rounded : _phaseIcon(phase),
164
+ color: Colors.white,
165
+ size: 28,
166
+ ),
167
+ ),
168
+ );
169
+ },
170
+ ),
171
+ ),
172
+ ),
173
+ );
174
+ }
175
+ }
176
+
177
+ // ── Collapsed caption bubble ────────────────────────────────────────────── //
178
+
179
+ class _CaptionBubble extends StatelessWidget {
180
+ const _CaptionBubble({required this.text});
181
+ final String text;
182
+
183
+ @override
184
+ Widget build(BuildContext context) {
185
+ final theme = Theme.of(context);
186
+ return ConstrainedBox(
187
+ constraints: const BoxConstraints(maxWidth: 320),
188
+ child: ClipRRect(
189
+ borderRadius: BorderRadius.circular(18),
190
+ child: BackdropFilter(
191
+ filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
192
+ child: Container(
193
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
194
+ decoration: BoxDecoration(
195
+ color: theme.colorScheme.surface.withValues(alpha: 0.82),
196
+ borderRadius: BorderRadius.circular(18),
197
+ border: Border.all(
198
+ color: theme.colorScheme.primary.withValues(alpha: 0.30),
199
+ ),
200
+ ),
201
+ child: Text(
202
+ text,
203
+ style: theme.textTheme.bodyMedium,
204
+ maxLines: 4,
205
+ overflow: TextOverflow.ellipsis,
206
+ ),
207
+ ),
208
+ ),
209
+ ),
210
+ );
211
+ }
212
+ }
213
+
214
+ // ── Expanded input panel ────────────────────────────────────────────────── //
215
+
216
+ class _Panel extends StatelessWidget {
217
+ const _Panel({
218
+ required this.controller,
219
+ required this.field,
220
+ required this.focus,
221
+ required this.onSubmit,
222
+ required this.onEnd,
223
+ });
224
+
225
+ final PresenterController controller;
226
+ final TextEditingController field;
227
+ final FocusNode focus;
228
+ final VoidCallback onSubmit;
229
+ final VoidCallback onEnd;
230
+
231
+ @override
232
+ Widget build(BuildContext context) {
233
+ final theme = Theme.of(context);
234
+ return ConstrainedBox(
235
+ constraints: const BoxConstraints(maxWidth: 340),
236
+ child: ClipRRect(
237
+ borderRadius: BorderRadius.circular(20),
238
+ child: BackdropFilter(
239
+ filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
240
+ child: Container(
241
+ padding: const EdgeInsets.all(14),
242
+ decoration: BoxDecoration(
243
+ color: theme.colorScheme.surface.withValues(alpha: 0.86),
244
+ borderRadius: BorderRadius.circular(20),
245
+ border: Border.all(
246
+ color: theme.colorScheme.primary.withValues(alpha: 0.35),
247
+ ),
248
+ ),
249
+ child: Column(
250
+ mainAxisSize: MainAxisSize.min,
251
+ crossAxisAlignment: CrossAxisAlignment.start,
252
+ children: [
253
+ Row(
254
+ children: [
255
+ Text(
256
+ _phaseLabel(controller.phase),
257
+ style: theme.textTheme.labelSmall?.copyWith(
258
+ letterSpacing: 1.2,
259
+ color: theme.colorScheme.primary,
260
+ ),
261
+ ),
262
+ const Spacer(),
263
+ TextButton.icon(
264
+ onPressed: onEnd,
265
+ icon: const Icon(Icons.stop_circle_outlined, size: 18),
266
+ label: const Text('End'),
267
+ ),
268
+ ],
269
+ ),
270
+ const SizedBox(height: 2),
271
+ Text(
272
+ controller.caption.isEmpty ? '…' : controller.caption,
273
+ style: theme.textTheme.bodyMedium,
274
+ maxLines: 4,
275
+ overflow: TextOverflow.ellipsis,
276
+ ),
277
+ const SizedBox(height: 12),
278
+ TextField(
279
+ controller: field,
280
+ focusNode: focus,
281
+ onSubmitted: (_) => onSubmit(),
282
+ textInputAction: TextInputAction.send,
283
+ decoration: InputDecoration(
284
+ hintText: 'Ask, or hand me a task…',
285
+ isDense: true,
286
+ filled: true,
287
+ fillColor: theme.colorScheme.surfaceContainerHighest
288
+ .withValues(alpha: 0.5),
289
+ border: OutlineInputBorder(
290
+ borderRadius: BorderRadius.circular(14),
291
+ borderSide: BorderSide.none,
292
+ ),
293
+ suffixIcon: IconButton(
294
+ icon: const Icon(Icons.send_rounded),
295
+ onPressed: onSubmit,
296
+ ),
297
+ ),
298
+ ),
299
+ ],
300
+ ),
301
+ ),
302
+ ),
303
+ ),
304
+ );
305
+ }
306
+ }
307
+
308
+ // ── Phase β†’ colour / icon / label ───────────────────────────────────────── //
309
+
310
+ Color _phaseColor(BuildContext context, PresenterPhase phase) {
311
+ final scheme = Theme.of(context).colorScheme;
312
+ return switch (phase) {
313
+ PresenterPhase.speaking => const Color(0xFF3DDC97),
314
+ PresenterPhase.thinking => const Color(0xFFF5A623),
315
+ PresenterPhase.acting => const Color(0xFF4DA8FF),
316
+ PresenterPhase.idle => scheme.primary,
317
+ PresenterPhase.done => Colors.grey,
318
+ };
319
+ }
320
+
321
+ IconData _phaseIcon(PresenterPhase phase) => switch (phase) {
322
+ PresenterPhase.speaking => Icons.graphic_eq_rounded,
323
+ PresenterPhase.thinking => Icons.auto_awesome_rounded,
324
+ PresenterPhase.acting => Icons.touch_app_rounded,
325
+ PresenterPhase.idle => Icons.mic_none_rounded,
326
+ PresenterPhase.done => Icons.check_rounded,
327
+ };
328
+
329
+ String _phaseLabel(PresenterPhase p) => switch (p) {
330
+ PresenterPhase.speaking => 'PRESENTING',
331
+ PresenterPhase.thinking => 'THINKING',
332
+ PresenterPhase.acting => 'DOING IT LIVE',
333
+ PresenterPhase.idle => 'READY β€” ASK ME',
334
+ PresenterPhase.done => 'DONE',
335
+ };
app/lib/ui/features/presenter/presenter_spotlight.dart ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// The guided-focus layer for Present mode: while the agent presents a beat it
2
+ /// dims the surrounding interface, lights the widget it's talking about, and
3
+ /// glides a presentation cursor there β€” the coach-marks feel of an app's
4
+ /// first-run tour.
5
+ ///
6
+ /// Targets are real widgets: screens register a GlobalKey per target id on the
7
+ /// [AgentBus] (see `agent_bus.dart`), and this layer resolves live geometry
8
+ /// from the key every ~150 ms while lit, so the hole hugs the actual widget
9
+ /// even if it mounts late or the layout shifts. Ids with no registered widget
10
+ /// fall back to a coarse screen region. The layer stays mounted for the whole
11
+ /// presentation so the hole and cursor *animate between* targets instead of
12
+ /// restarting, and it's wrapped in [IgnorePointer] so it never blocks input.
13
+ library;
14
+
15
+ import 'dart:async';
16
+
17
+ import 'package:flutter/material.dart';
18
+
19
+ import '../../core/theme.dart';
20
+ import 'agent_bus.dart';
21
+ import 'presenter_controller.dart';
22
+
23
+ class PresenterSpotlight extends StatefulWidget {
24
+ const PresenterSpotlight({
25
+ super.key,
26
+ required this.controller,
27
+ required this.bus,
28
+ });
29
+
30
+ final PresenterController controller;
31
+ final AgentBus bus;
32
+
33
+ @override
34
+ State<PresenterSpotlight> createState() => _PresenterSpotlightState();
35
+ }
36
+
37
+ class _PresenterSpotlightState extends State<PresenterSpotlight> {
38
+ Timer? _poll;
39
+
40
+ @override
41
+ void initState() {
42
+ super.initState();
43
+ widget.controller.addListener(_onFocusChanged);
44
+ _onFocusChanged();
45
+ }
46
+
47
+ @override
48
+ void dispose() {
49
+ widget.controller.removeListener(_onFocusChanged);
50
+ _poll?.cancel();
51
+ super.dispose();
52
+ }
53
+
54
+ /// While a target is lit, re-resolve its geometry a few times a second so a
55
+ /// screen that mounts (or scrolls) after navigation still gets hugged.
56
+ void _onFocusChanged() {
57
+ final lit = widget.controller.focusId != null;
58
+ if (lit && _poll == null) {
59
+ _poll = Timer.periodic(const Duration(milliseconds: 150), (_) {
60
+ if (mounted) setState(() {});
61
+ });
62
+ } else if (!lit) {
63
+ _poll?.cancel();
64
+ _poll = null;
65
+ }
66
+ if (mounted) setState(() {});
67
+ }
68
+
69
+ @override
70
+ Widget build(BuildContext context) {
71
+ return Positioned.fill(
72
+ child: IgnorePointer(
73
+ child: LayoutBuilder(
74
+ builder: (context, constraints) {
75
+ final size = Size(constraints.maxWidth, constraints.maxHeight);
76
+ final id = widget.controller.focusId;
77
+ final rect = id == null ? null : _resolve(id, size);
78
+ // Dim the app only around genuinely small targets (a button, a nav
79
+ // item); dimming a region that already fills the screen just makes
80
+ // everything darker. The cursor, though, always shows so the
81
+ // audience can see where the agent is clicking.
82
+ final small = rect != null &&
83
+ rect.width * rect.height < size.width * size.height * 0.5;
84
+ return _Guidance(
85
+ target: rect,
86
+ dim: small,
87
+ tapping: widget.controller.tapping,
88
+ screen: size,
89
+ color: Theme.of(context).colorScheme.primary,
90
+ );
91
+ },
92
+ ),
93
+ ),
94
+ );
95
+ }
96
+
97
+ /// Real widget bounds when the target is registered and mounted, else a
98
+ /// generous fallback region so guidance degrades instead of vanishing.
99
+ Rect _resolve(String id, Size size) {
100
+ final live = widget.bus.spotlightRect(id);
101
+ if (live != null && !live.isEmpty) {
102
+ // Keep the hole on screen even if the widget hangs off an edge.
103
+ final screen = Offset.zero & size;
104
+ final clamped = live.intersect(screen.inflate(-4));
105
+ if (!clamped.isEmpty) return clamped;
106
+ }
107
+ return _fallbackRect(id, size);
108
+ }
109
+
110
+ Rect _fallbackRect(String id, Size size) {
111
+ final w = size.width;
112
+ final h = size.height;
113
+ final desktop = w >= Insets.desktop;
114
+ // Bottom nav / side rail.
115
+ if (id == 'nav' || id.startsWith('nav.')) {
116
+ return desktop
117
+ ? Rect.fromLTWH(0, h * 0.18, 88, h * 0.64) // left rail
118
+ : Rect.fromLTWH(w * 0.08, h - 100, w * 0.84, 74); // bottom pill
119
+ }
120
+ // Add button β€” bottom-right on mobile, in the rail on desktop.
121
+ if (id == 'home.plus') {
122
+ return desktop
123
+ ? Rect.fromLTWH(16, 24, 56, 56)
124
+ : Rect.fromLTWH(w - 88, h - 168, 64, 64);
125
+ }
126
+ // Top bar: search field, top-bar icons, library segment tabs.
127
+ if (id.startsWith('search.') ||
128
+ id.startsWith('top.') ||
129
+ id.startsWith('library.tab') ||
130
+ id == 'top') {
131
+ final left = w * (desktop ? 0.18 : 0.06);
132
+ return Rect.fromLTWH(left, 8, w - left - 16, 96);
133
+ }
134
+ // The main content stage ('content', 'feed.page', 'graph.canvas',
135
+ // 'card.first', 'reader.*', 'catalog.item', 'folders.*', 'todo.*', …).
136
+ final left = desktop ? 96.0 : w * 0.06;
137
+ return Rect.fromLTWH(
138
+ left,
139
+ h * 0.14,
140
+ w - left - (desktop ? 24 : w * 0.06),
141
+ h * 0.66,
142
+ );
143
+ }
144
+ }
145
+
146
+ /// Always mounted so the implicit tweens retarget β€” the hole and cursor glide
147
+ /// from the previous target to the next instead of resetting. When [target] is
148
+ /// null the whole layer just fades out, holding its last geometry.
149
+ class _Guidance extends StatefulWidget {
150
+ const _Guidance({
151
+ required this.target,
152
+ required this.dim,
153
+ required this.tapping,
154
+ required this.screen,
155
+ required this.color,
156
+ });
157
+
158
+ final Rect? target;
159
+
160
+ /// Punch a dimming hole around the target (only worthwhile for small ones).
161
+ final bool dim;
162
+
163
+ /// The agent is tapping the target right now β€” render a tap ripple.
164
+ final bool tapping;
165
+
166
+ final Size screen;
167
+ final Color color;
168
+
169
+ @override
170
+ State<_Guidance> createState() => _GuidanceState();
171
+ }
172
+
173
+ class _GuidanceState extends State<_Guidance> {
174
+ late Rect _target = Rect.fromCenter(
175
+ center: widget.screen.center(Offset.zero),
176
+ width: 120,
177
+ height: 120,
178
+ );
179
+
180
+ @override
181
+ void initState() {
182
+ super.initState();
183
+ if (widget.target != null) _target = widget.target!;
184
+ }
185
+
186
+ @override
187
+ void didUpdateWidget(_Guidance old) {
188
+ super.didUpdateWidget(old);
189
+ // Adopt a new target only when it actually moved β€” the ~150ms geometry
190
+ // poll re-resolves the same widget with sub-pixel jitter every tick, and
191
+ // feeding that into the tween would restart the glide forever (the old
192
+ // "broken spotlight"). A few pixels of slack absorbs the jitter.
193
+ final t = widget.target;
194
+ if (t != null && !_closeEnough(t, _target)) _target = t;
195
+ }
196
+
197
+ bool _closeEnough(Rect a, Rect b) =>
198
+ (a.center - b.center).distance < 6 &&
199
+ (a.width - b.width).abs() < 6 &&
200
+ (a.height - b.height).abs() < 6;
201
+
202
+ @override
203
+ Widget build(BuildContext context) {
204
+ return AnimatedOpacity(
205
+ opacity: widget.target != null ? 1 : 0,
206
+ duration: const Duration(milliseconds: 350),
207
+ curve: Curves.easeOut,
208
+ child: TweenAnimationBuilder<Rect?>(
209
+ // Only `end` matters β€” TweenAnimationBuilder animates from the current
210
+ // in-flight value to a new end whenever it changes, so the hole and
211
+ // cursor glide smoothly target-to-target.
212
+ tween: RectTween(begin: _target, end: _target),
213
+ duration: const Duration(milliseconds: 550),
214
+ curve: Curves.easeInOutCubic,
215
+ builder: (context, animated, _) {
216
+ final r = animated ?? _target;
217
+ return Stack(
218
+ children: [
219
+ if (widget.dim)
220
+ Positioned.fill(
221
+ child:
222
+ CustomPaint(painter: _SpotlightPainter(r, widget.color)),
223
+ ),
224
+ // Presentation cursor gliding to the target, like a tour guide's
225
+ // pointer, with a tap ripple when the agent clicks.
226
+ Positioned(
227
+ left: r.center.dx - 18,
228
+ top: r.center.dy - 18,
229
+ child: _Cursor(color: widget.color, tapping: widget.tapping),
230
+ ),
231
+ ],
232
+ );
233
+ },
234
+ ),
235
+ );
236
+ }
237
+ }
238
+
239
+ class _SpotlightPainter extends CustomPainter {
240
+ _SpotlightPainter(this.rect, this.color);
241
+ final Rect rect;
242
+ final Color color;
243
+
244
+ @override
245
+ void paint(Canvas canvas, Size size) {
246
+ final full = Offset.zero & size;
247
+ final cut = RRect.fromRectAndRadius(
248
+ rect.inflate(8),
249
+ const Radius.circular(16),
250
+ );
251
+ // Scrim with a hole punched out around the lit widget.
252
+ final scrim = Path.combine(
253
+ PathOperation.difference,
254
+ Path()..addRect(full),
255
+ Path()..addRRect(cut),
256
+ );
257
+ // Light enough that a dark theme doesn't collapse into a black wash.
258
+ canvas.drawPath(scrim, Paint()..color = Colors.black.withValues(alpha: 0.35));
259
+ // Glowing ring around the lit widget.
260
+ canvas.drawRRect(
261
+ cut,
262
+ Paint()
263
+ ..style = PaintingStyle.stroke
264
+ ..strokeWidth = 2
265
+ ..color = color.withValues(alpha: 0.85)
266
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
267
+ );
268
+ }
269
+
270
+ @override
271
+ bool shouldRepaint(_SpotlightPainter old) =>
272
+ old.rect != rect || old.color != color;
273
+ }
274
+
275
+ /// The tour-guide pointer. A steady dot that glides to the target, plus an
276
+ /// expanding ring that fires while [tapping] so a "click" is visible.
277
+ class _Cursor extends StatelessWidget {
278
+ const _Cursor({required this.color, required this.tapping});
279
+ final Color color;
280
+ final bool tapping;
281
+
282
+ @override
283
+ Widget build(BuildContext context) {
284
+ return SizedBox(
285
+ width: 36,
286
+ height: 36,
287
+ child: Stack(
288
+ alignment: Alignment.center,
289
+ children: [
290
+ // Tap ripple: a ring that expands and fades out on each tap.
291
+ TweenAnimationBuilder<double>(
292
+ key: ValueKey(tapping),
293
+ tween: Tween(begin: tapping ? 0.0 : 1.0, end: 1.0),
294
+ duration: const Duration(milliseconds: 260),
295
+ curve: Curves.easeOut,
296
+ builder: (context, t, _) {
297
+ if (!tapping) return const SizedBox.shrink();
298
+ return Opacity(
299
+ opacity: (1 - t).clamp(0.0, 1.0),
300
+ child: Container(
301
+ width: 12 + 30 * t,
302
+ height: 12 + 30 * t,
303
+ decoration: BoxDecoration(
304
+ shape: BoxShape.circle,
305
+ border: Border.all(
306
+ color: Colors.white.withValues(alpha: 0.9), width: 2),
307
+ ),
308
+ ),
309
+ );
310
+ },
311
+ ),
312
+ // The pointer dot.
313
+ Container(
314
+ width: 24,
315
+ height: 24,
316
+ decoration: BoxDecoration(
317
+ shape: BoxShape.circle,
318
+ color: color.withValues(alpha: 0.35),
319
+ border:
320
+ Border.all(color: Colors.white.withValues(alpha: 0.9), width: 2),
321
+ boxShadow: [
322
+ BoxShadow(
323
+ color: color.withValues(alpha: 0.7),
324
+ blurRadius: 18,
325
+ spreadRadius: 2,
326
+ ),
327
+ ],
328
+ ),
329
+ ),
330
+ ],
331
+ ),
332
+ );
333
+ }
334
+ }
app/lib/ui/features/reader/view_models/chat_view_model.dart CHANGED
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
6
 
7
  import '../../../../data/repositories/card_repository.dart';
8
  import '../../../../data/services/api_client.dart';
 
9
 
10
  class ChatMessage {
11
  const ChatMessage({required this.role, required this.content});
@@ -16,7 +17,7 @@ class ChatMessage {
16
  Map<String, String> toWire() => {'role': role, 'content': content};
17
  }
18
 
19
- class ChatViewModel extends ChangeNotifier {
20
  ChatViewModel({required CardRepository repository, required this.cardId})
21
  : _repository = repository;
22
 
 
6
 
7
  import '../../../../data/repositories/card_repository.dart';
8
  import '../../../../data/services/api_client.dart';
9
+ import '../../../core/safe_notifier.dart';
10
 
11
  class ChatMessage {
12
  const ChatMessage({required this.role, required this.content});
 
17
  Map<String, String> toWire() => {'role': role, 'content': content};
18
  }
19
 
20
+ class ChatViewModel extends ChangeNotifier with SafeNotifier {
21
  ChatViewModel({required CardRepository repository, required this.cardId})
22
  : _repository = repository;
23
 
app/lib/ui/features/reader/view_models/rabbit_hole_view_model.dart CHANGED
@@ -8,8 +8,9 @@ import 'package:flutter/foundation.dart';
8
 
9
  import '../../../../data/repositories/card_repository.dart';
10
  import '../../../../data/services/api_client.dart';
 
11
 
12
- class RabbitHoleViewModel extends ChangeNotifier {
13
  RabbitHoleViewModel({required CardRepository repository, required this.cardId})
14
  : _repository = repository;
15
 
 
8
 
9
  import '../../../../data/repositories/card_repository.dart';
10
  import '../../../../data/services/api_client.dart';
11
+ import '../../../core/safe_notifier.dart';
12
 
13
+ class RabbitHoleViewModel extends ChangeNotifier with SafeNotifier {
14
  RabbitHoleViewModel({required CardRepository repository, required this.cardId})
15
  : _repository = repository;
16
 
app/lib/ui/features/reader/view_models/reader_view_model.dart CHANGED
@@ -12,8 +12,9 @@ import '../../../../domain/models/artifact.dart';
12
  import '../../../../domain/models/card.dart';
13
  import '../../../../domain/models/concept.dart';
14
  import '../../../../domain/models/pipeline_event.dart';
 
15
 
16
- class ReaderViewModel extends ChangeNotifier {
17
  ReaderViewModel({required CardRepository repository, required this.cardId})
18
  : _repository = repository;
19
 
 
12
  import '../../../../domain/models/card.dart';
13
  import '../../../../domain/models/concept.dart';
14
  import '../../../../domain/models/pipeline_event.dart';
15
+ import '../../../core/safe_notifier.dart';
16
 
17
+ class ReaderViewModel extends ChangeNotifier with SafeNotifier {
18
  ReaderViewModel({required CardRepository repository, required this.cardId})
19
  : _repository = repository;
20
 
app/lib/ui/features/reader/views/reader_screen.dart CHANGED
@@ -72,10 +72,40 @@ class _ReaderAgentBridge extends StatefulWidget {
72
  State<_ReaderAgentBridge> createState() => _ReaderAgentBridgeState();
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  class _ReaderAgentBridgeState extends State<_ReaderAgentBridge> {
76
  AgentBus? _bus;
77
  ReaderAgentHooks? _hooks;
78
  ReaderViewModel? _vm;
 
 
 
 
 
 
79
 
80
  @override
81
  void didChangeDependencies() {
@@ -90,6 +120,15 @@ class _ReaderAgentBridgeState extends State<_ReaderAgentBridge> {
90
  );
91
  _hooks = hooks;
92
  _bus!.attachReader(hooks);
 
 
 
 
 
 
 
 
 
93
  }
94
 
95
  Future<bool> _toggleCheck() async {
@@ -130,11 +169,25 @@ class _ReaderAgentBridgeState extends State<_ReaderAgentBridge> {
130
  void dispose() {
131
  final h = _hooks;
132
  if (h != null) _bus?.detachReader(h);
 
 
 
 
 
 
 
 
133
  super.dispose();
134
  }
135
 
136
  @override
137
- Widget build(BuildContext context) => widget.child;
 
 
 
 
 
 
138
  }
139
 
140
  class _ReaderView extends StatelessWidget {
@@ -168,6 +221,7 @@ class _ReaderView extends StatelessWidget {
168
  final accent = ContentAccent.of(card.base.contentType);
169
  final readMins = _estimateReadMinutes(card);
170
  final highlightStore = context.read<HighlightStore>();
 
171
 
172
  void onHighlight(String text) {
173
  highlightStore.add(Highlight(
@@ -241,7 +295,9 @@ class _ReaderView extends StatelessWidget {
241
 
242
  // Structured blocks β€” skeleton while building, fades in on arrival
243
  if (card.blocks.isNotEmpty) ...[
244
- _fadeIn(BlockList(
 
 
245
  blocks: card.blocks,
246
  onToggleChecklist: vm.toggleChecklistItem,
247
  onToggleStep: vm.toggleStep,
@@ -255,7 +311,7 @@ class _ReaderView extends StatelessWidget {
255
  ),
256
  ),
257
  onHighlight: card.isReady ? onHighlight : null,
258
- )),
259
  if (card.isProcessing) ...[
260
  const SizedBox(height: 12),
261
  const _SkeletonBox(height: 70, radius: Insets.radius),
@@ -274,16 +330,23 @@ class _ReaderView extends StatelessWidget {
274
  if (card.isReady &&
275
  card.insight != null &&
276
  card.insight!.hasContent)
277
- InsightSection(
278
- insight: card.insight!,
279
- accent: accent,
280
- cardId: card.cardId,
281
- cardTitle: card.base.oneLiner,
282
- readMinutes: readMins,
 
 
 
283
  ),
284
 
285
  // Referenced catalog items
286
- if (card.isReady) _ReferencesStrip(entries: vm.artifacts),
 
 
 
 
287
 
288
  // Concepts this card contributed to
289
  if (card.isReady) _ConceptsStrip(entries: vm.concepts),
@@ -302,20 +365,30 @@ class _ReaderView extends StatelessWidget {
302
  return Column(
303
  children: [
304
  _EmbeddedHeader(card: card, accent: accent, onClose: onClose),
305
- Expanded(child: SingleChildScrollView(child: content)),
306
- if (card.isReady) PrimaryActionBar(card: card),
 
 
 
 
307
  ],
308
  );
309
  }
310
 
311
  return Scaffold(
312
  body: CustomScrollView(
 
313
  slivers: [
314
  _FaceAppBar(card: card, api: api, accent: accent),
315
  SliverToBoxAdapter(child: content),
316
  ],
317
  ),
318
- bottomSheet: card.isReady ? PrimaryActionBar(card: card) : null,
 
 
 
 
 
319
  );
320
  }
321
 
 
72
  State<_ReaderAgentBridge> createState() => _ReaderAgentBridgeState();
73
  }
74
 
75
+ /// Spotlight anchors for the reader's sections, so the presenter's cursor can
76
+ /// land on the real blocks / "Going deeper" / references / action bar it's
77
+ /// talking about instead of a guessed region.
78
+ class ReaderSpotlightKeys {
79
+ final blocks = GlobalKey();
80
+ final insight = GlobalKey();
81
+ final references = GlobalKey();
82
+ final actionBar = GlobalKey();
83
+ }
84
+
85
+ /// Hands the section keys down to [_ReaderView] without threading them through
86
+ /// every constructor. Absent when not presenting.
87
+ class _ReaderSpotlight extends InheritedWidget {
88
+ const _ReaderSpotlight({required this.keys, required super.child});
89
+ final ReaderSpotlightKeys keys;
90
+
91
+ static ReaderSpotlightKeys? of(BuildContext context) => context
92
+ .dependOnInheritedWidgetOfExactType<_ReaderSpotlight>()
93
+ ?.keys;
94
+
95
+ @override
96
+ bool updateShouldNotify(_ReaderSpotlight old) => false;
97
+ }
98
+
99
  class _ReaderAgentBridgeState extends State<_ReaderAgentBridge> {
100
  AgentBus? _bus;
101
  ReaderAgentHooks? _hooks;
102
  ReaderViewModel? _vm;
103
+ final _bodyKey = GlobalKey();
104
+ final _spotlight = ReaderSpotlightKeys();
105
+
106
+ // Handed to the reader's scroll view below via PrimaryScrollController, so
107
+ // the agent can scroll through a card while narrating it.
108
+ final _scroll = ScrollController();
109
 
110
  @override
111
  void didChangeDependencies() {
 
120
  );
121
  _hooks = hooks;
122
  _bus!.attachReader(hooks);
123
+ _bus!.registerSpotlight('reader.body', _bodyKey);
124
+ _bus!.registerSpotlight('reader.blocks', _spotlight.blocks);
125
+ _bus!.registerSpotlight('reader.insight', _spotlight.insight);
126
+ _bus!.registerSpotlight('reader.references', _spotlight.references);
127
+ // Ask and the three-dots menu both live in the bottom action bar; point at
128
+ // it for both β€” the narration says which control.
129
+ _bus!.registerSpotlight('reader.ask', _spotlight.actionBar);
130
+ _bus!.registerSpotlight('reader.more', _spotlight.actionBar);
131
+ _bus!.registerScrollable('reader', _scroll);
132
  }
133
 
134
  Future<bool> _toggleCheck() async {
 
169
  void dispose() {
170
  final h = _hooks;
171
  if (h != null) _bus?.detachReader(h);
172
+ _bus?.unregisterSpotlight('reader.body', _bodyKey);
173
+ _bus?.unregisterSpotlight('reader.blocks', _spotlight.blocks);
174
+ _bus?.unregisterSpotlight('reader.insight', _spotlight.insight);
175
+ _bus?.unregisterSpotlight('reader.references', _spotlight.references);
176
+ _bus?.unregisterSpotlight('reader.ask', _spotlight.actionBar);
177
+ _bus?.unregisterSpotlight('reader.more', _spotlight.actionBar);
178
+ _bus?.unregisterScrollable('reader', _scroll);
179
+ _scroll.dispose();
180
  super.dispose();
181
  }
182
 
183
  @override
184
+ Widget build(BuildContext context) => PrimaryScrollController(
185
+ controller: _scroll,
186
+ child: _ReaderSpotlight(
187
+ keys: _spotlight,
188
+ child: KeyedSubtree(key: _bodyKey, child: widget.child),
189
+ ),
190
+ );
191
  }
192
 
193
  class _ReaderView extends StatelessWidget {
 
221
  final accent = ContentAccent.of(card.base.contentType);
222
  final readMins = _estimateReadMinutes(card);
223
  final highlightStore = context.read<HighlightStore>();
224
+ final spotlight = _ReaderSpotlight.of(context);
225
 
226
  void onHighlight(String text) {
227
  highlightStore.add(Highlight(
 
295
 
296
  // Structured blocks β€” skeleton while building, fades in on arrival
297
  if (card.blocks.isNotEmpty) ...[
298
+ _fadeIn(KeyedSubtree(
299
+ key: spotlight?.blocks,
300
+ child: BlockList(
301
  blocks: card.blocks,
302
  onToggleChecklist: vm.toggleChecklistItem,
303
  onToggleStep: vm.toggleStep,
 
311
  ),
312
  ),
313
  onHighlight: card.isReady ? onHighlight : null,
314
+ ))),
315
  if (card.isProcessing) ...[
316
  const SizedBox(height: 12),
317
  const _SkeletonBox(height: 70, radius: Insets.radius),
 
330
  if (card.isReady &&
331
  card.insight != null &&
332
  card.insight!.hasContent)
333
+ KeyedSubtree(
334
+ key: spotlight?.insight,
335
+ child: InsightSection(
336
+ insight: card.insight!,
337
+ accent: accent,
338
+ cardId: card.cardId,
339
+ cardTitle: card.base.oneLiner,
340
+ readMinutes: readMins,
341
+ ),
342
  ),
343
 
344
  // Referenced catalog items
345
+ if (card.isReady)
346
+ KeyedSubtree(
347
+ key: spotlight?.references,
348
+ child: _ReferencesStrip(entries: vm.artifacts),
349
+ ),
350
 
351
  // Concepts this card contributed to
352
  if (card.isReady) _ConceptsStrip(entries: vm.concepts),
 
365
  return Column(
366
  children: [
367
  _EmbeddedHeader(card: card, accent: accent, onClose: onClose),
368
+ Expanded(child: SingleChildScrollView(primary: true, child: content)),
369
+ if (card.isReady)
370
+ KeyedSubtree(
371
+ key: spotlight?.actionBar,
372
+ child: PrimaryActionBar(card: card),
373
+ ),
374
  ],
375
  );
376
  }
377
 
378
  return Scaffold(
379
  body: CustomScrollView(
380
+ primary: true,
381
  slivers: [
382
  _FaceAppBar(card: card, api: api, accent: accent),
383
  SliverToBoxAdapter(child: content),
384
  ],
385
  ),
386
+ bottomSheet: card.isReady
387
+ ? KeyedSubtree(
388
+ key: spotlight?.actionBar,
389
+ child: PrimaryActionBar(card: card),
390
+ )
391
+ : null,
392
  );
393
  }
394
 
app/lib/ui/features/search/views/search_screen.dart CHANGED
@@ -38,9 +38,12 @@ class _SearchScreenState extends State<SearchScreen> {
38
  String _query = '';
39
  ContentType? _filter; // null = All
40
 
41
- // Agent driving: let the presenter agent run searches while mounted.
 
42
  AgentBus? _bus;
43
  SearchAgentHooks? _hooks;
 
 
44
 
45
  @override
46
  void didChangeDependencies() {
@@ -50,6 +53,9 @@ class _SearchScreenState extends State<SearchScreen> {
50
  final hooks = SearchAgentHooks(run: _runAgentQuery, filter: _applyAgentFilter);
51
  _hooks = hooks;
52
  _bus!.attachSearch(hooks);
 
 
 
53
  }
54
 
55
  void _runAgentQuery(String query) {
@@ -114,6 +120,9 @@ class _SearchScreenState extends State<SearchScreen> {
114
  void dispose() {
115
  final h = _hooks;
116
  if (h != null) _bus?.detachSearch(h);
 
 
 
117
  _debounce?.cancel();
118
  _controller.dispose();
119
  super.dispose();
@@ -126,6 +135,7 @@ class _SearchScreenState extends State<SearchScreen> {
126
  appBar: AppBar(
127
  titleSpacing: 0,
128
  title: TextField(
 
129
  controller: _controller,
130
  autofocus: true,
131
  onChanged: _onChanged,
@@ -182,6 +192,7 @@ class _SearchScreenState extends State<SearchScreen> {
182
  crossAxisAlignment: CrossAxisAlignment.stretch,
183
  children: [
184
  _FilterBar(
 
185
  types: _presentTypes,
186
  selected: _filter,
187
  total: _results.length,
@@ -224,6 +235,7 @@ class _SearchScreenState extends State<SearchScreen> {
224
  /// filters for types present in the results (docs/06 search).
225
  class _FilterBar extends StatelessWidget {
226
  const _FilterBar({
 
227
  required this.types,
228
  required this.selected,
229
  required this.total,
 
38
  String _query = '';
39
  ContentType? _filter; // null = All
40
 
41
+ // Agent driving: let the presenter agent run searches while mounted, and
42
+ // give its spotlight real geometry for the field and the filter bar.
43
  AgentBus? _bus;
44
  SearchAgentHooks? _hooks;
45
+ final _fieldKey = GlobalKey();
46
+ final _filtersKey = GlobalKey();
47
 
48
  @override
49
  void didChangeDependencies() {
 
53
  final hooks = SearchAgentHooks(run: _runAgentQuery, filter: _applyAgentFilter);
54
  _hooks = hooks;
55
  _bus!.attachSearch(hooks);
56
+ _bus!
57
+ ..registerSpotlight('search.field', _fieldKey)
58
+ ..registerSpotlight('search.filters', _filtersKey);
59
  }
60
 
61
  void _runAgentQuery(String query) {
 
120
  void dispose() {
121
  final h = _hooks;
122
  if (h != null) _bus?.detachSearch(h);
123
+ _bus
124
+ ?..unregisterSpotlight('search.field', _fieldKey)
125
+ ..unregisterSpotlight('search.filters', _filtersKey);
126
  _debounce?.cancel();
127
  _controller.dispose();
128
  super.dispose();
 
135
  appBar: AppBar(
136
  titleSpacing: 0,
137
  title: TextField(
138
+ key: _fieldKey,
139
  controller: _controller,
140
  autofocus: true,
141
  onChanged: _onChanged,
 
192
  crossAxisAlignment: CrossAxisAlignment.stretch,
193
  children: [
194
  _FilterBar(
195
+ key: _filtersKey,
196
  types: _presentTypes,
197
  selected: _filter,
198
  total: _results.length,
 
235
  /// filters for types present in the results (docs/06 search).
236
  class _FilterBar extends StatelessWidget {
237
  const _FilterBar({
238
+ super.key,
239
  required this.types,
240
  required this.selected,
241
  required this.total,
backend/app/api/presenter.py CHANGED
@@ -33,8 +33,8 @@ _VIEWS = {
33
 
34
  # Action verbs the Flutter agent knows how to execute (mirrors AgentAction).
35
  _ACTIONS = {
36
- "navigate", "wait", "create_card", "search", "search_filter", "open_card",
37
- "reader_toggle",
38
  "graph_focus", "graph_open", "graph_wander", "graph_reset", "graph_cluster",
39
  "graph_concepts",
40
  "feed_next", "feed_prev", "feed_shuffle",
@@ -78,13 +78,19 @@ asks a question or hands you a task, don't just describe things β€” DEMONSTRATE
78
  them by driving the app, then narrate what's happening.
79
 
80
  You reply with an ordered list of "beats". Each beat has:
81
- - "say": one short sentence to speak aloud (no markdown, spell out acronyms
82
- like "U R L"), or null to just perform the action silently.
83
- - "action": one thing to do, or null to just talk.
 
 
 
 
84
 
85
  An action is an object with a "do" field and optional args:
86
  - {{"do":"navigate","view":"<view>"}} β€” view is one of: library, feed, graph,
87
  search, collections, actions, profile, catalog, concepts, connections.
 
 
88
  - {{"do":"search","query":"<text>"}} β€” open search and run a query.
89
  - {{"do":"search_filter"}} β€” narrow current results by content type.
90
  - {{"do":"open_card","query":"<text>"}} β€” open the best-matching saved card.
@@ -125,6 +131,11 @@ class AskRequest(BaseModel):
125
  question: str
126
 
127
 
 
 
 
 
 
128
  class Step(BaseModel):
129
  say: str | None = None
130
  action: dict[str, Any] | None = None
@@ -161,6 +172,15 @@ def _clean_action(raw: Any) -> dict[str, Any] | None:
161
  action["ms"] = int(raw.get("ms", 800))
162
  except (TypeError, ValueError):
163
  action["ms"] = 800
 
 
 
 
 
 
 
 
 
164
  return action
165
 
166
 
@@ -198,6 +218,26 @@ def _parse(raw: str | None) -> AskResponse:
198
  return AskResponse(steps=steps) if steps else fallback
199
 
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  @router.post("/ask", response_model=AskResponse)
202
  async def ask(req: AskRequest) -> AskResponse:
203
  """Answer one audience question/task as an ordered list of speak+do beats."""
 
33
 
34
  # Action verbs the Flutter agent knows how to execute (mirrors AgentAction).
35
  _ACTIONS = {
36
+ "navigate", "wait", "scroll", "create_card", "search", "search_filter",
37
+ "open_card", "reader_toggle",
38
  "graph_focus", "graph_open", "graph_wander", "graph_reset", "graph_cluster",
39
  "graph_concepts",
40
  "feed_next", "feed_prev", "feed_shuffle",
 
78
  them by driving the app, then narrate what's happening.
79
 
80
  You reply with an ordered list of "beats". Each beat has:
81
+ - "say": one short line to speak aloud, or null to perform the action silently.
82
+ Write it the way a friendly human presenter talks: casual, contractions,
83
+ short sentences, react to what's on screen ("okay, watch this", "there it
84
+ is"). No markdown. Avoid acronyms entirely β€” say "the link" not "URL",
85
+ "a quick summary" not "TL;DR" β€” never spell letters out.
86
+ - "action": one thing to do, or null to just talk. The app speaks the line
87
+ WHILE performing the action, like a person narrating over their own clicks.
88
 
89
  An action is an object with a "do" field and optional args:
90
  - {{"do":"navigate","view":"<view>"}} β€” view is one of: library, feed, graph,
91
  search, collections, actions, profile, catalog, concepts, connections.
92
+ - {{"do":"scroll","target":"library|reader","dy":<pixels>}} β€” scroll the
93
+ library grid or the open card; negative dy scrolls up.
94
  - {{"do":"search","query":"<text>"}} β€” open search and run a query.
95
  - {{"do":"search_filter"}} β€” narrow current results by content type.
96
  - {{"do":"open_card","query":"<text>"}} β€” open the best-matching saved card.
 
131
  question: str
132
 
133
 
134
+ class LogRequest(BaseModel):
135
+ message: str
136
+ level: str = "info"
137
+
138
+
139
  class Step(BaseModel):
140
  say: str | None = None
141
  action: dict[str, Any] | None = None
 
172
  action["ms"] = int(raw.get("ms", 800))
173
  except (TypeError, ValueError):
174
  action["ms"] = 800
175
+ if verb == "scroll":
176
+ target = str(raw.get("target", "library")).strip().lower()
177
+ if target not in {"library", "reader"}:
178
+ return None
179
+ action["target"] = target
180
+ try:
181
+ action["dy"] = int(raw.get("dy", 600))
182
+ except (TypeError, ValueError):
183
+ action["dy"] = 600
184
  return action
185
 
186
 
 
218
  return AskResponse(steps=steps) if steps else fallback
219
 
220
 
221
+ _LOG_LEVELS = {
222
+ "debug": logging.DEBUG,
223
+ "info": logging.INFO,
224
+ "warning": logging.WARNING,
225
+ "error": logging.ERROR,
226
+ }
227
+
228
+
229
+ @router.post("/log")
230
+ async def relay_log(req: LogRequest) -> dict[str, bool]:
231
+ """Relay a Present-mode event from the browser into this same pipeline
232
+ log, since `debugPrint` alone depends on the browser's DWDS log
233
+ forwarding making it back to the terminal β€” flaky enough that presenter
234
+ errors were going unseen. This endpoint is the reliable path: whatever
235
+ lands here is guaranteed to print in `start.py`'s console."""
236
+ level = _LOG_LEVELS.get(req.level.strip().lower(), logging.INFO)
237
+ log.log(level, "[frontend] %s", req.message.strip())
238
+ return {"ok": True}
239
+
240
+
241
  @router.post("/ask", response_model=AskResponse)
242
  async def ask(req: AskRequest) -> AskResponse:
243
  """Answer one audience question/task as an ordered list of speak+do beats."""