Vatxzz commited on
Commit
94bfbe1
·
1 Parent(s): 5a2a97a

added collections

Browse files
app/lib/domain/models/card.dart CHANGED
@@ -117,6 +117,75 @@ class ActionItems {
117
  {'followed': followed, 'items': items.map((e) => e.toJson()).toList()};
118
  }
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  class Media {
121
  const Media({this.thumbnail, this.keyframes = const []});
122
 
@@ -175,6 +244,7 @@ class Card {
175
  this.primaryAction = const PrimaryAction(),
176
  this.actionItems = const ActionItems(),
177
  this.blocks = const [],
 
178
  this.media = const Media(),
179
  this.meta = const Meta(),
180
  this.rawBlocks = const [],
@@ -189,6 +259,7 @@ class Card {
189
  final PrimaryAction primaryAction;
190
  final ActionItems actionItems;
191
  final List<Block> blocks;
 
192
  final Media media;
193
  final Meta meta;
194
 
@@ -224,6 +295,9 @@ class Card {
224
  (json['action_items'] as Map<String, dynamic>?) ?? const {},
225
  ),
226
  blocks: rawBlocks.map(Block.fromJson).toList(),
 
 
 
227
  media: Media.fromJson((json['media'] as Map<String, dynamic>?) ?? const {}),
228
  meta: Meta.fromJson((json['meta'] as Map<String, dynamic>?) ?? const {}),
229
  rawBlocks: rawBlocks,
@@ -246,6 +320,7 @@ class Card {
246
  primaryAction: primaryAction,
247
  actionItems: actionItems ?? this.actionItems,
248
  blocks: blocks ?? this.blocks,
 
249
  media: media,
250
  meta: meta,
251
  rawBlocks: rawBlocks ?? this.rawBlocks,
 
117
  {'followed': followed, 'items': items.map((e) => e.toJson()).toList()};
118
  }
119
 
120
+ /// Deep-analysis layer (docs/14). Present only on idea-rich cards; `null` for a
121
+ /// simple reel. Everything here is actionable: rabbit-hole threads tap into chat,
122
+ /// the topic map orients, the research prompt is paste-ready. Each sub-section is
123
+ /// independently optional — the UI renders only the parts that carry content.
124
+ class Insight {
125
+ const Insight({
126
+ this.rabbitHole = const RabbitHole(),
127
+ this.topicMap,
128
+ this.deepResearchPrompt,
129
+ });
130
+
131
+ final RabbitHole rabbitHole;
132
+ final TopicMap? topicMap;
133
+ final String? deepResearchPrompt;
134
+
135
+ bool get hasDeepResearch =>
136
+ deepResearchPrompt != null && deepResearchPrompt!.trim().isNotEmpty;
137
+ bool get hasContent => !rabbitHole.isEmpty || topicMap != null || hasDeepResearch;
138
+
139
+ factory Insight.fromJson(Map<String, dynamic> json) => Insight(
140
+ rabbitHole: RabbitHole.fromJson(
141
+ (json['rabbit_hole'] as Map<String, dynamic>?) ?? const {},
142
+ ),
143
+ topicMap: json['topic_map'] is Map<String, dynamic>
144
+ ? TopicMap.fromJson(json['topic_map'] as Map<String, dynamic>)
145
+ : null,
146
+ deepResearchPrompt: json['deep_research_prompt'] as String?,
147
+ );
148
+ }
149
+
150
+ class RabbitHole {
151
+ const RabbitHole({
152
+ this.questions = const [],
153
+ this.adjacentTopics = const [],
154
+ this.advancedConcepts = const [],
155
+ });
156
+
157
+ final List<String> questions;
158
+ final List<String> adjacentTopics;
159
+ final List<String> advancedConcepts;
160
+
161
+ bool get isEmpty =>
162
+ questions.isEmpty && adjacentTopics.isEmpty && advancedConcepts.isEmpty;
163
+
164
+ factory RabbitHole.fromJson(Map<String, dynamic> json) {
165
+ List<String> l(String k) =>
166
+ ((json[k] as List?) ?? const []).map((e) => e.toString()).toList();
167
+ return RabbitHole(
168
+ questions: l('questions'),
169
+ adjacentTopics: l('adjacent_topics'),
170
+ advancedConcepts: l('advanced_concepts'),
171
+ );
172
+ }
173
+ }
174
+
175
+ class TopicMap {
176
+ const TopicMap({required this.center, this.nodes = const []});
177
+
178
+ final String center;
179
+ final List<String> nodes;
180
+
181
+ factory TopicMap.fromJson(Map<String, dynamic> json) => TopicMap(
182
+ center: (json['center'] as String?) ?? '',
183
+ nodes: ((json['nodes'] as List?) ?? const [])
184
+ .map((e) => e.toString())
185
+ .toList(),
186
+ );
187
+ }
188
+
189
  class Media {
190
  const Media({this.thumbnail, this.keyframes = const []});
191
 
 
244
  this.primaryAction = const PrimaryAction(),
245
  this.actionItems = const ActionItems(),
246
  this.blocks = const [],
247
+ this.insight,
248
  this.media = const Media(),
249
  this.meta = const Meta(),
250
  this.rawBlocks = const [],
 
259
  final PrimaryAction primaryAction;
260
  final ActionItems actionItems;
261
  final List<Block> blocks;
262
+ final Insight? insight; // deep-analysis layer (docs/14); null for simple cards
263
  final Media media;
264
  final Meta meta;
265
 
 
295
  (json['action_items'] as Map<String, dynamic>?) ?? const {},
296
  ),
297
  blocks: rawBlocks.map(Block.fromJson).toList(),
298
+ insight: json['insight'] is Map<String, dynamic>
299
+ ? Insight.fromJson(json['insight'] as Map<String, dynamic>)
300
+ : null,
301
  media: Media.fromJson((json['media'] as Map<String, dynamic>?) ?? const {}),
302
  meta: Meta.fromJson((json['meta'] as Map<String, dynamic>?) ?? const {}),
303
  rawBlocks: rawBlocks,
 
320
  primaryAction: primaryAction,
321
  actionItems: actionItems ?? this.actionItems,
322
  blocks: blocks ?? this.blocks,
323
+ insight: insight,
324
  media: media,
325
  meta: meta,
326
  rawBlocks: rawBlocks ?? this.rawBlocks,
app/lib/domain/models/pipeline_event.dart CHANGED
@@ -10,6 +10,7 @@ enum PipelineStage {
10
  extracting,
11
  structuring,
12
  persisting,
 
13
  done,
14
  failed,
15
  unknown;
@@ -26,6 +27,8 @@ enum PipelineStage {
26
  return PipelineStage.structuring;
27
  case 'persisting':
28
  return PipelineStage.persisting;
 
 
29
  case 'done':
30
  return PipelineStage.done;
31
  case 'failed':
@@ -48,6 +51,8 @@ enum PipelineStage {
48
  return 'Structuring';
49
  case PipelineStage.persisting:
50
  return 'Finishing';
 
 
51
  case PipelineStage.done:
52
  return 'Ready';
53
  case PipelineStage.failed:
@@ -57,6 +62,31 @@ enum PipelineStage {
57
  }
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  /// Ordered pipeline steps shown as a progress track (excludes terminal/meta).
61
  static const List<PipelineStage> track = [
62
  PipelineStage.downloading,
 
10
  extracting,
11
  structuring,
12
  persisting,
13
+ analyzing,
14
  done,
15
  failed,
16
  unknown;
 
27
  return PipelineStage.structuring;
28
  case 'persisting':
29
  return PipelineStage.persisting;
30
+ case 'analyzing':
31
+ return PipelineStage.analyzing;
32
  case 'done':
33
  return PipelineStage.done;
34
  case 'failed':
 
51
  return 'Structuring';
52
  case PipelineStage.persisting:
53
  return 'Finishing';
54
+ case PipelineStage.analyzing:
55
+ return 'Analyzing';
56
  case PipelineStage.done:
57
  return 'Ready';
58
  case PipelineStage.failed:
 
62
  }
63
  }
64
 
65
+ /// A fixed one-line subtitle describing what each step does — shown beneath the
66
+ /// label so the pipeline reads as a narrated sequence, not bare keywords.
67
+ String get description {
68
+ switch (this) {
69
+ case PipelineStage.snapshot:
70
+ return 'Getting ready';
71
+ case PipelineStage.downloading:
72
+ return 'Fetching the video source';
73
+ case PipelineStage.extracting:
74
+ return 'Transcript + on-screen text';
75
+ case PipelineStage.structuring:
76
+ return 'Building your knowledge card';
77
+ case PipelineStage.persisting:
78
+ return 'Saving to your library';
79
+ case PipelineStage.analyzing:
80
+ return 'Surfacing deeper insight';
81
+ case PipelineStage.done:
82
+ return 'Card ready';
83
+ case PipelineStage.failed:
84
+ return 'Something went wrong';
85
+ case PipelineStage.unknown:
86
+ return '';
87
+ }
88
+ }
89
+
90
  /// Ordered pipeline steps shown as a progress track (excludes terminal/meta).
91
  static const List<PipelineStage> track = [
92
  PipelineStage.downloading,
app/lib/main.dart CHANGED
@@ -5,6 +5,7 @@ library;
5
 
6
  import 'dart:async';
7
 
 
8
  import 'package:flutter/material.dart';
9
  import 'package:flutter_native_splash/flutter_native_splash.dart';
10
  import 'package:provider/provider.dart';
@@ -56,6 +57,7 @@ class _CachyAppState extends State<CachyApp> {
56
  /// arrive while the app is already running. Degrades silently if the platform
57
  /// channel is unavailable (e.g. desktop/test).
58
  void _wireShareIntent() {
 
59
  try {
60
  final instance = ReceiveSharingIntent.instance;
61
  instance.getInitialMedia().then((files) {
 
5
 
6
  import 'dart:async';
7
 
8
+ import 'package:flutter/foundation.dart';
9
  import 'package:flutter/material.dart';
10
  import 'package:flutter_native_splash/flutter_native_splash.dart';
11
  import 'package:provider/provider.dart';
 
57
  /// arrive while the app is already running. Degrades silently if the platform
58
  /// channel is unavailable (e.g. desktop/test).
59
  void _wireShareIntent() {
60
+ if (kIsWeb) return;
61
  try {
62
  final instance = ReceiveSharingIntent.instance;
63
  instance.getInitialMedia().then((files) {
app/lib/ui/core/widgets/pipeline_progress.dart CHANGED
@@ -33,23 +33,40 @@ class PipelineProgress extends StatelessWidget {
33
  @override
34
  Widget build(BuildContext context) {
35
  final theme = Theme.of(context);
 
36
  final idx = _currentIndex;
 
 
 
 
 
37
  return Column(
38
  crossAxisAlignment: CrossAxisAlignment.start,
39
  children: [
40
- for (var i = 0; i < PipelineStage.track.length; i++)
41
  _StageRow(
42
  label: PipelineStage.track[i].label,
 
43
  done: i < idx,
44
  active: i == idx,
45
  detail: i == idx ? detail : '',
46
- isLast: i == PipelineStage.track.length - 1,
47
  ),
48
- if (detail.isNotEmpty && idx < 0)
49
- Padding(
50
- padding: const EdgeInsets.only(top: 8, left: 40),
51
- child: Text(detail, style: theme.textTheme.bodySmall),
 
 
 
 
52
  ),
 
 
 
 
 
 
53
  ],
54
  );
55
  }
@@ -58,6 +75,7 @@ class PipelineProgress extends StatelessWidget {
58
  class _StageRow extends StatelessWidget {
59
  const _StageRow({
60
  required this.label,
 
61
  required this.done,
62
  required this.active,
63
  required this.detail,
@@ -65,6 +83,7 @@ class _StageRow extends StatelessWidget {
65
  });
66
 
67
  final String label;
 
68
  final bool done;
69
  final bool active;
70
  final String detail;
@@ -109,12 +128,20 @@ class _StageRow extends StatelessWidget {
109
  color: lit ? scheme.onSurface : scheme.onSurfaceVariant,
110
  ),
111
  ),
112
- if (active && detail.isNotEmpty)
 
 
 
113
  Padding(
114
  padding: const EdgeInsets.only(top: 2),
115
- child: Text(detail,
116
- style: theme.textTheme.bodySmall
117
- ?.copyWith(color: scheme.onSurfaceVariant)),
 
 
 
 
 
118
  ),
119
  ],
120
  ),
 
33
  @override
34
  Widget build(BuildContext context) {
35
  final theme = Theme.of(context);
36
+ final scheme = theme.colorScheme;
37
  final idx = _currentIndex;
38
+ final total = PipelineStage.track.length;
39
+ // Fraction complete: finished steps + a half-credit for the active one.
40
+ final progress = idx < 0
41
+ ? 0.04
42
+ : ((idx.clamp(0, total) + (idx < total ? 0.5 : 0.0)) / total).clamp(0.0, 1.0);
43
  return Column(
44
  crossAxisAlignment: CrossAxisAlignment.start,
45
  children: [
46
+ for (var i = 0; i < total; i++)
47
  _StageRow(
48
  label: PipelineStage.track[i].label,
49
+ description: PipelineStage.track[i].description,
50
  done: i < idx,
51
  active: i == idx,
52
  detail: i == idx ? detail : '',
53
+ isLast: i == total - 1,
54
  ),
55
+ const SizedBox(height: 4),
56
+ ClipRRect(
57
+ borderRadius: BorderRadius.circular(2),
58
+ child: LinearProgressIndicator(
59
+ value: progress,
60
+ minHeight: 4,
61
+ backgroundColor: scheme.surfaceContainerHighest,
62
+ valueColor: AlwaysStoppedAnimation(scheme.primary),
63
  ),
64
+ ),
65
+ const SizedBox(height: 8),
66
+ Text(
67
+ '${(progress * 100).round()}% complete',
68
+ style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
69
+ ),
70
  ],
71
  );
72
  }
 
75
  class _StageRow extends StatelessWidget {
76
  const _StageRow({
77
  required this.label,
78
+ required this.description,
79
  required this.done,
80
  required this.active,
81
  required this.detail,
 
83
  });
84
 
85
  final String label;
86
+ final String description;
87
  final bool done;
88
  final bool active;
89
  final String detail;
 
128
  color: lit ? scheme.onSurface : scheme.onSurfaceVariant,
129
  ),
130
  ),
131
+ // Active step shows the live SSE detail; other steps show the
132
+ // fixed subtitle so the whole sequence reads as narrated work.
133
+ if ((active ? (detail.isNotEmpty ? detail : description) : description)
134
+ .isNotEmpty)
135
  Padding(
136
  padding: const EdgeInsets.only(top: 2),
137
+ child: Text(
138
+ active ? (detail.isNotEmpty ? detail : description) : description,
139
+ style: theme.textTheme.bodySmall?.copyWith(
140
+ color: lit
141
+ ? scheme.onSurfaceVariant
142
+ : scheme.onSurfaceVariant.withValues(alpha: 0.6),
143
+ ),
144
+ ),
145
  ),
146
  ],
147
  ),
app/lib/ui/core/widgets/processing_glyph.dart ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// The signature "working" mark (docs/06): a center badge carrying a spark glyph,
2
+ /// haloed by concentric rings that ripple outward — the calm pulse shown while a
3
+ /// reel is processed. Adapted from the Insightr prototype's star/processing motif,
4
+ /// reskinned into the cream/ink world (brand primary, flat — no neon glow).
5
+ library;
6
+
7
+ import 'package:flutter/material.dart';
8
+
9
+ import '../brand.dart';
10
+
11
+ class ProcessingGlyph extends StatefulWidget {
12
+ const ProcessingGlyph({
13
+ super.key,
14
+ this.size = 132,
15
+ this.icon = Icons.auto_awesome_rounded,
16
+ });
17
+
18
+ final double size;
19
+ final IconData icon;
20
+
21
+ @override
22
+ State<ProcessingGlyph> createState() => _ProcessingGlyphState();
23
+ }
24
+
25
+ class _ProcessingGlyphState extends State<ProcessingGlyph>
26
+ with SingleTickerProviderStateMixin {
27
+ late final AnimationController _c = AnimationController(
28
+ vsync: this,
29
+ duration: const Duration(milliseconds: 2400),
30
+ )..repeat();
31
+
32
+ static const _ringCount = 3;
33
+
34
+ @override
35
+ void dispose() {
36
+ _c.dispose();
37
+ super.dispose();
38
+ }
39
+
40
+ @override
41
+ Widget build(BuildContext context) {
42
+ final scheme = Theme.of(context).colorScheme;
43
+ final badge = widget.size * 0.42;
44
+ return SizedBox(
45
+ width: widget.size,
46
+ height: widget.size,
47
+ child: AnimatedBuilder(
48
+ animation: _c,
49
+ builder: (context, child) {
50
+ return Stack(
51
+ alignment: Alignment.center,
52
+ children: [
53
+ // Rippling halo rings, each offset in phase so they radiate steadily.
54
+ for (var i = 0; i < _ringCount; i++)
55
+ _ring(scheme, (_c.value + i / _ringCount) % 1.0, badge),
56
+ child!,
57
+ ],
58
+ );
59
+ },
60
+ // Center badge: a rounded square that breathes gently.
61
+ child: TweenAnimationBuilder<double>(
62
+ tween: Tween(begin: 0.0, end: 1.0),
63
+ duration: const Duration(milliseconds: 600),
64
+ curve: Curves.easeOutBack,
65
+ builder: (context, t, _) {
66
+ final pulse = 1.0 + 0.04 * (1 - (2 * (_c.value) - 1).abs());
67
+ return Transform.scale(
68
+ scale: t * pulse,
69
+ child: Container(
70
+ width: badge,
71
+ height: badge,
72
+ decoration: BoxDecoration(
73
+ color: scheme.primary,
74
+ borderRadius: BorderRadius.circular(badge * 0.3),
75
+ boxShadow: Brand.softShadow(opacity: 0.22, blur: 22, y: 6),
76
+ ),
77
+ child: Icon(widget.icon, size: badge * 0.5, color: scheme.onPrimary),
78
+ ),
79
+ );
80
+ },
81
+ ),
82
+ ),
83
+ );
84
+ }
85
+
86
+ Widget _ring(ColorScheme scheme, double t, double base) {
87
+ final diameter = base * (1.0 + t * 1.4);
88
+ final opacity = (1.0 - t) * 0.35;
89
+ return Container(
90
+ width: diameter,
91
+ height: diameter,
92
+ decoration: BoxDecoration(
93
+ shape: BoxShape.circle,
94
+ border: Border.all(
95
+ color: scheme.primary.withValues(alpha: opacity),
96
+ width: 1.4,
97
+ ),
98
+ ),
99
+ );
100
+ }
101
+ }
app/lib/ui/core/widgets/stat_strip.dart ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// A compact dashboard strip: a row of boxed value/label cells. Used for vault
2
+ /// stats (Profile, Catalog) and the insight stat trio atop deep cards. Editorial,
3
+ /// flat — bordered cells on the surface, no glow.
4
+ library;
5
+
6
+ import 'package:flutter/material.dart';
7
+
8
+ import '../brand.dart';
9
+ import '../theme.dart';
10
+
11
+ class Stat {
12
+ const Stat({required this.value, required this.label, this.emphasize = false});
13
+ final String value;
14
+ final String label;
15
+
16
+ /// Tint the value in the brand accent (used for the headline stat).
17
+ final bool emphasize;
18
+ }
19
+
20
+ class StatStrip extends StatelessWidget {
21
+ const StatStrip({super.key, required this.stats});
22
+ final List<Stat> stats;
23
+
24
+ @override
25
+ Widget build(BuildContext context) {
26
+ if (stats.isEmpty) return const SizedBox.shrink();
27
+ return IntrinsicHeight(
28
+ child: Row(
29
+ crossAxisAlignment: CrossAxisAlignment.stretch,
30
+ children: [
31
+ for (var i = 0; i < stats.length; i++) ...[
32
+ if (i > 0) const SizedBox(width: 10),
33
+ Expanded(child: _Cell(stat: stats[i])),
34
+ ],
35
+ ],
36
+ ),
37
+ );
38
+ }
39
+ }
40
+
41
+ class _Cell extends StatelessWidget {
42
+ const _Cell({required this.stat});
43
+ final Stat stat;
44
+
45
+ @override
46
+ Widget build(BuildContext context) {
47
+ final theme = Theme.of(context);
48
+ final scheme = theme.colorScheme;
49
+ return Container(
50
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14),
51
+ decoration: BoxDecoration(
52
+ color: scheme.surfaceContainerLow,
53
+ borderRadius: BorderRadius.circular(Insets.radius),
54
+ border: Border.all(color: scheme.outlineVariant),
55
+ ),
56
+ child: Column(
57
+ mainAxisSize: MainAxisSize.min,
58
+ children: [
59
+ Text(
60
+ stat.value,
61
+ maxLines: 1,
62
+ overflow: TextOverflow.ellipsis,
63
+ style: theme.textTheme.headlineSmall?.copyWith(
64
+ fontWeight: FontWeight.w800,
65
+ color: stat.emphasize ? scheme.primary : scheme.onSurface,
66
+ ),
67
+ ),
68
+ const SizedBox(height: 4),
69
+ Text(
70
+ stat.label.toUpperCase(),
71
+ textAlign: TextAlign.center,
72
+ style: Brand.label(
73
+ size: 9.5,
74
+ color: scheme.onSurfaceVariant,
75
+ weight: FontWeight.w700,
76
+ ),
77
+ ),
78
+ ],
79
+ ),
80
+ );
81
+ }
82
+ }
app/lib/ui/features/capture/views/capture_sheet.dart CHANGED
@@ -105,6 +105,18 @@ class _CaptureSheetState extends State<_CaptureSheet> {
105
  'Paste a link, or share to Cachy from Instagram, TikTok or YouTube.',
106
  style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
107
  ),
 
 
 
 
 
 
 
 
 
 
 
 
108
  const SizedBox(height: 20),
109
 
110
  // One-tap capture of a URL already on the clipboard.
@@ -143,6 +155,41 @@ class _CaptureSheetState extends State<_CaptureSheet> {
143
  }
144
  }
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  class _ClipboardChip extends StatelessWidget {
147
  const _ClipboardChip({required this.url, required this.onTap});
148
  final String url;
 
105
  'Paste a link, or share to Cachy from Instagram, TikTok or YouTube.',
106
  style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
107
  ),
108
+ const SizedBox(height: 16),
109
+
110
+ // Supported-platform affordance — sets expectations at a glance.
111
+ const Row(
112
+ children: [
113
+ _PlatformChip(label: 'Instagram', dot: Color(0xFFE1306C)),
114
+ SizedBox(width: 8),
115
+ _PlatformChip(label: 'TikTok', dot: Color(0xFF22C3D6)),
116
+ SizedBox(width: 8),
117
+ _PlatformChip(label: 'YouTube', dot: Color(0xFFE0301E)),
118
+ ],
119
+ ),
120
  const SizedBox(height: 20),
121
 
122
  // One-tap capture of a URL already on the clipboard.
 
155
  }
156
  }
157
 
158
+ /// A small static chip naming a supported source platform (colored dot + label).
159
+ class _PlatformChip extends StatelessWidget {
160
+ const _PlatformChip({required this.label, required this.dot});
161
+ final String label;
162
+ final Color dot;
163
+
164
+ @override
165
+ Widget build(BuildContext context) {
166
+ final theme = Theme.of(context);
167
+ final scheme = theme.colorScheme;
168
+ return Container(
169
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
170
+ decoration: BoxDecoration(
171
+ color: scheme.surfaceContainerHigh,
172
+ borderRadius: BorderRadius.circular(999),
173
+ border: Border.all(color: scheme.outlineVariant),
174
+ ),
175
+ child: Row(
176
+ mainAxisSize: MainAxisSize.min,
177
+ children: [
178
+ Container(
179
+ width: 7,
180
+ height: 7,
181
+ decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
182
+ ),
183
+ const SizedBox(width: 7),
184
+ Text(label,
185
+ style: theme.textTheme.labelMedium
186
+ ?.copyWith(fontWeight: FontWeight.w600)),
187
+ ],
188
+ ),
189
+ );
190
+ }
191
+ }
192
+
193
  class _ClipboardChip extends StatelessWidget {
194
  const _ClipboardChip({required this.url, required this.onTap});
195
  final String url;
app/lib/ui/features/catalog/view_models/catalog_view_model.dart CHANGED
@@ -33,6 +33,12 @@ class CatalogViewModel extends ChangeNotifier {
33
  String? _error;
34
  String? get error => _error;
35
 
 
 
 
 
 
 
36
  /// The type filters that actually have entries, in catalog order — so the
37
  /// filter bar never offers an empty category.
38
  List<ArtifactType> get availableTypes {
 
33
  String? _error;
34
  String? get error => _error;
35
 
36
+ /// Dashboard counts over the WHOLE catalog (ignore the active filter).
37
+ int get entryCount => _entries.length;
38
+ int get typeCount => {for (final e in _entries) e.type}.length;
39
+ int get referencedCardCount =>
40
+ {for (final e in _entries) ...e.sourceCardIds}.length;
41
+
42
  /// The type filters that actually have entries, in catalog order — so the
43
  /// filter bar never offers an empty category.
44
  List<ArtifactType> get availableTypes {
app/lib/ui/features/catalog/views/catalog_detail_screen.dart CHANGED
@@ -10,7 +10,10 @@ import 'package:provider/provider.dart';
10
 
11
  import '../../../../data/repositories/card_repository.dart';
12
  import '../../../../domain/models/artifact.dart';
 
 
13
  import '../../../core/theme.dart';
 
14
  import '../services/artifact_lookup.dart';
15
 
16
  class CatalogDetailScreen extends StatefulWidget {
@@ -25,6 +28,50 @@ class _CatalogDetailScreenState extends State<CatalogDetailScreen> {
25
  late CatalogEntry _entry = widget.entry;
26
  bool _loading = false;
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  Future<void> _fetchInfo() async {
29
  setState(() => _loading = true);
30
  final messenger = ScaffoldMessenger.of(context);
@@ -169,10 +216,130 @@ class _CatalogDetailScreenState extends State<CatalogDetailScreen> {
169
  icon: const Icon(Icons.open_in_new_rounded, size: 18),
170
  label: const Text('Search the web'),
171
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  ],
173
  ),
174
  );
175
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  }
177
 
178
  /// Cover with a typed placeholder fallback (mirrors the catalog grid tile).
 
10
 
11
  import '../../../../data/repositories/card_repository.dart';
12
  import '../../../../domain/models/artifact.dart';
13
+ import '../../../../domain/models/card.dart' as model;
14
+ import '../../../core/brand.dart';
15
  import '../../../core/theme.dart';
16
+ import '../../reader/views/reader_screen.dart';
17
  import '../services/artifact_lookup.dart';
18
 
19
  class CatalogDetailScreen extends StatefulWidget {
 
28
  late CatalogEntry _entry = widget.entry;
29
  bool _loading = false;
30
 
31
+ // Backlink browsing (best-effort, async): the cards that reference this thing,
32
+ // and other catalog entries that co-occur in those same cards.
33
+ List<({String id, String title})> _appearsIn = const [];
34
+ List<CatalogEntry> _related = const [];
35
+
36
+ @override
37
+ void initState() {
38
+ super.initState();
39
+ _loadBacklinks();
40
+ }
41
+
42
+ Future<void> _loadBacklinks() async {
43
+ final repo = context.read<CardRepository>();
44
+ final ids = _entry.sourceCardIds;
45
+ // "Appears in": resolve source cards to titles (cap to keep it light).
46
+ final cards = await Future.wait(
47
+ ids.take(12).map(
48
+ (id) => repo.getCard(id).then<model.Card?>((c) => c).catchError((_) => null),
49
+ ),
50
+ );
51
+ final appears = <({String id, String title})>[];
52
+ for (final c in cards) {
53
+ if (c == null) continue;
54
+ final title = c.base.oneLiner.isNotEmpty ? c.base.oneLiner : 'Untitled card';
55
+ appears.add((id: c.cardId, title: title));
56
+ }
57
+ // "Related": catalog entries sharing at least one source card with this one.
58
+ var related = const <CatalogEntry>[];
59
+ try {
60
+ final all = await repo.catalog();
61
+ final mine = _entry.sourceCardIds.toSet();
62
+ related = all
63
+ .where((e) => e.id != _entry.id && e.sourceCardIds.any(mine.contains))
64
+ .take(12)
65
+ .toList();
66
+ } catch (_) {/* related is optional */}
67
+ if (mounted) {
68
+ setState(() {
69
+ _appearsIn = appears;
70
+ _related = related;
71
+ });
72
+ }
73
+ }
74
+
75
  Future<void> _fetchInfo() async {
76
  setState(() => _loading = true);
77
  final messenger = ScaffoldMessenger.of(context);
 
216
  icon: const Icon(Icons.open_in_new_rounded, size: 18),
217
  label: const Text('Search the web'),
218
  ),
219
+
220
+ if (_appearsIn.isNotEmpty) ...[
221
+ const SizedBox(height: 28),
222
+ _label(theme, 'Appears in'),
223
+ const SizedBox(height: 8),
224
+ for (final c in _appearsIn)
225
+ _AppearsRow(
226
+ title: c.title,
227
+ onTap: () => Navigator.of(context).push(
228
+ MaterialPageRoute(builder: (_) => ReaderScreen(cardId: c.id)),
229
+ ),
230
+ ),
231
+ ],
232
+
233
+ if (_related.isNotEmpty) ...[
234
+ const SizedBox(height: 28),
235
+ _label(theme, 'Related'),
236
+ const SizedBox(height: 12),
237
+ Wrap(
238
+ spacing: 8,
239
+ runSpacing: 8,
240
+ children: [
241
+ for (final e in _related)
242
+ _RelatedChip(
243
+ entry: e,
244
+ onTap: () => Navigator.of(context).push(
245
+ MaterialPageRoute(builder: (_) => CatalogDetailScreen(entry: e)),
246
+ ),
247
+ ),
248
+ ],
249
+ ),
250
+ ],
251
  ],
252
  ),
253
  );
254
  }
255
+
256
+ Widget _label(ThemeData theme, String text) => Text(
257
+ text.toUpperCase(),
258
+ style: Brand.label(
259
+ size: 11,
260
+ color: theme.colorScheme.onSurfaceVariant,
261
+ weight: FontWeight.w700,
262
+ letterSpacing: 1.2,
263
+ ),
264
+ );
265
+ }
266
+
267
+ class _AppearsRow extends StatelessWidget {
268
+ const _AppearsRow({required this.title, required this.onTap});
269
+ final String title;
270
+ final VoidCallback onTap;
271
+
272
+ @override
273
+ Widget build(BuildContext context) {
274
+ final theme = Theme.of(context);
275
+ final scheme = theme.colorScheme;
276
+ return Padding(
277
+ padding: const EdgeInsets.only(bottom: 8),
278
+ child: Material(
279
+ color: scheme.surfaceContainerLow,
280
+ borderRadius: BorderRadius.circular(12),
281
+ child: InkWell(
282
+ onTap: onTap,
283
+ borderRadius: BorderRadius.circular(12),
284
+ child: Padding(
285
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
286
+ child: Row(
287
+ children: [
288
+ Icon(Icons.description_outlined, size: 18, color: scheme.primary),
289
+ const SizedBox(width: 12),
290
+ Expanded(
291
+ child: Text(title,
292
+ maxLines: 1,
293
+ overflow: TextOverflow.ellipsis,
294
+ style: theme.textTheme.bodyMedium),
295
+ ),
296
+ Icon(Icons.chevron_right_rounded,
297
+ size: 18, color: scheme.onSurfaceVariant),
298
+ ],
299
+ ),
300
+ ),
301
+ ),
302
+ ),
303
+ );
304
+ }
305
+ }
306
+
307
+ class _RelatedChip extends StatelessWidget {
308
+ const _RelatedChip({required this.entry, required this.onTap});
309
+ final CatalogEntry entry;
310
+ final VoidCallback onTap;
311
+
312
+ @override
313
+ Widget build(BuildContext context) {
314
+ final theme = Theme.of(context);
315
+ final scheme = theme.colorScheme;
316
+ return GestureDetector(
317
+ onTap: onTap,
318
+ child: Container(
319
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
320
+ decoration: BoxDecoration(
321
+ color: scheme.surfaceContainerLow,
322
+ borderRadius: BorderRadius.circular(999),
323
+ border: Border.all(color: scheme.outlineVariant),
324
+ ),
325
+ child: Row(
326
+ mainAxisSize: MainAxisSize.min,
327
+ children: [
328
+ Text(entry.type.sectionLabel,
329
+ style: Brand.label(size: 9, color: scheme.primary, weight: FontWeight.w700)),
330
+ const SizedBox(width: 8),
331
+ ConstrainedBox(
332
+ constraints: const BoxConstraints(maxWidth: 160),
333
+ child: Text(entry.title,
334
+ maxLines: 1,
335
+ overflow: TextOverflow.ellipsis,
336
+ style: theme.textTheme.bodyMedium),
337
+ ),
338
+ ],
339
+ ),
340
+ ),
341
+ );
342
+ }
343
  }
344
 
345
  /// Cover with a typed placeholder fallback (mirrors the catalog grid tile).
app/lib/ui/features/catalog/views/catalog_screen.dart CHANGED
@@ -11,6 +11,7 @@ import '../../../../data/repositories/card_repository.dart';
11
  import '../../../../domain/models/artifact.dart';
12
  import '../../../core/brand.dart';
13
  import '../../../core/theme.dart';
 
14
  import '../view_models/catalog_view_model.dart';
15
  import 'catalog_detail_screen.dart';
16
 
@@ -77,10 +78,19 @@ class _CatalogView extends StatelessWidget {
77
  case CatalogStatus.ready:
78
  final sections = vm.sections;
79
  return ListView.builder(
80
- padding: const EdgeInsets.fromLTRB(Insets.page, 8, Insets.page, 96),
81
  physics: const AlwaysScrollableScrollPhysics(),
82
- itemCount: sections.length,
83
- itemBuilder: (ctx, i) => _Section(section: sections[i], vm: vm),
 
 
 
 
 
 
 
 
 
84
  );
85
  }
86
  }
 
11
  import '../../../../domain/models/artifact.dart';
12
  import '../../../core/brand.dart';
13
  import '../../../core/theme.dart';
14
+ import '../../../core/widgets/stat_strip.dart';
15
  import '../view_models/catalog_view_model.dart';
16
  import 'catalog_detail_screen.dart';
17
 
 
78
  case CatalogStatus.ready:
79
  final sections = vm.sections;
80
  return ListView.builder(
81
+ padding: const EdgeInsets.fromLTRB(Insets.page, 12, Insets.page, 96),
82
  physics: const AlwaysScrollableScrollPhysics(),
83
+ itemCount: sections.length + 1,
84
+ itemBuilder: (ctx, i) {
85
+ if (i == 0) {
86
+ return StatStrip(stats: [
87
+ Stat(value: '${vm.entryCount}', label: 'Entries', emphasize: true),
88
+ Stat(value: '${vm.typeCount}', label: 'Types'),
89
+ Stat(value: '${vm.referencedCardCount}', label: 'From cards'),
90
+ ]);
91
+ }
92
+ return _Section(section: sections[i - 1], vm: vm);
93
+ },
94
  );
95
  }
96
  }
app/lib/ui/features/graph/views/graph_screen.dart CHANGED
@@ -40,7 +40,7 @@ class _PhysicsConfig {
40
  _PhysicsConfig({
41
  this.repelForce = 2.2,
42
  this.linkForce = 1.3,
43
- this.centerForce = 0.02,
44
  this.linkDistance = 90,
45
  });
46
  }
@@ -71,13 +71,18 @@ class _GraphScreenState extends State<GraphScreen>
71
  final Map<String, Offset> _vel = {};
72
  final Map<String, List<String>> _adj = {};
73
  final Map<String, double> _edgeWeights = {};
 
 
 
 
 
 
74
  double _temperature = 0;
75
 
76
  // View transform.
77
  Offset _pan = Offset.zero;
78
- double _zoom = 1.0;
79
- Offset _panStart = Offset.zero;
80
- double _baseZoom = 1.0;
81
  String? _selected;
82
  String? _draggedNode;
83
 
@@ -125,7 +130,7 @@ class _GraphScreenState extends State<GraphScreen>
125
  }
126
 
127
  // --------------------------------------------------------------------------
128
- // Layout seeding — random ring + jitter, then start the physics ticker
129
  // --------------------------------------------------------------------------
130
 
131
  void _seedLayout(GraphData data) {
@@ -133,20 +138,13 @@ class _GraphScreenState extends State<GraphScreen>
133
  _vel.clear();
134
  _adj.clear();
135
  _edgeWeights.clear();
 
136
 
137
- final rng = math.Random(7);
138
- final n = data.nodes.length;
139
-
140
- for (var i = 0; i < n; i++) {
141
- final a = (i / math.max(1, n)) * 2 * math.pi;
142
- final r = 35 + rng.nextDouble() * 90;
143
- _pos[data.nodes[i].id] =
144
- Offset(math.cos(a) * r, math.sin(a) * r) +
145
- Offset(rng.nextDouble() * 8 - 4, rng.nextDouble() * 8 - 4);
146
- _vel[data.nodes[i].id] = Offset.zero;
147
- _adj[data.nodes[i].id] = [];
148
  }
149
-
150
  for (final e in data.edges) {
151
  _adj[e.source]?.add(e.target);
152
  _adj[e.target]?.add(e.source);
@@ -156,8 +154,107 @@ class _GraphScreenState extends State<GraphScreen>
156
  _edgeWeights[key] = e.weight;
157
  }
158
 
159
- _temperature = 90;
160
- if (!_ticker.isActive && n > 0) _ticker.start();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  }
162
 
163
  // --------------------------------------------------------------------------
@@ -263,6 +360,18 @@ class _GraphScreenState extends State<GraphScreen>
263
  disp[id] = disp[id]! - p * _physics.centerForce;
264
  }
265
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  // --- Apply forces with temperature clamping + velocity damping ---
267
  const damping = 0.80; // less drag = bouncier spring oscillation
268
  for (final node in visible) {
@@ -313,7 +422,6 @@ class _GraphScreenState extends State<GraphScreen>
313
 
314
  void _onScaleStart(ScaleStartDetails d, Size size) {
315
  _baseZoom = _zoom;
316
- _panStart = _pan;
317
  final hit = _hitTest(d.localFocalPoint, size);
318
  if (hit != null && d.pointerCount == 1) {
319
  _draggedNode = hit;
@@ -350,7 +458,9 @@ class _GraphScreenState extends State<GraphScreen>
350
 
351
  void _onScaleEnd(ScaleEndDetails d) {
352
  if (_draggedNode != null) {
353
- _temperature = math.max(_temperature, 25.0);
 
 
354
  if (!_ticker.isActive) _ticker.start();
355
  }
356
  _draggedNode = null;
@@ -438,10 +548,10 @@ class _GraphScreenState extends State<GraphScreen>
438
  setState(() {
439
  _localMode = true;
440
  _localRoot = node.id;
441
- // Reheat so visible subset re-settles.
442
  _temperature = 60;
443
  if (!_ticker.isActive) _ticker.start();
444
  });
 
445
  },
446
  ),
447
  );
@@ -475,6 +585,8 @@ class _GraphScreenState extends State<GraphScreen>
475
  _temperature = 60;
476
  if (!_ticker.isActive) _ticker.start();
477
  });
 
 
478
  (ctx as Element).markNeedsBuild();
479
  },
480
  onLocalDepthChanged: (v) {
@@ -483,6 +595,7 @@ class _GraphScreenState extends State<GraphScreen>
483
  _temperature = 60;
484
  if (!_ticker.isActive) _ticker.start();
485
  });
 
486
  (ctx as Element).markNeedsBuild();
487
  },
488
  ),
@@ -563,12 +676,15 @@ class _GraphScreenState extends State<GraphScreen>
563
  ?.label ??
564
  'Unknown',
565
  depth: _localDepth,
566
- onExit: () => setState(() {
567
- _localMode = false;
568
- _localRoot = null;
569
- _temperature = 60;
570
- if (!_ticker.isActive) _ticker.start();
571
- }),
 
 
 
572
  ),
573
  // Cluster filter chips.
574
  if (!_localMode && data.clusters.isNotEmpty)
 
40
  _PhysicsConfig({
41
  this.repelForce = 2.2,
42
  this.linkForce = 1.3,
43
+ this.centerForce = 0.005,
44
  this.linkDistance = 90,
45
  });
46
  }
 
71
  final Map<String, Offset> _vel = {};
72
  final Map<String, List<String>> _adj = {};
73
  final Map<String, double> _edgeWeights = {};
74
+
75
+ // Star layout anchors — the ideal position each node should occupy.
76
+ final Map<String, Offset> _starPos = {};
77
+ static const double _starRadius = 110.0;
78
+ // Loose strength so the spring-back feels natural, not snappy.
79
+ static const double _starRestoreStrength = 0.04;
80
  double _temperature = 0;
81
 
82
  // View transform.
83
  Offset _pan = Offset.zero;
84
+ double _zoom = 0.72;
85
+ double _baseZoom = 0.72;
 
86
  String? _selected;
87
  String? _draggedNode;
88
 
 
130
  }
131
 
132
  // --------------------------------------------------------------------------
133
+ // Layout seeding — places nodes directly into star positions
134
  // --------------------------------------------------------------------------
135
 
136
  void _seedLayout(GraphData data) {
 
138
  _vel.clear();
139
  _adj.clear();
140
  _edgeWeights.clear();
141
+ _starPos.clear();
142
 
143
+ // Initialise adjacency lists and zero velocities.
144
+ for (final node in data.nodes) {
145
+ _adj[node.id] = [];
146
+ _vel[node.id] = Offset.zero;
 
 
 
 
 
 
 
147
  }
 
148
  for (final e in data.edges) {
149
  _adj[e.source]?.add(e.target);
150
  _adj[e.target]?.add(e.source);
 
154
  _edgeWeights[key] = e.weight;
155
  }
156
 
157
+ // Compute star positions and seed nodes directly into them so the graph
158
+ // opens already in star form — no physics convergence needed.
159
+ _starPos.addAll(_computeStarLayout(data, data.nodes));
160
+ for (final node in data.nodes) {
161
+ _pos[node.id] = _starPos[node.id] ?? Offset.zero;
162
+ }
163
+
164
+ _temperature = 45;
165
+ if (!_ticker.isActive && data.nodes.isNotEmpty) _ticker.start();
166
+ }
167
+
168
+ // --------------------------------------------------------------------------
169
+ // Star layout computation
170
+ // --------------------------------------------------------------------------
171
+
172
+ /// Returns the ideal star position for each node in [visible].
173
+ ///
174
+ /// Each connected cluster gets one **hub** (highest-degree node) placed on a
175
+ /// coarse ring, with all other cluster members as **spokes** radiating
176
+ /// outward at equal angular intervals.
177
+ ///
178
+ /// Cluster-hub separation = `_starRadius * 2.4` so adjacent clusters' spoke
179
+ /// disks never spatially overlap, preventing cross-cluster edge crossings.
180
+ Map<String, Offset> _computeStarLayout(
181
+ GraphData data, List<GraphNode> visible) {
182
+ final result = <String, Offset>{};
183
+
184
+ // Group nodes by cluster ID.
185
+ final clusterMap = <int, List<GraphNode>>{};
186
+ for (final node in visible) {
187
+ clusterMap.putIfAbsent(node.clusterId, () => []).add(node);
188
+ }
189
+
190
+ // Isolated nodes (-1) live on an outer ring, handled separately.
191
+ final isolated = clusterMap.remove(-1) ?? <GraphNode>[];
192
+ final clusterIds = clusterMap.keys.toList()..sort();
193
+ final numClusters = clusterIds.length;
194
+
195
+ // Minimum separation between adjacent cluster hubs so that no spoke from
196
+ // cluster A can reach any spoke of cluster B.
197
+ const clusterSep = _starRadius * 2.4;
198
+ // Chord formula: for n equally spaced points on a ring, the chord between
199
+ // adjacent points = 2 * R * sin(π / n). Solve for R given chord = clusterSep.
200
+ final hubRingRadius = numClusters <= 1
201
+ ? 0.0
202
+ : clusterSep / (2 * math.sin(math.pi / numClusters));
203
+
204
+ for (var ci = 0; ci < numClusters; ci++) {
205
+ final cid = clusterIds[ci];
206
+ final nodes = List<GraphNode>.from(clusterMap[cid]!);
207
+
208
+ // Highest-degree node becomes the star's hub.
209
+ nodes.sort((a, b) => b.degree.compareTo(a.degree));
210
+ final hub = nodes.first;
211
+
212
+ final hubAngle =
213
+ (ci / math.max(1, numClusters)) * 2 * math.pi - math.pi / 2;
214
+ final hubPos = numClusters <= 1
215
+ ? Offset.zero
216
+ : Offset(
217
+ math.cos(hubAngle) * hubRingRadius,
218
+ math.sin(hubAngle) * hubRingRadius,
219
+ );
220
+ result[hub.id] = hubPos;
221
+
222
+ // Place spoke nodes at equal angular intervals around the hub.
223
+ final spokes = nodes.skip(1).toList();
224
+ for (var si = 0; si < spokes.length; si++) {
225
+ final spokeAngle =
226
+ (si / math.max(1, spokes.length)) * 2 * math.pi - math.pi / 2;
227
+ result[spokes[si].id] = hubPos +
228
+ Offset(
229
+ math.cos(spokeAngle) * _starRadius,
230
+ math.sin(spokeAngle) * _starRadius,
231
+ );
232
+ }
233
+ }
234
+
235
+ // Isolated nodes on a ring well beyond all star clusters.
236
+ final outerRadius = hubRingRadius + _starRadius * 1.6;
237
+ for (var ii = 0; ii < isolated.length; ii++) {
238
+ final angle =
239
+ (ii / math.max(1, isolated.length)) * 2 * math.pi - math.pi / 2;
240
+ result[isolated[ii].id] = Offset(
241
+ math.cos(angle) * outerRadius,
242
+ math.sin(angle) * outerRadius,
243
+ );
244
+ }
245
+
246
+ return result;
247
+ }
248
+
249
+ /// Recomputes star target positions for the currently visible node set and
250
+ /// updates [_starPos]. Call whenever local-mode or depth changes.
251
+ void _recomputeStar() {
252
+ final data = _data;
253
+ if (data == null) return;
254
+ final visible = _visibleNodes(data);
255
+ _starPos
256
+ ..clear()
257
+ ..addAll(_computeStarLayout(data, visible));
258
  }
259
 
260
  // --------------------------------------------------------------------------
 
360
  disp[id] = disp[id]! - p * _physics.centerForce;
361
  }
362
 
363
+ // --- Force 4: Star restoring spring ---
364
+ // Pulls every non-dragged node gently back to its ideal star position.
365
+ // Strength 0.04 = loose and natural; large enough to correct drift over
366
+ // a few seconds, small enough not to fight the user's drag.
367
+ for (final id in ids) {
368
+ if (id == _draggedNode) continue;
369
+ final target = _starPos[id];
370
+ final current = _pos[id];
371
+ if (target == null || current == null) continue;
372
+ disp[id] = disp[id]! + (target - current) * _starRestoreStrength;
373
+ }
374
+
375
  // --- Apply forces with temperature clamping + velocity damping ---
376
  const damping = 0.80; // less drag = bouncier spring oscillation
377
  for (final node in visible) {
 
422
 
423
  void _onScaleStart(ScaleStartDetails d, Size size) {
424
  _baseZoom = _zoom;
 
425
  final hit = _hitTest(d.localFocalPoint, size);
426
  if (hit != null && d.pointerCount == 1) {
427
  _draggedNode = hit;
 
458
 
459
  void _onScaleEnd(ScaleEndDetails d) {
460
  if (_draggedNode != null) {
461
+ // Reheat enough for the restoring force to animate the snap-back
462
+ // visibly, but not so hot that distant nodes thrash.
463
+ _temperature = math.max(_temperature, 45.0);
464
  if (!_ticker.isActive) _ticker.start();
465
  }
466
  _draggedNode = null;
 
548
  setState(() {
549
  _localMode = true;
550
  _localRoot = node.id;
 
551
  _temperature = 60;
552
  if (!_ticker.isActive) _ticker.start();
553
  });
554
+ _recomputeStar();
555
  },
556
  ),
557
  );
 
585
  _temperature = 60;
586
  if (!_ticker.isActive) _ticker.start();
587
  });
588
+ _recomputeStar();
589
+ // Update the bottom sheet's own state.
590
  (ctx as Element).markNeedsBuild();
591
  },
592
  onLocalDepthChanged: (v) {
 
595
  _temperature = 60;
596
  if (!_ticker.isActive) _ticker.start();
597
  });
598
+ _recomputeStar();
599
  (ctx as Element).markNeedsBuild();
600
  },
601
  ),
 
676
  ?.label ??
677
  'Unknown',
678
  depth: _localDepth,
679
+ onExit: () {
680
+ setState(() {
681
+ _localMode = false;
682
+ _localRoot = null;
683
+ _temperature = 60;
684
+ if (!_ticker.isActive) _ticker.start();
685
+ });
686
+ _recomputeStar();
687
+ },
688
  ),
689
  // Cluster filter chips.
690
  if (!_localMode && data.clusters.isNotEmpty)
app/lib/ui/features/library/views/card_tile.dart CHANGED
@@ -95,6 +95,7 @@ class CardTile extends StatelessWidget {
95
  height: 1.15,
96
  ),
97
  ),
 
98
  ],
99
  ),
100
  ),
@@ -133,3 +134,68 @@ class CardTile extends StatelessWidget {
133
  if (ok == true) onDelete();
134
  }
135
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  height: 1.15,
96
  ),
97
  ),
98
+ if (card.isReady) _MetaPills(card: card),
99
  ],
100
  ),
101
  ),
 
134
  if (ok == true) onDelete();
135
  }
136
  }
137
+
138
+ /// Compact at-a-glance counts on a tile scrim — how much a card carries before
139
+ /// you open it (actions to do, steps inside, deeper analysis). Renders nothing
140
+ /// when the card has none.
141
+ class _MetaPills extends StatelessWidget {
142
+ const _MetaPills({required this.card});
143
+ final model.Card card;
144
+
145
+ @override
146
+ Widget build(BuildContext context) {
147
+ final actions = card.actionItems.items.length;
148
+ var steps = 0;
149
+ for (final b in card.rawBlocks) {
150
+ final type = b['type'];
151
+ if (type == 'step_list') steps += (b['steps'] as List?)?.length ?? 0;
152
+ if (type == 'checklist') steps += (b['items'] as List?)?.length ?? 0;
153
+ }
154
+ final hasInsight = card.insight?.hasContent ?? false;
155
+
156
+ final pills = <String>[
157
+ if (actions > 0) '$actions ${actions == 1 ? 'action' : 'actions'}',
158
+ if (steps > 0) '$steps steps',
159
+ ];
160
+ if (pills.isEmpty && !hasInsight) return const SizedBox.shrink();
161
+
162
+ return Padding(
163
+ padding: const EdgeInsets.only(top: 7),
164
+ child: Wrap(
165
+ spacing: 6,
166
+ runSpacing: 6,
167
+ children: [
168
+ for (final p in pills) _Pill(label: p),
169
+ if (hasInsight) const _Pill(label: 'Deep', highlight: true),
170
+ ],
171
+ ),
172
+ );
173
+ }
174
+ }
175
+
176
+ class _Pill extends StatelessWidget {
177
+ const _Pill({required this.label, this.highlight = false});
178
+ final String label;
179
+ final bool highlight;
180
+
181
+ @override
182
+ Widget build(BuildContext context) {
183
+ return Container(
184
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
185
+ decoration: BoxDecoration(
186
+ color: highlight
187
+ ? Colors.white.withValues(alpha: 0.92)
188
+ : Colors.white.withValues(alpha: 0.18),
189
+ borderRadius: BorderRadius.circular(999),
190
+ ),
191
+ child: Text(
192
+ label,
193
+ style: Brand.label(
194
+ size: 9.5,
195
+ color: highlight ? Colors.black87 : Colors.white,
196
+ weight: FontWeight.w700,
197
+ ),
198
+ ),
199
+ );
200
+ }
201
+ }
app/lib/ui/features/onboarding/views/onboarding_screen.dart CHANGED
@@ -128,16 +128,7 @@ class _Panel extends StatelessWidget {
128
  child: Column(
129
  mainAxisAlignment: MainAxisAlignment.center,
130
  children: [
131
- Container(
132
- width: 132,
133
- height: 132,
134
- decoration: BoxDecoration(
135
- color: theme.colorScheme.primary.withValues(alpha: 0.10),
136
- shape: BoxShape.circle,
137
- border: Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.3)),
138
- ),
139
- child: Icon(icon, size: 58, color: theme.colorScheme.primary),
140
- ),
141
  const SizedBox(height: 40),
142
  Text(title, textAlign: TextAlign.center, style: theme.textTheme.headlineMedium),
143
  const SizedBox(height: 14),
@@ -152,3 +143,44 @@ class _Panel extends StatelessWidget {
152
  );
153
  }
154
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  child: Column(
129
  mainAxisAlignment: MainAxisAlignment.center,
130
  children: [
131
+ _RingedIcon(icon: icon),
 
 
 
 
 
 
 
 
 
132
  const SizedBox(height: 40),
133
  Text(title, textAlign: TextAlign.center, style: theme.textTheme.headlineMedium),
134
  const SizedBox(height: 14),
 
143
  );
144
  }
145
  }
146
+
147
+ /// A haloed feature icon: a filled tinted core ringed by two concentric outlines
148
+ /// that fade outward — the onboarding's "feature highlight" motif.
149
+ class _RingedIcon extends StatelessWidget {
150
+ const _RingedIcon({required this.icon});
151
+ final IconData icon;
152
+
153
+ @override
154
+ Widget build(BuildContext context) {
155
+ final primary = Theme.of(context).colorScheme.primary;
156
+ Widget ring(double size, double alpha) => Container(
157
+ width: size,
158
+ height: size,
159
+ decoration: BoxDecoration(
160
+ shape: BoxShape.circle,
161
+ border: Border.all(color: primary.withValues(alpha: alpha)),
162
+ ),
163
+ );
164
+ return SizedBox(
165
+ width: 168,
166
+ height: 168,
167
+ child: Stack(
168
+ alignment: Alignment.center,
169
+ children: [
170
+ ring(168, 0.10),
171
+ ring(140, 0.20),
172
+ Container(
173
+ width: 108,
174
+ height: 108,
175
+ decoration: BoxDecoration(
176
+ color: primary.withValues(alpha: 0.10),
177
+ shape: BoxShape.circle,
178
+ border: Border.all(color: primary.withValues(alpha: 0.35)),
179
+ ),
180
+ child: Icon(icon, size: 50, color: primary),
181
+ ),
182
+ ],
183
+ ),
184
+ );
185
+ }
186
+ }
app/lib/ui/features/onboarding/views/splash_screen.dart CHANGED
@@ -82,6 +82,20 @@ class _SplashScreenState extends State<SplashScreen>
82
  ),
83
  ),
84
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  ],
86
  ),
87
  ),
@@ -91,6 +105,40 @@ class _SplashScreenState extends State<SplashScreen>
91
  }
92
  }
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  /// A small loading splash variant reused while the app boots, without animation.
95
  class SplashStatic extends StatelessWidget {
96
  const SplashStatic({super.key});
 
82
  ),
83
  ),
84
  ),
85
+ const SizedBox(height: 28),
86
+ // Floating capability chips drift up as the wordmark settles.
87
+ _FloatingChip(
88
+ icon: Icons.video_library_rounded,
89
+ label: 'Short-form videos',
90
+ t: _wordmark.value,
91
+ ),
92
+ const SizedBox(height: 12),
93
+ _FloatingChip(
94
+ icon: Icons.auto_awesome_rounded,
95
+ label: 'AI-powered recall',
96
+ // Slight stagger so the second chip trails the first.
97
+ t: (_wordmark.value * 1.25 - 0.25).clamp(0.0, 1.0),
98
+ ),
99
  ],
100
  ),
101
  ),
 
105
  }
106
  }
107
 
108
+ /// A glassy capability pill that fades + drifts up — the splash's floating tags.
109
+ class _FloatingChip extends StatelessWidget {
110
+ const _FloatingChip({required this.icon, required this.label, required this.t});
111
+ final IconData icon;
112
+ final String label;
113
+ final double t;
114
+
115
+ @override
116
+ Widget build(BuildContext context) {
117
+ return Opacity(
118
+ opacity: t.clamp(0.0, 1.0),
119
+ child: Transform.translate(
120
+ offset: Offset(0, (1 - t) * 16),
121
+ child: Container(
122
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
123
+ decoration: BoxDecoration(
124
+ color: Brand.creamRaised,
125
+ borderRadius: BorderRadius.circular(999),
126
+ border: Border.all(color: Brand.ink.withValues(alpha: 0.08)),
127
+ ),
128
+ child: Row(
129
+ mainAxisSize: MainAxisSize.min,
130
+ children: [
131
+ Icon(icon, size: 15, color: Brand.rust),
132
+ const SizedBox(width: 8),
133
+ Text(label, style: Brand.label(size: 12, color: Brand.ink, weight: FontWeight.w600)),
134
+ ],
135
+ ),
136
+ ),
137
+ ),
138
+ );
139
+ }
140
+ }
141
+
142
  /// A small loading splash variant reused while the app boots, without animation.
143
  class SplashStatic extends StatelessWidget {
144
  const SplashStatic({super.key});
app/lib/ui/features/profile/views/profile_screen.dart CHANGED
@@ -7,11 +7,12 @@ import 'package:flutter/material.dart';
7
  import 'package:provider/provider.dart';
8
 
9
  import '../../../../data/repositories/card_repository.dart';
 
10
  import '../../../../domain/models/card.dart' as model;
11
- import '../../../../domain/models/enums.dart';
12
  import '../../../core/app_controller.dart';
13
  import '../../../core/brand.dart';
14
  import '../../../core/theme.dart';
 
15
 
16
  class ProfileScreen extends StatefulWidget {
17
  const ProfileScreen({super.key});
@@ -22,11 +23,14 @@ class ProfileScreen extends StatefulWidget {
22
 
23
  class _ProfileScreenState extends State<ProfileScreen> {
24
  late Future<List<model.Card>> _cards;
 
25
 
26
  @override
27
  void initState() {
28
  super.initState();
29
- _cards = context.read<CardRepository>().list();
 
 
30
  }
31
 
32
  @override
@@ -68,44 +72,40 @@ class _ProfileScreenState extends State<ProfileScreen> {
68
  }
69
 
70
  Widget _header(ThemeData theme) {
 
71
  return FutureBuilder<List<model.Card>>(
72
  future: _cards,
73
  builder: (context, snap) {
74
  final cards = snap.data ?? const <model.Card>[];
75
  final total = cards.length;
76
- final todo = cards
77
- .where((c) => c.base.contentType == ContentType.recipe ||
78
- c.base.contentType == ContentType.workout)
79
  .length;
80
- final scheme = theme.colorScheme;
81
- return Container(
82
- padding: const EdgeInsets.all(22),
83
- decoration: BoxDecoration(
84
- color: scheme.surfaceContainerLow,
85
- borderRadius: BorderRadius.circular(Insets.radius),
86
- border: Border.all(color: scheme.outlineVariant),
87
- ),
88
- child: Row(
89
- children: [
90
- CachyGlyph(size: 44, color: scheme.onSurface, reelColor: scheme.primary),
91
- const SizedBox(width: 16),
92
- Expanded(
93
- child: Column(
94
- crossAxisAlignment: CrossAxisAlignment.start,
95
- children: [
96
- Text('Your shelf',
97
- style: Brand.wordmarkStyle(24, color: scheme.onSurface)),
98
- const SizedBox(height: 6),
99
- Text(
100
- '$total ${total == 1 ? 'CARD' : 'CARDS'}'
101
- '${todo > 0 ? ' · $todo TO DO' : ''}',
102
- style: Brand.label(size: 11, color: scheme.onSurfaceVariant),
103
- ),
104
- ],
105
- ),
106
- ),
107
- ],
108
- ),
109
  );
110
  },
111
  );
 
7
  import 'package:provider/provider.dart';
8
 
9
  import '../../../../data/repositories/card_repository.dart';
10
+ import '../../../../domain/models/artifact.dart';
11
  import '../../../../domain/models/card.dart' as model;
 
12
  import '../../../core/app_controller.dart';
13
  import '../../../core/brand.dart';
14
  import '../../../core/theme.dart';
15
+ import '../../../core/widgets/stat_strip.dart';
16
 
17
  class ProfileScreen extends StatefulWidget {
18
  const ProfileScreen({super.key});
 
23
 
24
  class _ProfileScreenState extends State<ProfileScreen> {
25
  late Future<List<model.Card>> _cards;
26
+ late Future<List<CatalogEntry>> _catalog;
27
 
28
  @override
29
  void initState() {
30
  super.initState();
31
+ final repo = context.read<CardRepository>();
32
+ _cards = repo.list();
33
+ _catalog = repo.catalog().catchError((_) => <CatalogEntry>[]);
34
  }
35
 
36
  @override
 
72
  }
73
 
74
  Widget _header(ThemeData theme) {
75
+ final scheme = theme.colorScheme;
76
  return FutureBuilder<List<model.Card>>(
77
  future: _cards,
78
  builder: (context, snap) {
79
  final cards = snap.data ?? const <model.Card>[];
80
  final total = cards.length;
81
+ final weekAgo = DateTime.now().subtract(const Duration(days: 7));
82
+ final thisWeek = cards
83
+ .where((c) => (c.meta.createdAt ?? DateTime(0)).isAfter(weekAgo))
84
  .length;
85
+ return Column(
86
+ crossAxisAlignment: CrossAxisAlignment.stretch,
87
+ children: [
88
+ Row(
89
+ children: [
90
+ CachyGlyph(size: 44, color: scheme.onSurface, reelColor: scheme.primary),
91
+ const SizedBox(width: 14),
92
+ Text('Your shelf',
93
+ style: Brand.wordmarkStyle(24, color: scheme.onSurface)),
94
+ ],
95
+ ),
96
+ const SizedBox(height: 16),
97
+ FutureBuilder<List<CatalogEntry>>(
98
+ future: _catalog,
99
+ builder: (context, catSnap) {
100
+ final refs = catSnap.data?.length;
101
+ return StatStrip(stats: [
102
+ Stat(value: '$total', label: 'Cards', emphasize: true),
103
+ Stat(value: '$thisWeek', label: 'This week'),
104
+ Stat(value: refs == null ? '—' : '$refs', label: 'References'),
105
+ ]);
106
+ },
107
+ ),
108
+ ],
 
 
 
 
 
109
  );
110
  },
111
  );
app/lib/ui/features/reader/view_models/chat_view_model.dart CHANGED
@@ -34,6 +34,13 @@ class ChatViewModel extends ChangeNotifier {
34
 
35
  bool get isEmpty => _messages.isEmpty;
36
 
 
 
 
 
 
 
 
37
  Future<void> send(String text) async {
38
  final trimmed = text.trim();
39
  if (trimmed.isEmpty || _busy) return;
 
34
 
35
  bool get isEmpty => _messages.isEmpty;
36
 
37
+ /// Fire an opening question on entry (rabbit-hole tap → grounded chat). Runs
38
+ /// after the first frame so the screen is already mounted when the reply lands.
39
+ void seed(String? text) {
40
+ if (text == null || text.trim().isEmpty) return;
41
+ Future.microtask(() => send(text));
42
+ }
43
+
44
  Future<void> send(String text) async {
45
  final trimmed = text.trim();
46
  if (trimmed.isEmpty || _busy) return;
app/lib/ui/features/reader/views/chat_screen.dart CHANGED
@@ -12,17 +12,25 @@ import '../../../core/theme.dart';
12
  import '../view_models/chat_view_model.dart';
13
 
14
  class ChatScreen extends StatelessWidget {
15
- const ChatScreen({super.key, required this.cardId, required this.title});
 
 
 
 
 
16
  final String cardId;
17
  final String title;
18
 
 
 
 
19
  @override
20
  Widget build(BuildContext context) {
21
  return ChangeNotifierProvider(
22
  create: (ctx) => ChatViewModel(
23
  repository: ctx.read<CardRepository>(),
24
  cardId: cardId,
25
- ),
26
  child: _ChatView(title: title),
27
  );
28
  }
 
12
  import '../view_models/chat_view_model.dart';
13
 
14
  class ChatScreen extends StatelessWidget {
15
+ const ChatScreen({
16
+ super.key,
17
+ required this.cardId,
18
+ required this.title,
19
+ this.seed,
20
+ });
21
  final String cardId;
22
  final String title;
23
 
24
+ /// Optional opening question, auto-sent on entry (rabbit-hole → ask the card).
25
+ final String? seed;
26
+
27
  @override
28
  Widget build(BuildContext context) {
29
  return ChangeNotifierProvider(
30
  create: (ctx) => ChatViewModel(
31
  repository: ctx.read<CardRepository>(),
32
  cardId: cardId,
33
+ )..seed(seed),
34
  child: _ChatView(title: title),
35
  );
36
  }
app/lib/ui/features/reader/views/insight_section.dart ADDED
@@ -0,0 +1,561 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// The deep-analysis layer (docs/14): claims, blind spots, rabbit holes, a small
2
+ /// topic map, and a doorway to the deep-research prompt. Rendered ONLY when a card
3
+ /// carries an `insight` (idea-rich content); a simple reel shows none of this. Each
4
+ /// sub-section guards on its own content, so a partial layer renders cleanly.
5
+ library;
6
+
7
+ import 'dart:math' as math;
8
+
9
+ import 'package:flutter/material.dart';
10
+ import 'package:flutter/services.dart';
11
+
12
+ import '../../../../domain/models/card.dart';
13
+ import '../../../core/brand.dart';
14
+ import '../../../core/content_accent.dart';
15
+ import '../../../core/theme.dart';
16
+ import '../../../core/widgets/stat_strip.dart';
17
+ import 'chat_screen.dart';
18
+
19
+ class InsightSection extends StatelessWidget {
20
+ const InsightSection({
21
+ super.key,
22
+ required this.insight,
23
+ required this.accent,
24
+ required this.cardId,
25
+ required this.cardTitle,
26
+ required this.readMinutes,
27
+ });
28
+
29
+ final Insight insight;
30
+ final ContentAccent accent;
31
+ final String cardId;
32
+ final String cardTitle;
33
+
34
+ /// Estimated read time of the card body, computed by the reader.
35
+ final int readMinutes;
36
+
37
+ List<Stat> _trio() {
38
+ final rh = insight.rabbitHole;
39
+ final threads =
40
+ rh.questions.length + rh.adjacentTopics.length + rh.advancedConcepts.length;
41
+ return [
42
+ Stat(value: '${readMinutes < 1 ? 1 : readMinutes}m', label: 'Read'),
43
+ Stat(value: '$threads', label: 'Threads', emphasize: true),
44
+ if (insight.topicMap != null)
45
+ Stat(value: '${insight.topicMap!.nodes.length}', label: 'Topics'),
46
+ ];
47
+ }
48
+
49
+ @override
50
+ Widget build(BuildContext context) {
51
+ final children = <Widget>[];
52
+ if (!insight.rabbitHole.isEmpty) {
53
+ children.add(_RabbitHoleCard(
54
+ rabbitHole: insight.rabbitHole,
55
+ accent: accent,
56
+ cardId: cardId,
57
+ cardTitle: cardTitle,
58
+ ));
59
+ }
60
+ if (insight.topicMap != null) {
61
+ children.add(_TopicMapCard(map: insight.topicMap!, accent: accent));
62
+ }
63
+ if (insight.hasDeepResearch) {
64
+ children.add(_DeepResearchButton(prompt: insight.deepResearchPrompt!, accent: accent));
65
+ }
66
+ if (children.isEmpty) return const SizedBox.shrink();
67
+
68
+ return Padding(
69
+ padding: const EdgeInsets.only(top: 28),
70
+ child: Column(
71
+ crossAxisAlignment: CrossAxisAlignment.stretch,
72
+ children: [
73
+ _SectionHeader(icon: Icons.psychology_alt_rounded, label: 'Going deeper', accent: accent),
74
+ const SizedBox(height: 12),
75
+ StatStrip(stats: _trio()),
76
+ const SizedBox(height: 14),
77
+ _DiveDeeperButton(accent: accent, children: children),
78
+ ],
79
+ ),
80
+ );
81
+ }
82
+ }
83
+
84
+ class _DiveDeeperButton extends StatefulWidget {
85
+ const _DiveDeeperButton({required this.accent, required this.children});
86
+ final ContentAccent accent;
87
+ final List<Widget> children;
88
+
89
+ @override
90
+ State<_DiveDeeperButton> createState() => _DiveDeeperButtonState();
91
+ }
92
+
93
+ class _DiveDeeperButtonState extends State<_DiveDeeperButton> {
94
+ bool _expanded = false;
95
+
96
+ @override
97
+ Widget build(BuildContext context) {
98
+ final theme = Theme.of(context);
99
+ final scheme = theme.colorScheme;
100
+ return Column(
101
+ crossAxisAlignment: CrossAxisAlignment.stretch,
102
+ children: [
103
+ Material(
104
+ color: _expanded ? scheme.surfaceContainerHighest : scheme.surfaceContainerLow,
105
+ shape: RoundedRectangleBorder(
106
+ borderRadius: BorderRadius.circular(Insets.radius),
107
+ side: BorderSide(
108
+ color: _expanded ? widget.accent.color : scheme.outlineVariant,
109
+ width: _expanded ? 1.5 : 1.0,
110
+ ),
111
+ ),
112
+ clipBehavior: Clip.antiAlias,
113
+ child: InkWell(
114
+ onTap: () => setState(() => _expanded = !_expanded),
115
+ child: Padding(
116
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
117
+ child: Row(
118
+ children: [
119
+ Icon(Icons.travel_explore_rounded, size: 20, color: widget.accent.color),
120
+ const SizedBox(width: 10),
121
+ Expanded(
122
+ child: Text(
123
+ 'Dive deeper',
124
+ style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
125
+ ),
126
+ ),
127
+ AnimatedRotation(
128
+ turns: _expanded ? 0.5 : 0.0,
129
+ duration: const Duration(milliseconds: 200),
130
+ curve: Curves.easeInOut,
131
+ child: Icon(Icons.expand_more_rounded, color: scheme.onSurfaceVariant),
132
+ ),
133
+ ],
134
+ ),
135
+ ),
136
+ ),
137
+ ),
138
+ AnimatedCrossFade(
139
+ firstChild: const SizedBox.shrink(),
140
+ secondChild: Column(
141
+ crossAxisAlignment: CrossAxisAlignment.stretch,
142
+ children: [
143
+ for (final child in widget.children)
144
+ Padding(padding: const EdgeInsets.only(top: 12), child: child),
145
+ ],
146
+ ),
147
+ crossFadeState: _expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
148
+ duration: const Duration(milliseconds: 250),
149
+ sizeCurve: Curves.easeOutCubic,
150
+ ),
151
+ ],
152
+ );
153
+ }
154
+ }
155
+
156
+ class _SectionHeader extends StatelessWidget {
157
+ const _SectionHeader({required this.icon, required this.label, required this.accent});
158
+ final IconData icon;
159
+ final String label;
160
+ final ContentAccent accent;
161
+
162
+ @override
163
+ Widget build(BuildContext context) {
164
+ return Row(
165
+ children: [
166
+ Icon(icon, size: 16, color: accent.color),
167
+ const SizedBox(width: 7),
168
+ Text(label.toUpperCase(),
169
+ style: Brand.label(size: 11, color: accent.color, weight: FontWeight.w700)),
170
+ ],
171
+ );
172
+ }
173
+ }
174
+
175
+ /// A bordered container shared by the insight cards — matches the reader's
176
+ /// editorial surfaces.
177
+ class _Panel extends StatelessWidget {
178
+ const _Panel({required this.title, required this.icon, required this.child});
179
+ final String title;
180
+ final IconData icon;
181
+ final Widget child;
182
+
183
+ @override
184
+ Widget build(BuildContext context) {
185
+ final theme = Theme.of(context);
186
+ final scheme = theme.colorScheme;
187
+ return Container(
188
+ padding: const EdgeInsets.all(16),
189
+ decoration: BoxDecoration(
190
+ color: scheme.surfaceContainerLow,
191
+ borderRadius: BorderRadius.circular(Insets.radius),
192
+ border: Border.all(color: scheme.outlineVariant),
193
+ ),
194
+ child: Column(
195
+ crossAxisAlignment: CrossAxisAlignment.start,
196
+ children: [
197
+ Row(
198
+ children: [
199
+ Icon(icon, size: 18, color: scheme.onSurfaceVariant),
200
+ const SizedBox(width: 8),
201
+ Text(title,
202
+ style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
203
+ ],
204
+ ),
205
+ const SizedBox(height: 12),
206
+ child,
207
+ ],
208
+ ),
209
+ );
210
+ }
211
+ }
212
+
213
+ // --- Rabbit hole ----------------------------------------------------------- //
214
+
215
+ class _RabbitHoleCard extends StatelessWidget {
216
+ const _RabbitHoleCard({
217
+ required this.rabbitHole,
218
+ required this.accent,
219
+ required this.cardId,
220
+ required this.cardTitle,
221
+ });
222
+ final RabbitHole rabbitHole;
223
+ final ContentAccent accent;
224
+ final String cardId;
225
+ final String cardTitle;
226
+
227
+ @override
228
+ Widget build(BuildContext context) {
229
+ final theme = Theme.of(context);
230
+ final groups = <(String, List<String>)>[
231
+ ('Questions', rabbitHole.questions),
232
+ ('Adjacent topics', rabbitHole.adjacentTopics),
233
+ ('Advanced concepts', rabbitHole.advancedConcepts),
234
+ ].where((g) => g.$2.isNotEmpty).toList();
235
+
236
+ return _Panel(
237
+ title: 'Rabbit hole',
238
+ icon: Icons.travel_explore_rounded,
239
+ child: Column(
240
+ crossAxisAlignment: CrossAxisAlignment.start,
241
+ children: [
242
+ Padding(
243
+ padding: const EdgeInsets.only(bottom: 6),
244
+ child: Text('Tap a thread to go deeper — the card answers, grounded in its content.',
245
+ style: theme.textTheme.bodySmall
246
+ ?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
247
+ ),
248
+ for (final group in groups)
249
+ _RabbitHoleGroup(
250
+ label: group.$1,
251
+ items: group.$2,
252
+ accent: accent,
253
+ cardId: cardId,
254
+ cardTitle: cardTitle,
255
+ ),
256
+ ],
257
+ ),
258
+ );
259
+ }
260
+ }
261
+
262
+ class _RabbitHoleGroup extends StatelessWidget {
263
+ const _RabbitHoleGroup({
264
+ required this.label,
265
+ required this.items,
266
+ required this.accent,
267
+ required this.cardId,
268
+ required this.cardTitle,
269
+ });
270
+ final String label;
271
+ final List<String> items;
272
+ final ContentAccent accent;
273
+ final String cardId;
274
+ final String cardTitle;
275
+
276
+ void _ask(BuildContext context, String thread) {
277
+ Navigator.of(context).push(
278
+ MaterialPageRoute(
279
+ builder: (_) => ChatScreen(cardId: cardId, title: cardTitle, seed: thread),
280
+ ),
281
+ );
282
+ }
283
+
284
+ @override
285
+ Widget build(BuildContext context) {
286
+ final theme = Theme.of(context);
287
+ return Theme(
288
+ data: theme.copyWith(dividerColor: Colors.transparent),
289
+ child: ExpansionTile(
290
+ tilePadding: EdgeInsets.zero,
291
+ childrenPadding: const EdgeInsets.only(bottom: 4),
292
+ expandedCrossAxisAlignment: CrossAxisAlignment.start,
293
+ initiallyExpanded: true,
294
+ title: Text(label,
295
+ style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
296
+ trailing: Container(
297
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
298
+ decoration: BoxDecoration(
299
+ color: theme.colorScheme.surfaceContainerHighest,
300
+ borderRadius: BorderRadius.circular(999),
301
+ ),
302
+ child: Text('${items.length}',
303
+ style: theme.textTheme.labelSmall
304
+ ?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
305
+ ),
306
+ children: [
307
+ for (final item in items)
308
+ InkWell(
309
+ onTap: () => _ask(context, item),
310
+ borderRadius: BorderRadius.circular(8),
311
+ child: Padding(
312
+ padding: const EdgeInsets.symmetric(vertical: 8),
313
+ child: Row(
314
+ crossAxisAlignment: CrossAxisAlignment.start,
315
+ children: [
316
+ Padding(
317
+ padding: const EdgeInsets.only(top: 1),
318
+ child: Icon(Icons.chat_bubble_outline_rounded,
319
+ size: 15, color: accent.color),
320
+ ),
321
+ const SizedBox(width: 8),
322
+ Expanded(
323
+ child: Text(item,
324
+ style: theme.textTheme.bodyMedium?.copyWith(height: 1.3)),
325
+ ),
326
+ Icon(Icons.arrow_outward_rounded,
327
+ size: 14, color: theme.colorScheme.onSurfaceVariant),
328
+ ],
329
+ ),
330
+ ),
331
+ ),
332
+ ],
333
+ ),
334
+ );
335
+ }
336
+ }
337
+
338
+ // --- Topic map ------------------------------------------------------------- //
339
+
340
+ class _TopicMapCard extends StatelessWidget {
341
+ const _TopicMapCard({required this.map, required this.accent});
342
+ final TopicMap map;
343
+ final ContentAccent accent;
344
+
345
+ @override
346
+ Widget build(BuildContext context) {
347
+ return _Panel(
348
+ title: 'Topic map',
349
+ icon: Icons.hub_outlined,
350
+ child: SizedBox(
351
+ height: 240,
352
+ width: double.infinity,
353
+ child: CustomPaint(
354
+ painter: _TopicMapPainter(
355
+ center: map.center,
356
+ nodes: map.nodes,
357
+ accent: accent.color,
358
+ line: Theme.of(context).colorScheme.outlineVariant,
359
+ centerText: Colors.white,
360
+ nodeFill: Theme.of(context).colorScheme.surfaceContainerHighest,
361
+ nodeText: Theme.of(context).colorScheme.onSurface,
362
+ ),
363
+ ),
364
+ ),
365
+ );
366
+ }
367
+ }
368
+
369
+ class _TopicMapPainter extends CustomPainter {
370
+ _TopicMapPainter({
371
+ required this.center,
372
+ required this.nodes,
373
+ required this.accent,
374
+ required this.line,
375
+ required this.centerText,
376
+ required this.nodeFill,
377
+ required this.nodeText,
378
+ });
379
+
380
+ final String center;
381
+ final List<String> nodes;
382
+ final Color accent;
383
+ final Color line;
384
+ final Color centerText;
385
+ final Color nodeFill;
386
+ final Color nodeText;
387
+
388
+ @override
389
+ void paint(Canvas canvas, Size size) {
390
+ final c = Offset(size.width / 2, size.height / 2);
391
+ final radius = math.min(size.width, size.height) / 2 - 38;
392
+ const centerR = 40.0;
393
+ const nodeR = 30.0;
394
+
395
+ final linePaint = Paint()
396
+ ..color = line
397
+ ..strokeWidth = 1.2
398
+ ..style = PaintingStyle.stroke;
399
+
400
+ final positions = <Offset>[];
401
+ for (var i = 0; i < nodes.length; i++) {
402
+ final angle = -math.pi / 2 + (2 * math.pi * i / nodes.length);
403
+ positions.add(Offset(c.dx + radius * math.cos(angle), c.dy + radius * math.sin(angle)));
404
+ }
405
+
406
+ // Connecting lines (dashed-ish: just straight, kept subtle).
407
+ for (final p in positions) {
408
+ canvas.drawLine(c, p, linePaint);
409
+ }
410
+
411
+ // Satellite nodes.
412
+ for (var i = 0; i < positions.length; i++) {
413
+ canvas.drawCircle(positions[i], nodeR, Paint()..color = nodeFill);
414
+ canvas.drawCircle(positions[i], nodeR, linePaint);
415
+ _label(canvas, nodes[i], positions[i], nodeR * 2 - 6, nodeText, 9);
416
+ }
417
+
418
+ // Center node.
419
+ canvas.drawCircle(c, centerR, Paint()..color = accent);
420
+ _label(canvas, center, c, centerR * 2 - 6, centerText, 11, bold: true);
421
+ }
422
+
423
+ void _label(Canvas canvas, String text, Offset at, double maxWidth, Color color,
424
+ double fontSize, {bool bold = false}) {
425
+ final tp = TextPainter(
426
+ text: TextSpan(
427
+ text: text,
428
+ style: TextStyle(
429
+ color: color,
430
+ fontSize: fontSize,
431
+ fontWeight: bold ? FontWeight.w800 : FontWeight.w600,
432
+ height: 1.1,
433
+ ),
434
+ ),
435
+ textAlign: TextAlign.center,
436
+ textDirection: TextDirection.ltr,
437
+ maxLines: 2,
438
+ ellipsis: '…',
439
+ )..layout(maxWidth: maxWidth);
440
+ tp.paint(canvas, Offset(at.dx - tp.width / 2, at.dy - tp.height / 2));
441
+ }
442
+
443
+ @override
444
+ bool shouldRepaint(covariant _TopicMapPainter old) =>
445
+ old.center != center || old.nodes != nodes || old.accent != accent;
446
+ }
447
+
448
+ // --- Deep research --------------------------------------------------------- //
449
+
450
+ class _DeepResearchButton extends StatelessWidget {
451
+ const _DeepResearchButton({required this.prompt, required this.accent});
452
+ final String prompt;
453
+ final ContentAccent accent;
454
+
455
+ @override
456
+ Widget build(BuildContext context) {
457
+ return SizedBox(
458
+ width: double.infinity,
459
+ child: FilledButton.icon(
460
+ style: FilledButton.styleFrom(
461
+ backgroundColor: accent.color,
462
+ foregroundColor: Colors.white,
463
+ padding: const EdgeInsets.symmetric(vertical: 16),
464
+ ),
465
+ onPressed: () => Navigator.of(context).push(
466
+ MaterialPageRoute(builder: (_) => DeepResearchScreen(prompt: prompt, accent: accent)),
467
+ ),
468
+ icon: const Icon(Icons.auto_awesome_rounded, size: 18),
469
+ label: const Text('Deep research prompt'),
470
+ ),
471
+ );
472
+ }
473
+ }
474
+
475
+ /// The deep-research prompt screen (docs/14): a ready-to-paste research brief for
476
+ /// an external frontier LLM. Copy is the dominant action.
477
+ class DeepResearchScreen extends StatelessWidget {
478
+ const DeepResearchScreen({super.key, required this.prompt, required this.accent});
479
+ final String prompt;
480
+ final ContentAccent accent;
481
+
482
+ int get _tokenEstimate => (prompt.length / 4).ceil();
483
+
484
+ void _copy(BuildContext context) {
485
+ Clipboard.setData(ClipboardData(text: prompt));
486
+ ScaffoldMessenger.of(context).showSnackBar(
487
+ const SnackBar(content: Text('Prompt copied'), duration: Motion.medium),
488
+ );
489
+ }
490
+
491
+ @override
492
+ Widget build(BuildContext context) {
493
+ final theme = Theme.of(context);
494
+ final scheme = theme.colorScheme;
495
+ return Scaffold(
496
+ appBar: AppBar(title: const Text('Deep research')),
497
+ body: Center(
498
+ child: ConstrainedBox(
499
+ constraints: const BoxConstraints(maxWidth: Insets.readingColumn),
500
+ child: ListView(
501
+ padding: const EdgeInsets.fromLTRB(Insets.page, 16, Insets.page, 120),
502
+ children: [
503
+ Row(
504
+ children: [
505
+ Icon(Icons.auto_awesome_rounded, size: 16, color: accent.color),
506
+ const SizedBox(width: 7),
507
+ Text('DEEP RESEARCH PROMPT',
508
+ style: Brand.label(size: 11, color: accent.color, weight: FontWeight.w700)),
509
+ ],
510
+ ),
511
+ const SizedBox(height: 8),
512
+ Text('~$_tokenEstimate tokens · paste into ChatGPT or Gemini',
513
+ style: theme.textTheme.bodySmall
514
+ ?.copyWith(color: scheme.onSurfaceVariant)),
515
+ const SizedBox(height: 16),
516
+ Container(
517
+ padding: const EdgeInsets.all(16),
518
+ decoration: BoxDecoration(
519
+ color: scheme.surfaceContainerLow,
520
+ borderRadius: BorderRadius.circular(Insets.radius),
521
+ border: Border.all(color: scheme.outlineVariant),
522
+ ),
523
+ child: SelectableText(
524
+ prompt,
525
+ style: theme.textTheme.bodyMedium?.copyWith(
526
+ fontFamily: 'monospace',
527
+ height: 1.5,
528
+ ),
529
+ ),
530
+ ),
531
+ ],
532
+ ),
533
+ ),
534
+ ),
535
+ bottomSheet: Padding(
536
+ padding: const EdgeInsets.fromLTRB(Insets.page, 8, Insets.page, 20),
537
+ child: Row(
538
+ children: [
539
+ Expanded(
540
+ child: FilledButton.icon(
541
+ style: FilledButton.styleFrom(
542
+ backgroundColor: accent.color,
543
+ foregroundColor: Colors.white,
544
+ padding: const EdgeInsets.symmetric(vertical: 16),
545
+ ),
546
+ onPressed: () => _copy(context),
547
+ icon: const Icon(Icons.copy_rounded, size: 18),
548
+ label: const Text('Copy prompt'),
549
+ ),
550
+ ),
551
+ const SizedBox(width: 12),
552
+ IconButton.filledTonal(
553
+ onPressed: () => _copy(context),
554
+ icon: const Icon(Icons.ios_share_rounded),
555
+ ),
556
+ ],
557
+ ),
558
+ ),
559
+ );
560
+ }
561
+ }
app/lib/ui/features/reader/views/reader_screen.dart CHANGED
@@ -20,9 +20,11 @@ import '../../../core/theme.dart';
20
  import '../../../core/widgets/card_face.dart';
21
  import '../../../core/widgets/error_state.dart';
22
  import '../../../core/widgets/pipeline_progress.dart';
 
23
  import '../../blocks/block_renderer.dart';
24
  import '../../catalog/services/artifact_lookup.dart';
25
  import '../view_models/reader_view_model.dart';
 
26
  import 'primary_action_bar.dart';
27
 
28
  class ReaderScreen extends StatelessWidget {
@@ -122,6 +124,19 @@ class _ReaderView extends StatelessWidget {
122
  // Action items (docs/13): follow into the Actions hub, tick off.
123
  if (card.isReady && card.actionItems.isPresent)
124
  _ActionItemsSection(card: card, accent: accent, vm: vm),
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  // Referenced things (books/products/places) as tappable covers.
126
  if (card.isReady) _ReferencesStrip(entries: vm.artifacts),
127
  if (card.source.creator != null) ...[
@@ -140,6 +155,25 @@ class _ReaderView extends StatelessWidget {
140
  );
141
  }
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  void _copyUrl(BuildContext context, String url) {
144
  Clipboard.setData(ClipboardData(text: url));
145
  ScaffoldMessenger.of(context).showSnackBar(
@@ -270,9 +304,13 @@ class _ProcessingPanel extends StatelessWidget {
270
  child: Column(
271
  crossAxisAlignment: CrossAxisAlignment.start,
272
  children: [
273
- Text('Building your card',
274
- style: Theme.of(context).textTheme.titleMedium),
275
- const SizedBox(height: 16),
 
 
 
 
276
  PipelineProgress(current: stage, detail: vm.lastEvent?.detail ?? ''),
277
  ],
278
  ),
@@ -363,7 +401,7 @@ class _ReferencesStrip extends StatelessWidget {
363
  ?.copyWith(fontWeight: FontWeight.w700)),
364
  const SizedBox(height: 12),
365
  SizedBox(
366
- height: 150,
367
  child: ListView.separated(
368
  scrollDirection: Axis.horizontal,
369
  itemCount: entries.length,
@@ -440,12 +478,14 @@ class _ReferenceTile extends StatelessWidget {
440
  ),
441
  ),
442
  const SizedBox(height: 5),
443
- Text(
444
- entry.title,
445
- maxLines: 2,
446
- overflow: TextOverflow.ellipsis,
447
- style: theme.textTheme.labelSmall
448
- ?.copyWith(fontWeight: FontWeight.w600, height: 1.15),
 
 
449
  ),
450
  ],
451
  ),
 
20
  import '../../../core/widgets/card_face.dart';
21
  import '../../../core/widgets/error_state.dart';
22
  import '../../../core/widgets/pipeline_progress.dart';
23
+ import '../../../core/widgets/processing_glyph.dart';
24
  import '../../blocks/block_renderer.dart';
25
  import '../../catalog/services/artifact_lookup.dart';
26
  import '../view_models/reader_view_model.dart';
27
+ import 'insight_section.dart';
28
  import 'primary_action_bar.dart';
29
 
30
  class ReaderScreen extends StatelessWidget {
 
124
  // Action items (docs/13): follow into the Actions hub, tick off.
125
  if (card.isReady && card.actionItems.isPresent)
126
  _ActionItemsSection(card: card, accent: accent, vm: vm),
127
+ // Deep-analysis layer (docs/14): claims, blind spots, rabbit
128
+ // holes, topic map, deep-research prompt. Present only on
129
+ // idea-rich cards — a simple reel renders nothing here.
130
+ if (card.isReady &&
131
+ card.insight != null &&
132
+ card.insight!.hasContent)
133
+ InsightSection(
134
+ insight: card.insight!,
135
+ accent: accent,
136
+ cardId: card.cardId,
137
+ cardTitle: card.base.oneLiner,
138
+ readMinutes: _estimateReadMinutes(card),
139
+ ),
140
  // Referenced things (books/products/places) as tappable covers.
141
  if (card.isReady) _ReferencesStrip(entries: vm.artifacts),
142
  if (card.source.creator != null) ...[
 
155
  );
156
  }
157
 
158
+ /// Rough read time of the card body (~200 wpm) from its text fields — drives
159
+ /// the insight stat trio. Counts the tldr plus all block text content.
160
+ int _estimateReadMinutes(model.Card card) {
161
+ var words = card.base.tldr.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).length;
162
+ for (final b in card.rawBlocks) {
163
+ void add(Object? v) {
164
+ if (v is String) words += v.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).length;
165
+ }
166
+ add(b['text']);
167
+ for (final it in (b['items'] as List?) ?? const []) {
168
+ add(it is Map ? it['text'] : it);
169
+ }
170
+ for (final st in (b['steps'] as List?) ?? const []) {
171
+ add(st is Map ? st['text'] : st);
172
+ }
173
+ }
174
+ return (words / 200).ceil();
175
+ }
176
+
177
  void _copyUrl(BuildContext context, String url) {
178
  Clipboard.setData(ClipboardData(text: url));
179
  ScaffoldMessenger.of(context).showSnackBar(
 
304
  child: Column(
305
  crossAxisAlignment: CrossAxisAlignment.start,
306
  children: [
307
+ const Center(child: ProcessingGlyph(size: 116)),
308
+ const SizedBox(height: 18),
309
+ Center(
310
+ child: Text('Building your card',
311
+ style: Theme.of(context).textTheme.titleMedium),
312
+ ),
313
+ const SizedBox(height: 18),
314
  PipelineProgress(current: stage, detail: vm.lastEvent?.detail ?? ''),
315
  ],
316
  ),
 
401
  ?.copyWith(fontWeight: FontWeight.w700)),
402
  const SizedBox(height: 12),
403
  SizedBox(
404
+ height: 168,
405
  child: ListView.separated(
406
  scrollDirection: Axis.horizontal,
407
  itemCount: entries.length,
 
478
  ),
479
  ),
480
  const SizedBox(height: 5),
481
+ Flexible(
482
+ child: Text(
483
+ entry.title,
484
+ maxLines: 2,
485
+ overflow: TextOverflow.ellipsis,
486
+ style: theme.textTheme.labelSmall
487
+ ?.copyWith(fontWeight: FontWeight.w600, height: 1.15),
488
+ ),
489
  ),
490
  ],
491
  ),
app/lib/ui/features/search/views/search_screen.dart CHANGED
@@ -10,9 +10,12 @@ import 'package:provider/provider.dart';
10
 
11
  import '../../../../data/repositories/card_repository.dart';
12
  import '../../../../domain/models/card.dart' as model;
 
 
13
  import '../../../core/theme.dart';
14
  import '../../../core/widgets/empty_state.dart';
15
  import '../../../core/widgets/error_state.dart';
 
16
  import '../../library/views/card_tile.dart';
17
  import '../../reader/views/reader_screen.dart';
18
 
@@ -31,6 +34,7 @@ class _SearchScreenState extends State<SearchScreen> {
31
  _Status _status = _Status.idle;
32
  List<model.Card> _results = const [];
33
  String _query = '';
 
34
 
35
  void _onChanged(String value) {
36
  _query = value.trim();
@@ -50,6 +54,7 @@ class _SearchScreenState extends State<SearchScreen> {
50
  if (!mounted) return;
51
  setState(() {
52
  _results = cards;
 
53
  _status = cards.isEmpty ? _Status.empty : _Status.results;
54
  });
55
  } catch (_) {
@@ -58,6 +63,20 @@ class _SearchScreenState extends State<SearchScreen> {
58
  }
59
  }
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  @override
62
  void dispose() {
63
  _debounce?.cancel();
@@ -116,34 +135,146 @@ class _SearchScreenState extends State<SearchScreen> {
116
  return EmptyState(
117
  icon: Icons.search_off_rounded,
118
  title: 'No matches',
119
- message: 'Nothing matched "$_query". Try a different word.',
 
 
120
  );
121
  case _Status.results:
122
  final api = context.read<CardRepository>().api;
123
  final cols = (MediaQuery.of(context).size.width / 200).floor().clamp(2, 5);
124
- return GridView.builder(
125
- padding: const EdgeInsets.all(Insets.page),
126
- gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
127
- crossAxisCount: cols,
128
- mainAxisSpacing: Insets.block,
129
- crossAxisSpacing: Insets.block,
130
- childAspectRatio: 0.72,
131
- ),
132
- itemCount: _results.length,
133
- itemBuilder: (_, i) {
134
- final card = _results[i];
135
- return CardTile(
136
- card: card,
137
- api: api,
138
- onTap: () => Navigator.of(context).push(
139
- MaterialPageRoute(
140
- builder: (_) => ReaderScreen(cardId: card.cardId),
 
 
 
141
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  ),
143
- onDelete: () {},
144
- );
145
- },
146
  );
147
  }
148
  }
149
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  import '../../../../data/repositories/card_repository.dart';
12
  import '../../../../domain/models/card.dart' as model;
13
+ import '../../../../domain/models/enums.dart';
14
+ import '../../../core/brand.dart';
15
  import '../../../core/theme.dart';
16
  import '../../../core/widgets/empty_state.dart';
17
  import '../../../core/widgets/error_state.dart';
18
+ import '../../capture/views/capture_sheet.dart';
19
  import '../../library/views/card_tile.dart';
20
  import '../../reader/views/reader_screen.dart';
21
 
 
34
  _Status _status = _Status.idle;
35
  List<model.Card> _results = const [];
36
  String _query = '';
37
+ ContentType? _filter; // null = All
38
 
39
  void _onChanged(String value) {
40
  _query = value.trim();
 
54
  if (!mounted) return;
55
  setState(() {
56
  _results = cards;
57
+ _filter = null; // reset filter on a fresh query
58
  _status = cards.isEmpty ? _Status.empty : _Status.results;
59
  });
60
  } catch (_) {
 
63
  }
64
  }
65
 
66
+ /// Content types actually present in the current results — only offer filters
67
+ /// that match something, in the order they appear.
68
+ List<ContentType> get _presentTypes {
69
+ final seen = <ContentType>[];
70
+ for (final c in _results) {
71
+ if (!seen.contains(c.base.contentType)) seen.add(c.base.contentType);
72
+ }
73
+ return seen;
74
+ }
75
+
76
+ List<model.Card> get _filtered => _filter == null
77
+ ? _results
78
+ : _results.where((c) => c.base.contentType == _filter).toList();
79
+
80
  @override
81
  void dispose() {
82
  _debounce?.cancel();
 
135
  return EmptyState(
136
  icon: Icons.search_off_rounded,
137
  title: 'No matches',
138
+ message: 'Nothing matched "$_query". Try a different word, or capture a reel about it.',
139
+ actionLabel: 'Capture a reel',
140
+ onAction: () => showCaptureSheet(context),
141
  );
142
  case _Status.results:
143
  final api = context.read<CardRepository>().api;
144
  final cols = (MediaQuery.of(context).size.width / 200).floor().clamp(2, 5);
145
+ final cards = _filtered;
146
+ return Column(
147
+ crossAxisAlignment: CrossAxisAlignment.stretch,
148
+ children: [
149
+ _FilterBar(
150
+ types: _presentTypes,
151
+ selected: _filter,
152
+ total: _results.length,
153
+ shown: cards.length,
154
+ onSelect: (t) => setState(() => _filter = t),
155
+ ),
156
+ Expanded(
157
+ child: GridView.builder(
158
+ padding: const EdgeInsets.fromLTRB(
159
+ Insets.page, 4, Insets.page, Insets.page),
160
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
161
+ crossAxisCount: cols,
162
+ mainAxisSpacing: Insets.block,
163
+ crossAxisSpacing: Insets.block,
164
+ childAspectRatio: 0.72,
165
  ),
166
+ itemCount: cards.length,
167
+ itemBuilder: (_, i) {
168
+ final card = cards[i];
169
+ return CardTile(
170
+ card: card,
171
+ api: api,
172
+ onTap: () => Navigator.of(context).push(
173
+ MaterialPageRoute(
174
+ builder: (_) => ReaderScreen(cardId: card.cardId),
175
+ ),
176
+ ),
177
+ onDelete: () {},
178
+ );
179
+ },
180
  ),
181
+ ),
182
+ ],
 
183
  );
184
  }
185
  }
186
  }
187
+
188
+ /// Result count + content-type filter pills above the result wall. Only shows
189
+ /// filters for types present in the results (docs/06 search).
190
+ class _FilterBar extends StatelessWidget {
191
+ const _FilterBar({
192
+ required this.types,
193
+ required this.selected,
194
+ required this.total,
195
+ required this.shown,
196
+ required this.onSelect,
197
+ });
198
+
199
+ final List<ContentType> types;
200
+ final ContentType? selected;
201
+ final int total;
202
+ final int shown;
203
+ final ValueChanged<ContentType?> onSelect;
204
+
205
+ @override
206
+ Widget build(BuildContext context) {
207
+ final theme = Theme.of(context);
208
+ final scheme = theme.colorScheme;
209
+ final count = selected == null ? total : shown;
210
+ return Column(
211
+ crossAxisAlignment: CrossAxisAlignment.start,
212
+ children: [
213
+ Padding(
214
+ padding: const EdgeInsets.fromLTRB(Insets.page, 10, Insets.page, 8),
215
+ child: Text(
216
+ '$count ${count == 1 ? 'result' : 'results'}',
217
+ style: Brand.label(
218
+ size: 11, color: scheme.onSurfaceVariant, weight: FontWeight.w700),
219
+ ),
220
+ ),
221
+ SizedBox(
222
+ height: 38,
223
+ child: ListView(
224
+ scrollDirection: Axis.horizontal,
225
+ padding: const EdgeInsets.symmetric(horizontal: Insets.page),
226
+ children: [
227
+ _Chip(
228
+ label: 'All',
229
+ selected: selected == null,
230
+ onTap: () => onSelect(null),
231
+ ),
232
+ for (final t in types)
233
+ _Chip(
234
+ label: t.label,
235
+ selected: selected == t,
236
+ onTap: () => onSelect(t),
237
+ ),
238
+ ],
239
+ ),
240
+ ),
241
+ ],
242
+ );
243
+ }
244
+ }
245
+
246
+ class _Chip extends StatelessWidget {
247
+ const _Chip({required this.label, required this.selected, required this.onTap});
248
+ final String label;
249
+ final bool selected;
250
+ final VoidCallback onTap;
251
+
252
+ @override
253
+ Widget build(BuildContext context) {
254
+ final scheme = Theme.of(context).colorScheme;
255
+ return Padding(
256
+ padding: const EdgeInsets.only(right: 8),
257
+ child: GestureDetector(
258
+ onTap: onTap,
259
+ child: Container(
260
+ alignment: Alignment.center,
261
+ padding: const EdgeInsets.symmetric(horizontal: 14),
262
+ decoration: BoxDecoration(
263
+ color: selected ? scheme.primary : scheme.surfaceContainerHigh,
264
+ borderRadius: BorderRadius.circular(999),
265
+ border: Border.all(
266
+ color: selected ? scheme.primary : scheme.outlineVariant),
267
+ ),
268
+ child: Text(
269
+ label,
270
+ style: Brand.label(
271
+ size: 11,
272
+ color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
273
+ weight: FontWeight.w700,
274
+ ),
275
+ ),
276
+ ),
277
+ ),
278
+ );
279
+ }
280
+ }
app/lib/ui/features/share/views/share_screen.dart CHANGED
@@ -15,6 +15,7 @@ import '../../../core/brand.dart';
15
  import '../../../core/theme.dart';
16
  import '../../../core/widgets/error_state.dart';
17
  import '../../../core/widgets/pipeline_progress.dart';
 
18
  import '../../reader/views/reader_screen.dart';
19
  import '../view_models/share_view_model.dart';
20
 
@@ -76,8 +77,8 @@ class _ShareView extends StatelessWidget {
76
  child: Column(
77
  mainAxisSize: MainAxisSize.min,
78
  children: [
79
- CircularProgressIndicator(color: theme.colorScheme.primary),
80
- const SizedBox(height: 16),
81
  const Text('Sending to Cachy…'),
82
  ],
83
  ),
@@ -125,6 +126,8 @@ class _ShareView extends StatelessWidget {
125
  return Column(
126
  crossAxisAlignment: CrossAxisAlignment.start,
127
  children: [
 
 
128
  Text('Building your card', style: theme.textTheme.headlineSmall),
129
  const SizedBox(height: 8),
130
  Text(
 
15
  import '../../../core/theme.dart';
16
  import '../../../core/widgets/error_state.dart';
17
  import '../../../core/widgets/pipeline_progress.dart';
18
+ import '../../../core/widgets/processing_glyph.dart';
19
  import '../../reader/views/reader_screen.dart';
20
  import '../view_models/share_view_model.dart';
21
 
 
77
  child: Column(
78
  mainAxisSize: MainAxisSize.min,
79
  children: [
80
+ const ProcessingGlyph(size: 132),
81
+ const SizedBox(height: 20),
82
  const Text('Sending to Cachy…'),
83
  ],
84
  ),
 
126
  return Column(
127
  crossAxisAlignment: CrossAxisAlignment.start,
128
  children: [
129
+ const Center(child: ProcessingGlyph(size: 132)),
130
+ const SizedBox(height: 24),
131
  Text('Building your card', style: theme.textTheme.headlineSmall),
132
  const SizedBox(height: 8),
133
  Text(
backend/app/models/card.py CHANGED
@@ -13,7 +13,7 @@ from typing import Annotated, Literal, Optional, Union
13
 
14
  from pydantic import BaseModel, Field
15
 
16
- SCHEMA_VERSION = "1.3" # 1.1: artifacts list (docs/12); 1.2: base.tags (docs/09); 1.3: action_items (docs/13)
17
 
18
 
19
  # --------------------------------------------------------------------------- #
@@ -217,6 +217,50 @@ class ActionItems(BaseModel):
217
  items: list[ActionItem] = Field(default_factory=list)
218
 
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  class Media(BaseModel):
221
  thumbnail: Optional[str] = None
222
  keyframes: list[str] = Field(default_factory=list)
@@ -246,5 +290,7 @@ class Card(BaseModel):
246
  primary_action: PrimaryAction = Field(default_factory=PrimaryAction)
247
  action_items: ActionItems = Field(default_factory=ActionItems)
248
  blocks: list[Block] = Field(default_factory=list)
 
 
249
  media: Media = Field(default_factory=Media)
250
  meta: Meta = Field(default_factory=Meta)
 
13
 
14
  from pydantic import BaseModel, Field
15
 
16
+ SCHEMA_VERSION = "1.4" # 1.1: artifacts list (docs/12); 1.2: base.tags (docs/09); 1.3: action_items (docs/13); 1.4: insight layer (docs/14)
17
 
18
 
19
  # --------------------------------------------------------------------------- #
 
217
  items: list[ActionItem] = Field(default_factory=list)
218
 
219
 
220
+ # --------------------------------------------------------------------------- #
221
+ # Insight layer (docs/14) — the optional "deep" analysis. Populated by a SECOND,
222
+ # gated LLM pass ONLY for knowledge-rich cards; a simple reel (recipe, a quick
223
+ # tip) carries `insight = None` and renders none of this. Within a deep card each
224
+ # sub-section is independently optional — emit only what the content warrants.
225
+ #
226
+ # Everything here is ACTIONABLE, never a passive list: rabbit-hole threads are
227
+ # tappable doorways into grounded chat, the topic map orients, and the research
228
+ # prompt is paste-ready. (Earlier Claims / What's-Missing sections were removed —
229
+ # read-only analysis with nothing to do.)
230
+ # --------------------------------------------------------------------------- #
231
+
232
+ class RabbitHole(BaseModel):
233
+ """Threads to pull on to go deeper. Each becomes a tappable prompt in the UI
234
+ (opens grounded chat). Each list is independently optional."""
235
+ questions: list[str] = Field(default_factory=list)
236
+ adjacent_topics: list[str] = Field(default_factory=list)
237
+ advanced_concepts: list[str] = Field(default_factory=list)
238
+
239
+ def is_empty(self) -> bool:
240
+ return not (self.questions or self.adjacent_topics or self.advanced_concepts)
241
+
242
+
243
+ class TopicMap(BaseModel):
244
+ """A single-hop concept map: one center idea + a few connected satellites."""
245
+ center: str
246
+ nodes: list[str] = Field(default_factory=list) # satellite labels
247
+
248
+
249
+ class Insight(BaseModel):
250
+ rabbit_hole: RabbitHole = Field(default_factory=RabbitHole)
251
+ topic_map: Optional[TopicMap] = None
252
+ # A ready-to-paste deep-research prompt for an external LLM (docs/14).
253
+ deep_research_prompt: Optional[str] = None
254
+
255
+ def has_content(self) -> bool:
256
+ """True when at least one sub-section is non-empty — the gate the worker
257
+ and clients use to decide whether to attach/render the layer at all."""
258
+ return bool(
259
+ not self.rabbit_hole.is_empty()
260
+ or self.topic_map or self.deep_research_prompt
261
+ )
262
+
263
+
264
  class Media(BaseModel):
265
  thumbnail: Optional[str] = None
266
  keyframes: list[str] = Field(default_factory=list)
 
290
  primary_action: PrimaryAction = Field(default_factory=PrimaryAction)
291
  action_items: ActionItems = Field(default_factory=ActionItems)
292
  blocks: list[Block] = Field(default_factory=list)
293
+ # Deep analysis (docs/14). None for simple cards; only the gated 2nd pass fills it.
294
+ insight: Optional[Insight] = None
295
  media: Media = Field(default_factory=Media)
296
  meta: Meta = Field(default_factory=Meta)
backend/app/pipeline/insight.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Insight analysis (docs/14): the GATED second pass. Runs ONLY for cards the
2
+ structuring pass judged `depth == "deep"`. One text-only LLM call turns the card's
3
+ own summary + body into a reasoning layer — claims, blind spots, rabbit holes, a
4
+ small topic map, and a ready-to-paste deep-research prompt.
5
+
6
+ Same discipline as structuring: never trust the model. Every field is validated
7
+ and independently optional; a malformed or empty result yields `None` (no layer),
8
+ never a crash. A simple reel never reaches this module.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+
16
+ from app.models.card import (
17
+ Insight,
18
+ RabbitHole,
19
+ TopicMap,
20
+ )
21
+ from app.pipeline.structuring import complete, _strip_fences
22
+
23
+ log = logging.getLogger("pipeline.insight")
24
+
25
+ _MAX = {
26
+ "questions": 6,
27
+ "adjacent_topics": 6,
28
+ "advanced_concepts": 5,
29
+ "nodes": 6,
30
+ }
31
+
32
+ _PROMPT = """You are a sharp analyst. You are given a knowledge card distilled from a
33
+ short-form video on an idea-rich topic. Produce a DEEP ANALYSIS layer — the
34
+ scaffolding a curious person would use to go FURTHER than the video. Everything
35
+ must be something the reader can act on, not passive commentary.
36
+
37
+ Return ONLY a JSON object (no prose, no markdown fences) with this exact shape:
38
+ {{
39
+ "rabbit_hole": {{ // threads to go deeper; each becomes a tappable
40
+ // question the reader can ask an AI. Each list may be empty.
41
+ "questions": [str], // sharp open questions the content raises
42
+ "adjacent_topics": [str], // neighbouring subjects worth exploring
43
+ "advanced_concepts": [str] // next-level ideas for someone who gets the basics
44
+ }},
45
+ "topic_map": {{ // a one-hop concept map, or null if not meaningful
46
+ "center": str, // the core idea (1-3 words)
47
+ "nodes": [str] // 3-6 connected concepts (1-3 words each)
48
+ }},
49
+ "deep_research_prompt": str // a ready-to-paste prompt for an external LLM (see below)
50
+ }}
51
+
52
+ Rules:
53
+ - Ground EVERY item in the card's actual content. Do not invent. If the card is
54
+ thin, return fewer items — an empty list is correct when there is nothing real
55
+ to say. Never pad.
56
+ - rabbit_hole: phrase questions/topics so they make sense as a prompt the reader
57
+ taps to ask an AI (e.g. "How does compound interest differ from simple
58
+ interest?", "Behavioural economics of saving"). Concrete, specific, self-contained.
59
+ - topic_map: only when the content genuinely connects several concepts. If it is a
60
+ single narrow point, set "topic_map": null.
61
+ - deep_research_prompt: a structured, rigorous research brief (research objectives,
62
+ desired output sections, constraints) an expert could paste into a frontier LLM to
63
+ go far beyond this video. Plain text, ~150-350 words, no markdown headers. Omit it
64
+ (use null) only if the topic does not reward independent research.
65
+ - Output strict JSON. No commentary.
66
+
67
+ Card:
68
+ ---
69
+ {card}
70
+ ---
71
+ """
72
+
73
+
74
+ def _card_digest(one_liner: str, tldr: str, body: str, tags: list[str]) -> str:
75
+ """A compact text view of the card for the analysis prompt."""
76
+ parts = []
77
+ if one_liner:
78
+ parts.append(f"ONE-LINER: {one_liner}")
79
+ if tldr:
80
+ parts.append(f"SUMMARY: {tldr}")
81
+ if tags:
82
+ parts.append("TAGS: " + ", ".join(tags))
83
+ if body:
84
+ parts.append("BODY:\n" + body[:4000])
85
+ return "\n\n".join(parts).strip()
86
+
87
+
88
+ # --------------------------------------------------------------------------- #
89
+ # Validation — never trust the model
90
+ # --------------------------------------------------------------------------- #
91
+
92
+ def _clean_str(value, limit: int = 400) -> str:
93
+ if not isinstance(value, str):
94
+ return ""
95
+ return " ".join(value.split()).strip()[:limit]
96
+
97
+
98
+ def _str_list(raw, cap: int) -> list[str]:
99
+ out: list[str] = []
100
+ if not isinstance(raw, list):
101
+ return out
102
+ seen: set[str] = set()
103
+ for item in raw:
104
+ text = _clean_str(item)
105
+ if not text or text.lower() in seen:
106
+ continue
107
+ seen.add(text.lower())
108
+ out.append(text)
109
+ if len(out) >= cap:
110
+ break
111
+ return out
112
+
113
+
114
+ def _validate(raw_text: str) -> Insight | None:
115
+ try:
116
+ data = json.loads(_strip_fences(raw_text))
117
+ except (json.JSONDecodeError, TypeError):
118
+ log.warning("insight: LLM output was not valid JSON -> no insight layer")
119
+ return None
120
+ if not isinstance(data, dict):
121
+ return None
122
+
123
+ rh_raw = data.get("rabbit_hole") if isinstance(data.get("rabbit_hole"), dict) else {}
124
+ rabbit_hole = RabbitHole(
125
+ questions=_str_list(rh_raw.get("questions"), _MAX["questions"]),
126
+ adjacent_topics=_str_list(rh_raw.get("adjacent_topics"), _MAX["adjacent_topics"]),
127
+ advanced_concepts=_str_list(
128
+ rh_raw.get("advanced_concepts"), _MAX["advanced_concepts"]
129
+ ),
130
+ )
131
+
132
+ topic_map = None
133
+ tm_raw = data.get("topic_map")
134
+ if isinstance(tm_raw, dict):
135
+ center = _clean_str(tm_raw.get("center"), limit=60)
136
+ nodes = _str_list(tm_raw.get("nodes"), _MAX["nodes"])
137
+ if center and len(nodes) >= 2: # a center with <2 satellites is not a map
138
+ topic_map = TopicMap(center=center, nodes=nodes)
139
+
140
+ deep_research_prompt = _clean_str(data.get("deep_research_prompt"), limit=4000) or None
141
+
142
+ insight = Insight(
143
+ rabbit_hole=rabbit_hole,
144
+ topic_map=topic_map,
145
+ deep_research_prompt=deep_research_prompt,
146
+ )
147
+ # Drop a layer that came back empty after validation — render nothing rather
148
+ # than an empty shell (docs/14).
149
+ return insight if insight.has_content() else None
150
+
151
+
152
+ # --------------------------------------------------------------------------- #
153
+ # Entry point
154
+ # --------------------------------------------------------------------------- #
155
+
156
+ def analyze(one_liner: str, tldr: str, body: str, tags: list[str]) -> Insight | None:
157
+ """Run the gated second pass. Returns a validated Insight, or None when the
158
+ backend is unavailable / the model returns nothing usable. Caller (worker)
159
+ treats None as "no insight layer" — never an error."""
160
+ card = _card_digest(one_liner, tldr, body, tags)
161
+ if not card:
162
+ return None
163
+ raw = complete(_PROMPT.format(card=card), max_tokens=4096)
164
+ if not raw:
165
+ log.info("insight: no LLM output -> skipping insight layer")
166
+ return None
167
+ return _validate(raw)
168
+
169
+
170
+ async def analyze_async(
171
+ one_liner: str, tldr: str, body: str, tags: list[str]
172
+ ) -> Insight | None:
173
+ import asyncio
174
+
175
+ return await asyncio.to_thread(analyze, one_liner, tldr, body, tags)
backend/app/pipeline/structuring.py CHANGED
@@ -69,6 +69,7 @@ class StructuredCard:
69
  primary_action: PrimaryAction,
70
  artifacts: list[Artifact] | None = None,
71
  action_items: ActionItems | None = None,
 
72
  degraded: bool = False,
73
  degraded_reason: str = "",
74
  ):
@@ -76,6 +77,10 @@ class StructuredCard:
76
  self.blocks = blocks # list of plain dicts (validated), ready for JSON column
77
  self.primary_action = primary_action
78
  self.artifacts = artifacts or [] # referenced things for the catalog (docs/12)
 
 
 
 
79
  # Concrete to-dos the video tells the viewer to do (docs/13). Inert
80
  # (followed=False) at ingestion; the user opts a card into the hub later.
81
  self.action_items = action_items or ActionItems()
@@ -140,7 +145,8 @@ Return ONLY a JSON object (no prose, no markdown fences) with this exact shape:
140
  ],
141
  "action_items": [ // concrete things the viewer should DO (may be empty)
142
  str // one short imperative task, e.g. "Batch emails into two daily windows"
143
- ]
 
144
  }}
145
 
146
  Rules:
@@ -161,6 +167,13 @@ Rules:
161
  Short imperative phrases, max ~8. These are the takeaways to act on, NOT a
162
  restatement of every step in a recipe/tutorial. Return an empty list if the
163
  video is purely informational with nothing to act on.
 
 
 
 
 
 
 
164
 
165
  {vocab}
166
 
@@ -175,62 +188,66 @@ Extracted text bundle:
175
  # LLM call (selectable backend: huggingface | groq)
176
  # --------------------------------------------------------------------------- #
177
 
178
- def _call_llm(bundle: str) -> str | None:
179
- """Dispatch to the configured structuring backend. Any failure -> None,
180
- which the caller turns into a paragraph fallback."""
 
181
  settings = get_settings()
182
  if settings.hf_enabled:
183
- out = _call_hf(bundle)
184
  if out:
185
  return out
186
  # HF's free router 504s on long generations (e.g. big carousels). Rather
187
- # than drop straight to a paragraph, fall back to Groq when a key exists.
188
  if settings.groq_api_key.strip():
189
- log.info("structuring: HF backend failed; falling back to Groq")
190
- return _call_groq(bundle)
191
  return None
192
  if settings.groq_llm_enabled:
193
- return _call_groq(bundle)
194
  return None
195
 
196
 
197
- def _call_hf(bundle: str) -> str | None:
 
 
 
 
 
198
  settings = get_settings()
199
  try:
200
  from huggingface_hub import InferenceClient
201
 
202
  client = InferenceClient(api_key=settings.hf_api_key)
203
- prompt = _PROMPT.format(vocab=_VOCAB_SPEC, bundle=bundle)
204
  resp = client.chat_completion(
205
  model=settings.hf_model, # free Inference Providers, e.g. Qwen2.5-72B-Instruct
206
  messages=[{"role": "user", "content": prompt}],
207
- temperature=0.2,
208
- max_tokens=8192, # rich carousels (e.g. 140-item lists) overflow a smaller cap
209
  )
210
  text = resp.choices[0].message.content if resp.choices else ""
211
  return (text or "").strip()
212
  except Exception as e:
213
- log.warning("structuring call (huggingface) failed: %s", e)
214
  return None
215
 
216
 
217
- def _call_groq(bundle: str) -> str | None:
218
  settings = get_settings()
219
  try:
220
  from groq import Groq
221
 
222
  client = Groq(api_key=settings.groq_api_key)
223
- prompt = _PROMPT.format(vocab=_VOCAB_SPEC, bundle=bundle)
224
  resp = client.chat.completions.create(
225
  model=settings.groq_llm_model, # free tier; default llama-3.3-70b-versatile
226
  messages=[{"role": "user", "content": prompt}],
227
- temperature=0.2,
228
- max_tokens=8192, # rich carousels (e.g. 140-item lists) overflow a smaller cap
229
  )
230
  text = resp.choices[0].message.content if resp.choices else ""
231
  return (text or "").strip()
232
  except Exception as e:
233
- log.warning("structuring call (groq) failed: %s", e)
234
  return None
235
 
236
 
@@ -417,6 +434,7 @@ def _validate(raw_text: str, bundle: str, transcript: str, caption: str) -> "Str
417
 
418
  artifacts = _coerce_artifacts(data.get("artifacts") or [])
419
  action_items = _coerce_action_items(data.get("action_items") or [])
 
420
 
421
  blocks = _coerce_blocks(data.get("blocks") or [])
422
  if not blocks:
@@ -434,7 +452,7 @@ def _validate(raw_text: str, bundle: str, transcript: str, caption: str) -> "Str
434
 
435
  return StructuredCard(
436
  base, blocks, _primary_action_for(base.content_type), artifacts,
437
- action_items=action_items,
438
  )
439
 
440
 
 
69
  primary_action: PrimaryAction,
70
  artifacts: list[Artifact] | None = None,
71
  action_items: ActionItems | None = None,
72
+ depth: str = "shallow",
73
  degraded: bool = False,
74
  degraded_reason: str = "",
75
  ):
 
77
  self.blocks = blocks # list of plain dicts (validated), ready for JSON column
78
  self.primary_action = primary_action
79
  self.artifacts = artifacts or [] # referenced things for the catalog (docs/12)
80
+ # Gate for the 2nd pass (docs/14): "deep" => run insight analysis;
81
+ # "shallow" => simple card, no extra LLM call. The model judges; the worker
82
+ # acts. Never block the card on this — default shallow.
83
+ self.depth = depth if depth in ("shallow", "deep") else "shallow"
84
  # Concrete to-dos the video tells the viewer to do (docs/13). Inert
85
  # (followed=False) at ingestion; the user opts a card into the hub later.
86
  self.action_items = action_items or ActionItems()
 
145
  ],
146
  "action_items": [ // concrete things the viewer should DO (may be empty)
147
  str // one short imperative task, e.g. "Batch emails into two daily windows"
148
+ ],
149
+ "depth": "shallow|deep" // does this content warrant deep analysis? (see below)
150
  }}
151
 
152
  Rules:
 
167
  Short imperative phrases, max ~8. These are the takeaways to act on, NOT a
168
  restatement of every step in a recipe/tutorial. Return an empty list if the
169
  video is purely informational with nothing to act on.
170
+ - depth: judge whether this content rewards DEEPER analysis (claims to fact-check,
171
+ unstated assumptions, open questions, a web of related concepts). Answer "deep"
172
+ ONLY for idea-rich, knowledge-heavy, or argumentative content (e.g. a science
173
+ explainer, an investing thesis, a psychology breakdown, a contested take).
174
+ Answer "shallow" for practical/procedural or lightweight content with nothing
175
+ to interrogate (a recipe, a workout, a product list, a quick how-to, a meme).
176
+ When unsure, answer "shallow". Most videos are shallow.
177
 
178
  {vocab}
179
 
 
188
  # LLM call (selectable backend: huggingface | groq)
189
  # --------------------------------------------------------------------------- #
190
 
191
+ def complete(prompt: str, *, max_tokens: int = 8192, temperature: float = 0.2) -> str | None:
192
+ """Generic single-prompt completion via the configured backend (HF, with a
193
+ Groq fallback). Shared by the structuring pass and the gated insight pass
194
+ (docs/14). Any failure -> None; callers decide how to degrade."""
195
  settings = get_settings()
196
  if settings.hf_enabled:
197
+ out = _call_hf(prompt, max_tokens, temperature)
198
  if out:
199
  return out
200
  # HF's free router 504s on long generations (e.g. big carousels). Rather
201
+ # than drop straight to failure, fall back to Groq when a key exists.
202
  if settings.groq_api_key.strip():
203
+ log.info("llm: HF backend failed; falling back to Groq")
204
+ return _call_groq(prompt, max_tokens, temperature)
205
  return None
206
  if settings.groq_llm_enabled:
207
+ return _call_groq(prompt, max_tokens, temperature)
208
  return None
209
 
210
 
211
+ def _call_llm(bundle: str) -> str | None:
212
+ """Structuring (pass 1): format the card prompt and complete it."""
213
+ return complete(_PROMPT.format(vocab=_VOCAB_SPEC, bundle=bundle))
214
+
215
+
216
+ def _call_hf(prompt: str, max_tokens: int, temperature: float) -> str | None:
217
  settings = get_settings()
218
  try:
219
  from huggingface_hub import InferenceClient
220
 
221
  client = InferenceClient(api_key=settings.hf_api_key)
 
222
  resp = client.chat_completion(
223
  model=settings.hf_model, # free Inference Providers, e.g. Qwen2.5-72B-Instruct
224
  messages=[{"role": "user", "content": prompt}],
225
+ temperature=temperature,
226
+ max_tokens=max_tokens, # rich carousels (e.g. 140-item lists) overflow a smaller cap
227
  )
228
  text = resp.choices[0].message.content if resp.choices else ""
229
  return (text or "").strip()
230
  except Exception as e:
231
+ log.warning("llm call (huggingface) failed: %s", e)
232
  return None
233
 
234
 
235
+ def _call_groq(prompt: str, max_tokens: int, temperature: float) -> str | None:
236
  settings = get_settings()
237
  try:
238
  from groq import Groq
239
 
240
  client = Groq(api_key=settings.groq_api_key)
 
241
  resp = client.chat.completions.create(
242
  model=settings.groq_llm_model, # free tier; default llama-3.3-70b-versatile
243
  messages=[{"role": "user", "content": prompt}],
244
+ temperature=temperature,
245
+ max_tokens=max_tokens, # rich carousels (e.g. 140-item lists) overflow a smaller cap
246
  )
247
  text = resp.choices[0].message.content if resp.choices else ""
248
  return (text or "").strip()
249
  except Exception as e:
250
+ log.warning("llm call (groq) failed: %s", e)
251
  return None
252
 
253
 
 
434
 
435
  artifacts = _coerce_artifacts(data.get("artifacts") or [])
436
  action_items = _coerce_action_items(data.get("action_items") or [])
437
+ depth = data.get("depth") if data.get("depth") in ("shallow", "deep") else "shallow"
438
 
439
  blocks = _coerce_blocks(data.get("blocks") or [])
440
  if not blocks:
 
452
 
453
  return StructuredCard(
454
  base, blocks, _primary_action_for(base.content_type), artifacts,
455
+ action_items=action_items, depth=depth,
456
  )
457
 
458
 
backend/app/pipeline/worker.py CHANGED
@@ -25,6 +25,7 @@ from app.pipeline.ingestion.downloader import (
25
  DownloaderConfig,
26
  download_content_async,
27
  )
 
28
  from app.pipeline.structuring import structure_async
29
  from app.services import artifact_images, embeddings, events, llm_chat, notify
30
  from app.store import db, media
@@ -105,6 +106,16 @@ async def _write_blocks(session, card_id: str, blocks: list[dict]) -> None:
105
  await session.commit()
106
 
107
 
 
 
 
 
 
 
 
 
 
 
108
  async def _write_media_and_meta(
109
  session, card_id: str, extraction, caption: str, resolver: str,
110
  creator: str | None = None,
@@ -291,6 +302,37 @@ async def _run_job(session, job: db.JobRow) -> None:
291
  creator=download.author,
292
  )
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  # 5) Catalog: aggregate any referenced artifacts + fetch their thumbnails.
295
  if structured.artifacts:
296
  events.publish(card_id, "cataloging", "processing", "Cataloging references")
 
25
  DownloaderConfig,
26
  download_content_async,
27
  )
28
+ from app.pipeline.insight import analyze_async
29
  from app.pipeline.structuring import structure_async
30
  from app.services import artifact_images, embeddings, events, llm_chat, notify
31
  from app.store import db, media
 
106
  await session.commit()
107
 
108
 
109
+ async def _write_insight(session, card_id: str, insight) -> None:
110
+ """Persist the deep-analysis layer (docs/14). `insight` is a validated model."""
111
+ await session.execute(
112
+ update(db.CardRow)
113
+ .where(db.CardRow.id == card_id)
114
+ .values(insight=insight.model_dump())
115
+ )
116
+ await session.commit()
117
+
118
+
119
  async def _write_media_and_meta(
120
  session, card_id: str, extraction, caption: str, resolver: str,
121
  creator: str | None = None,
 
302
  creator=download.author,
303
  )
304
 
305
+ # 4b) Deep analysis (docs/14) — GATED. Only idea-rich cards (the structuring
306
+ # pass judged `depth == "deep"`) get a second LLM call. A simple reel skips
307
+ # this entirely. Best-effort + isolated: a failure leaves insight=None and
308
+ # never blocks the card from going READY.
309
+ if structured.depth == "deep":
310
+ events.publish(card_id, "analyzing", "processing", "Analyzing in depth")
311
+ log.info("%s Step 4b deep-analysis: depth=deep -> running insight pass", tag)
312
+ try:
313
+ row = await db.get_card_row(session, card_id)
314
+ body = llm_chat.card_context(row.to_card()) if row else ""
315
+ insight = await analyze_async(
316
+ structured.base.one_liner, structured.base.tldr, body,
317
+ structured.base.tags,
318
+ )
319
+ if insight is not None:
320
+ await _write_insight(session, card_id, insight)
321
+ rh = insight.rabbit_hole
322
+ log.info(
323
+ "%s Step 4b deep-analysis OK | threads=%d topic_map=%s "
324
+ "deep_research=%s", tag,
325
+ len(rh.questions) + len(rh.adjacent_topics) + len(rh.advanced_concepts),
326
+ "yes" if insight.topic_map else "no",
327
+ "yes" if insight.deep_research_prompt else "no",
328
+ )
329
+ else:
330
+ log.info("%s Step 4b deep-analysis: no usable layer produced", tag)
331
+ except Exception: # noqa: BLE001 — insight is non-critical to the card
332
+ log.warning("%s Step 4b deep-analysis failed", tag, exc_info=True)
333
+ else:
334
+ log.info("%s Step 4b deep-analysis: depth=shallow, skipping", tag)
335
+
336
  # 5) Catalog: aggregate any referenced artifacts + fetch their thumbnails.
337
  if structured.artifacts:
338
  events.publish(card_id, "cataloging", "processing", "Cataloging references")
backend/app/store/db.py CHANGED
@@ -35,6 +35,7 @@ from app.models.card import (
35
  CardState,
36
  ExtractionFlags,
37
  FailureReason,
 
38
  Media,
39
  Meta,
40
  PrimaryAction,
@@ -81,6 +82,8 @@ class CardRow(Base):
81
  # {followed: bool, items: [{id, text, done}]} — the to-do list (docs/13).
82
  action_items: Mapped[dict | None] = mapped_column(JSON, nullable=True)
83
  blocks: Mapped[list] = mapped_column(JSON, default=list)
 
 
84
  thumbnail: Mapped[str | None] = mapped_column(String, nullable=True)
85
  keyframes: Mapped[list | None] = mapped_column(JSON, nullable=True)
86
  extraction: Mapped[dict | None] = mapped_column(JSON, nullable=True)
@@ -118,6 +121,7 @@ class CardRow(Base):
118
  primary_action=PrimaryAction(**(self.primary_action or {})),
119
  action_items=ActionItems(**(self.action_items or {})),
120
  blocks=self.blocks or [],
 
121
  media=Media(
122
  thumbnail=media_store.to_media_url(self.thumbnail),
123
  keyframes=[
@@ -218,7 +222,10 @@ async def init_db() -> None:
218
  await _add_missing_columns(
219
  conn,
220
  "cards",
221
- {"action_items": "JSON"}, # docs/13 — added in schema 1.3
 
 
 
222
  )
223
 
224
 
 
35
  CardState,
36
  ExtractionFlags,
37
  FailureReason,
38
+ Insight,
39
  Media,
40
  Meta,
41
  PrimaryAction,
 
82
  # {followed: bool, items: [{id, text, done}]} — the to-do list (docs/13).
83
  action_items: Mapped[dict | None] = mapped_column(JSON, nullable=True)
84
  blocks: Mapped[list] = mapped_column(JSON, default=list)
85
+ # Deep-analysis layer (docs/14): null for simple cards, filled by the gated pass.
86
+ insight: Mapped[dict | None] = mapped_column(JSON, nullable=True)
87
  thumbnail: Mapped[str | None] = mapped_column(String, nullable=True)
88
  keyframes: Mapped[list | None] = mapped_column(JSON, nullable=True)
89
  extraction: Mapped[dict | None] = mapped_column(JSON, nullable=True)
 
121
  primary_action=PrimaryAction(**(self.primary_action or {})),
122
  action_items=ActionItems(**(self.action_items or {})),
123
  blocks=self.blocks or [],
124
+ insight=Insight(**self.insight) if self.insight else None,
125
  media=Media(
126
  thumbnail=media_store.to_media_url(self.thumbnail),
127
  keyframes=[
 
222
  await _add_missing_columns(
223
  conn,
224
  "cards",
225
+ {
226
+ "action_items": "JSON", # docs/13 — added in schema 1.3
227
+ "insight": "JSON", # docs/14 — added in schema 1.4
228
+ },
229
  )
230
 
231
 
backend/tests/test_api.py CHANGED
@@ -18,7 +18,7 @@ async def client(database):
18
  async def test_health(client):
19
  r = await client.get("/health")
20
  assert r.status_code == 200
21
- assert r.json()["schema_version"] == "1.3"
22
 
23
 
24
  async def test_create_returns_id_and_queued(client):
 
18
  async def test_health(client):
19
  r = await client.get("/health")
20
  assert r.status_code == 200
21
+ assert r.json()["schema_version"] == "1.4"
22
 
23
 
24
  async def test_create_returns_id_and_queued(client):