learnx-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. learnx_cli-0.3.0.dist-info/METADATA +240 -0
  2. learnx_cli-0.3.0.dist-info/RECORD +131 -0
  3. learnx_cli-0.3.0.dist-info/WHEEL +4 -0
  4. learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
  5. tutor/.env copy.example +4 -0
  6. tutor/__init__.py +0 -0
  7. tutor/__main__.py +4 -0
  8. tutor/assets/__init__.py +5 -0
  9. tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
  10. tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
  11. tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
  12. tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
  13. tutor/assets/html/highlight-java.min.js +2 -0
  14. tutor/assets/html/highlight-javascript.min.js +2 -0
  15. tutor/assets/html/highlight-python.min.js +2 -0
  16. tutor/assets/html/highlight.min.js +17 -0
  17. tutor/assets/html/mermaid.min.js +31 -0
  18. tutor/assets/html/slide_base.css +464 -0
  19. tutor/assets/html/theme-learnx-dark.css +12 -0
  20. tutor/audio/__init__.py +0 -0
  21. tutor/audio/audio_builder.py +143 -0
  22. tutor/audio/sanitizer.py +9 -0
  23. tutor/audio/tts_renderer.py +54 -0
  24. tutor/cli/__init__.py +0 -0
  25. tutor/cli/commands.py +391 -0
  26. tutor/cli/logo.py +21 -0
  27. tutor/cli/playback_commands.py +239 -0
  28. tutor/cli/shell.py +91 -0
  29. tutor/cli/shell_context.py +18 -0
  30. tutor/cli/theme.py +39 -0
  31. tutor/cli/video_commands.py +123 -0
  32. tutor/config.py +122 -0
  33. tutor/conftest.py +5 -0
  34. tutor/constants.py +82 -0
  35. tutor/exceptions.py +26 -0
  36. tutor/generation/__init__.py +0 -0
  37. tutor/generation/assembler.py +81 -0
  38. tutor/generation/curriculum.py +97 -0
  39. tutor/generation/dialogue.py +172 -0
  40. tutor/generation/narrator.py +122 -0
  41. tutor/generation/segment_parser.py +223 -0
  42. tutor/generation/segment_planner.py +200 -0
  43. tutor/generation/visual_planner.py +205 -0
  44. tutor/infra/__init__.py +0 -0
  45. tutor/infra/llm.py +152 -0
  46. tutor/ingestion/__init__.py +0 -0
  47. tutor/ingestion/chunker.py +171 -0
  48. tutor/ingestion/doc_analyzer.py +41 -0
  49. tutor/ingestion/parse_content.py +19 -0
  50. tutor/ingestion/summarizer.py +51 -0
  51. tutor/inspector.py +117 -0
  52. tutor/llm_config.toml +58 -0
  53. tutor/models.py +147 -0
  54. tutor/player/__init__.py +0 -0
  55. tutor/player/input_handler.py +45 -0
  56. tutor/player/player.py +308 -0
  57. tutor/player/player_display.py +117 -0
  58. tutor/prompts/curriculum.txt +67 -0
  59. tutor/prompts/dialogue.txt +62 -0
  60. tutor/prompts/narrate.txt +34 -0
  61. tutor/prompts/qa.txt +17 -0
  62. tutor/prompts/summarize.txt +9 -0
  63. tutor/prompts/visual.txt +60 -0
  64. tutor/prompts/visual_v3.txt +91 -0
  65. tutor/qa/__init__.py +0 -0
  66. tutor/qa/qa.py +105 -0
  67. tutor/requirements-dev.txt +2 -0
  68. tutor/requirements.txt +12 -0
  69. tutor/sample_docs/headingless_large.md +1 -0
  70. tutor/sample_docs/headingless_test.md +1 -0
  71. tutor/sample_docs/java-basics.md +78 -0
  72. tutor/tests/__init__.py +0 -0
  73. tutor/tests/audio/__init__.py +0 -0
  74. tutor/tests/audio/test_audio_builder.py +106 -0
  75. tutor/tests/audio/test_sanitizer.py +41 -0
  76. tutor/tests/cli/__init__.py +0 -0
  77. tutor/tests/cli/test_commands.py +67 -0
  78. tutor/tests/cli/test_video_commands.py +190 -0
  79. tutor/tests/e2e/README.md +61 -0
  80. tutor/tests/e2e/__init__.py +0 -0
  81. tutor/tests/e2e/conftest.py +117 -0
  82. tutor/tests/e2e/fixtures/README.md +17 -0
  83. tutor/tests/e2e/fixtures/sample.md +13 -0
  84. tutor/tests/e2e/test_audio_quality.py +40 -0
  85. tutor/tests/e2e/test_av_sync.py +56 -0
  86. tutor/tests/e2e/test_pipeline_smoke.py +37 -0
  87. tutor/tests/e2e/test_slide_render.py +72 -0
  88. tutor/tests/e2e/test_video_streams.py +104 -0
  89. tutor/tests/generation/__init__.py +0 -0
  90. tutor/tests/generation/conftest.py +134 -0
  91. tutor/tests/generation/test_assembler.py +64 -0
  92. tutor/tests/generation/test_curriculum.py +107 -0
  93. tutor/tests/generation/test_narrator.py +165 -0
  94. tutor/tests/generation/test_segment_edge_cases.py +280 -0
  95. tutor/tests/generation/test_segment_planner.py +324 -0
  96. tutor/tests/generation/test_visual_planner.py +319 -0
  97. tutor/tests/ingestion/__init__.py +0 -0
  98. tutor/tests/ingestion/test_chunker.py +94 -0
  99. tutor/tests/ingestion/test_doc_analyzer.py +51 -0
  100. tutor/tests/player/__init__.py +0 -0
  101. tutor/tests/player/test_player_states.py +88 -0
  102. tutor/tests/test_assets.py +39 -0
  103. tutor/tests/test_models_visual.py +180 -0
  104. tutor/tests/visual/__init__.py +0 -0
  105. tutor/tests/visual/test_beat_timer.py +321 -0
  106. tutor/tests/visual/test_pipeline_integration.py +178 -0
  107. tutor/tests/visual/test_slide_renderer.py +298 -0
  108. tutor/tests/visual/test_subtitle_writer.py +165 -0
  109. tutor/tests/visual/test_video_assembler.py +108 -0
  110. tutor/tests/visual/test_visual_pipeline.py +270 -0
  111. tutor/tutor.py +365 -0
  112. tutor/visual/__init__.py +213 -0
  113. tutor/visual/beat_timer.py +222 -0
  114. tutor/visual/slide_renderer.py +236 -0
  115. tutor/visual/subtitle_writer.py +187 -0
  116. tutor/visual/templates/_base.html.j2 +40 -0
  117. tutor/visual/templates/analogy.html.j2 +21 -0
  118. tutor/visual/templates/callout.html.j2 +10 -0
  119. tutor/visual/templates/code_example.html.j2 +12 -0
  120. tutor/visual/templates/comparison.html.j2 +28 -0
  121. tutor/visual/templates/decision_guide.html.j2 +37 -0
  122. tutor/visual/templates/definition.html.j2 +13 -0
  123. tutor/visual/templates/diagram.html.j2 +11 -0
  124. tutor/visual/templates/hook_question.html.j2 +17 -0
  125. tutor/visual/templates/key_insight.html.j2 +9 -0
  126. tutor/visual/templates/memory_hook.html.j2 +7 -0
  127. tutor/visual/templates/outro.html.j2 +16 -0
  128. tutor/visual/templates/question_prompt.html.j2 +13 -0
  129. tutor/visual/templates/step_sequence.html.j2 +14 -0
  130. tutor/visual/templates/title_card.html.j2 +12 -0
  131. tutor/visual/video_assembler.py +299 -0
@@ -0,0 +1,187 @@
1
+ """
2
+ Convert a flat list of DialogueLines into a SRT subtitle file.
3
+ No ffmpeg, no Pillow, no LLM calls here.
4
+ """
5
+
6
+ from tutor.constants import SILENCE_TURN_MS, SILENCE_UNIT_MS, WPM
7
+ from tutor.models import DialogueLine
8
+
9
+ MIN_LINE_DURATION_S = 1.5
10
+ MAX_SUBTITLE_CHARS = 60
11
+
12
+
13
+ def build_srt(
14
+ all_lines: list[DialogueLine],
15
+ unit_durations_s: list[float],
16
+ timing_json: dict | None = None,
17
+ ) -> str:
18
+ """
19
+ Build a complete SRT string for the session.
20
+ If timing_json provided: use exact start_ms/end_ms per line.
21
+ If timing_json is None: use WPM estimation (existing behaviour, unchanged).
22
+ """
23
+ offsets = get_line_start_offsets(all_lines, unit_durations_s, timing_json)
24
+ _, durations = _compute_timing(all_lines, unit_durations_s)
25
+ entries: list[str] = []
26
+ for idx, (line, start, dur) in enumerate(
27
+ zip(all_lines, offsets, durations, strict=False), start=1
28
+ ):
29
+ end = start + dur
30
+ text = _wrap_subtitle(line.speaker, line.text)
31
+ entries.append(f"{idx}\n{_format_timestamp(start)} --> {_format_timestamp(end)}\n{text}\n")
32
+ return "\n".join(entries)
33
+
34
+
35
+ def get_line_start_offsets(
36
+ all_lines: list[DialogueLine],
37
+ unit_durations_s: list[float],
38
+ timing_json: dict | None = None,
39
+ ) -> list[float]:
40
+ """
41
+ Return the start time (seconds) of each line.
42
+ If timing_json provided: use exact offsets.
43
+ If timing_json is None: use WPM estimation (existing behaviour, unchanged).
44
+ """
45
+ if timing_json is not None:
46
+ return _exact_line_offsets(all_lines, unit_durations_s, timing_json)
47
+ offsets, _ = _compute_timing(all_lines, unit_durations_s)
48
+ return offsets
49
+
50
+
51
+ # ── Internals ────────────────────────────────────────────────────────────────
52
+
53
+
54
+ def _exact_line_offsets(
55
+ all_lines: list[DialogueLine],
56
+ unit_durations_s: list[float],
57
+ timing_json: dict,
58
+ ) -> list[float]:
59
+ """
60
+ Compute session-global start time for each line using timing_json.
61
+
62
+ unit_start[u] accumulates unit durations + inter-unit silence so
63
+ subtitle timestamps align with the concatenated video.
64
+ Lines not in timing_json (intro/outro) fall back to WPM estimation.
65
+ """
66
+ # Build cumulative unit start offsets
67
+ unit_start: dict[int, float] = {}
68
+ cursor = 0.0
69
+ for u_idx, dur in enumerate(unit_durations_s, start=1):
70
+ unit_start[u_idx] = cursor
71
+ cursor += dur + SILENCE_UNIT_MS / 1000
72
+
73
+ units_timing: dict[str, list] = timing_json.get("units", {})
74
+ wpm_offsets, _ = _compute_timing(all_lines, unit_durations_s)
75
+
76
+ # Track within-unit line index per unit
77
+ unit_line_idx: dict[int, int] = {}
78
+ offsets: list[float] = []
79
+
80
+ for i, ln in enumerate(all_lines):
81
+ u = ln.unit_number
82
+ key = str(u)
83
+ unit_entries = units_timing.get(key)
84
+
85
+ if unit_entries is None:
86
+ offsets.append(wpm_offsets[i])
87
+ continue
88
+
89
+ within_idx = unit_line_idx.get(u, 0)
90
+ unit_line_idx[u] = within_idx + 1
91
+
92
+ if within_idx >= len(unit_entries):
93
+ offsets.append(wpm_offsets[i])
94
+ continue
95
+
96
+ entry = unit_entries[within_idx]
97
+ offsets.append(unit_start.get(u, 0.0) + entry["start_ms"] / 1000)
98
+
99
+ return offsets
100
+
101
+
102
+ def _compute_timing(
103
+ all_lines: list[DialogueLine],
104
+ unit_durations_s: list[float],
105
+ ) -> tuple[list[float], list[float]]:
106
+ """Return (start_offsets, durations) for every line."""
107
+ # Group lines by unit number (preserving order)
108
+ unit_groups: dict[int, list[int]] = {}
109
+ for i, ln in enumerate(all_lines):
110
+ unit_groups.setdefault(ln.unit_number, []).append(i)
111
+
112
+ raw_durations: list[float] = [_line_duration(ln.text) for ln in all_lines]
113
+
114
+ # Scale per-unit durations to match actual MP3 lengths
115
+ for unit_num, indices in unit_groups.items():
116
+ if unit_num < 1:
117
+ continue
118
+ unit_idx = unit_num - 1
119
+ if unit_idx >= len(unit_durations_s):
120
+ continue
121
+ actual_s = unit_durations_s[unit_idx]
122
+ estimated_s = sum(raw_durations[i] for i in indices) + (
123
+ SILENCE_TURN_MS / 1000 * (len(indices) - 1)
124
+ )
125
+ if estimated_s > 0 and abs(estimated_s - actual_s) / estimated_s > 0.10:
126
+ scaled = _scale_unit_lines([raw_durations[i] for i in indices], actual_s)
127
+ for i, d in zip(indices, scaled, strict=False):
128
+ raw_durations[i] = d
129
+
130
+ # Build offsets with silence gaps
131
+ offsets: list[float] = []
132
+ cursor = 0.0
133
+ prev_unit: int | None = None
134
+
135
+ for i, ln in enumerate(all_lines):
136
+ if prev_unit is not None and ln.unit_number != prev_unit:
137
+ cursor += SILENCE_UNIT_MS / 1000
138
+ elif prev_unit is not None:
139
+ cursor += SILENCE_TURN_MS / 1000
140
+ offsets.append(cursor)
141
+ cursor += raw_durations[i]
142
+ prev_unit = ln.unit_number
143
+
144
+ return offsets, raw_durations
145
+
146
+
147
+ def _line_duration(text: str) -> float:
148
+ words = len(text.split())
149
+ return max(words / WPM * 60, MIN_LINE_DURATION_S)
150
+
151
+
152
+ def _scale_unit_lines(durations: list[float], actual_s: float) -> list[float]:
153
+ total = sum(durations)
154
+ if total == 0:
155
+ return durations
156
+ factor = actual_s / total
157
+ return [d * factor for d in durations]
158
+
159
+
160
+ def _format_timestamp(seconds: float) -> str:
161
+ ms = int(round(seconds * 1000))
162
+ hh = ms // 3_600_000
163
+ ms -= hh * 3_600_000
164
+ mm = ms // 60_000
165
+ ms -= mm * 60_000
166
+ ss = ms // 1_000
167
+ ms -= ss * 1_000
168
+ return f"{hh:02d}:{mm:02d}:{ss:02d},{ms:03d}"
169
+
170
+
171
+ def _wrap_subtitle(speaker: str, text: str, max_chars: int = MAX_SUBTITLE_CHARS) -> str:
172
+ prefix = f"{speaker}: "
173
+ full = prefix + text
174
+ if len(full) <= max_chars:
175
+ return full
176
+ # Wrap: keep prefix on first line, spill remainder
177
+ words = text.split()
178
+ line1_words: list[str] = []
179
+ for word in words:
180
+ candidate = prefix + " ".join(line1_words + [word])
181
+ if len(candidate) <= max_chars:
182
+ line1_words.append(word)
183
+ else:
184
+ break
185
+ remainder = " ".join(words[len(line1_words) :])
186
+ line1 = prefix + " ".join(line1_words)
187
+ return f"{line1}\n{remainder}" if remainder else line1
@@ -0,0 +1,40 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=1920, height=1080">
6
+ <link rel="stylesheet" href="{{ asset_dir }}/slide_base.css">
7
+ <link rel="stylesheet" href="{{ asset_dir }}/theme-learnx-dark.css">
8
+ <style>{% block extra_style %}{% endblock %}</style>
9
+ <script src="{{ asset_dir }}/highlight.min.js"></script>
10
+ <script src="{{ asset_dir }}/highlight-java.min.js"></script>
11
+ <script src="{{ asset_dir }}/highlight-python.min.js"></script>
12
+ <script src="{{ asset_dir }}/highlight-javascript.min.js"></script>
13
+ <script src="{{ asset_dir }}/mermaid.min.js"></script>
14
+ <script>
15
+ document.addEventListener('DOMContentLoaded', function() {
16
+ if (typeof hljs !== 'undefined') { hljs.highlightAll(); }
17
+ if (typeof mermaid !== 'undefined') { mermaid.initialize({startOnLoad: true, theme: 'dark'}); }
18
+ });
19
+ </script>
20
+ </head>
21
+ <body>
22
+ <div class="top-bar">
23
+ {% block top_bar %}
24
+ {% if seg is defined %}Unit {{ seg.unit_index }} &middot; {{ seg.title | e }}{% endif %}
25
+ {% endblock %}
26
+ </div>
27
+ <div class="content">
28
+ {% block content %}{% endblock %}
29
+ </div>
30
+ <div class="footer-bar">
31
+ {% block footer %}
32
+ {% if total_dots is defined %}
33
+ {% for i in range(total_dots) %}
34
+ <span class="dot {% if i < current_dot %}dot--filled{% else %}dot--hollow{% endif %}"></span>
35
+ {% endfor %}
36
+ {% endif %}
37
+ {% endblock %}
38
+ </div>
39
+ </body>
40
+ </html>
@@ -0,0 +1,21 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--purple); }{% endblock %}
3
+ {% block content %}
4
+ <div class="analogy-slide">
5
+ {% if seg.rows %}
6
+ <div class="analogy-panel">
7
+ <div class="analogy-label">{{ seg.left | default('Real world') | e }}</div>
8
+ <div class="analogy-body">{{ seg.rows[0][0] | e }}</div>
9
+ </div>
10
+ <div class="analogy-sep">&#8776;</div>
11
+ <div class="analogy-panel">
12
+ <div class="analogy-label">{{ seg.right | default('In code') | e }}</div>
13
+ <div class="analogy-body">{{ seg.rows[0][1] | e }}</div>
14
+ </div>
15
+ {% else %}
16
+ <div class="analogy-panel" style="grid-column:1/4;">
17
+ <div class="analogy-body">{{ seg.body | e if seg.body else seg.title | e }}</div>
18
+ </div>
19
+ {% endif %}
20
+ </div>
21
+ {% endblock %}
@@ -0,0 +1,10 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--amber); }{% endblock %}
3
+ {% block content %}
4
+ <div class="callout-slide">
5
+ <div class="callout-box">
6
+ <div class="callout-label">{{ seg.title | e }}</div>
7
+ <div class="callout-text">{{ seg.body | e }}</div>
8
+ </div>
9
+ </div>
10
+ {% endblock %}
@@ -0,0 +1,12 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--green); }{% endblock %}
3
+ {% block content %}
4
+ <div class="code-slide">
5
+ {% if seg.body %}
6
+ <div class="code-desc">{{ seg.body | e }}</div>
7
+ {% endif %}
8
+ {% if seg.code %}
9
+ <pre><code class="language-{{ seg.language or 'text' }}">{{ seg.code | e }}</code></pre>
10
+ {% endif %}
11
+ </div>
12
+ {% endblock %}
@@ -0,0 +1,28 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--teal); }{% endblock %}
3
+ {% block content %}
4
+ <div class="comparison-slide">
5
+ <table class="comparison-table">
6
+ <thead>
7
+ <tr>
8
+ <th class="th-left">{{ seg.left | default('') | e }}</th>
9
+ <th class="th-right">{{ seg.right | default('') | e }}</th>
10
+ </tr>
11
+ </thead>
12
+ <tbody>
13
+ {% set rows = seg.rows or [] %}
14
+ {% for row in rows[:6] %}
15
+ <tr>
16
+ <td>{{ row[0] | e }}</td>
17
+ <td>{{ row[1] | e }}</td>
18
+ </tr>
19
+ {% endfor %}
20
+ {% if rows | length > 6 %}
21
+ <tr>
22
+ <td colspan="2" class="td-ellipsis">…</td>
23
+ </tr>
24
+ {% endif %}
25
+ </tbody>
26
+ </table>
27
+ </div>
28
+ {% endblock %}
@@ -0,0 +1,37 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--orange); }{% endblock %}
3
+ {% block content %}
4
+ {% if seg.rows %}
5
+ <div class="comparison-slide">
6
+ <table class="comparison-table">
7
+ <thead>
8
+ <tr>
9
+ <th class="th-left">{{ seg.left | default('Use when') | e }}</th>
10
+ <th class="th-right">{{ seg.right | default('Avoid when') | e }}</th>
11
+ </tr>
12
+ </thead>
13
+ <tbody>
14
+ {% set rows = seg.rows or [] %}
15
+ {% for row in rows[:6] %}
16
+ <tr>
17
+ <td>{{ row[0] | e }}</td>
18
+ <td>{{ row[1] | e }}</td>
19
+ </tr>
20
+ {% endfor %}
21
+ {% if rows | length > 6 %}
22
+ <tr>
23
+ <td colspan="2" class="td-ellipsis">…</td>
24
+ </tr>
25
+ {% endif %}
26
+ </tbody>
27
+ </table>
28
+ </div>
29
+ {% else %}
30
+ <div class="definition-slide">
31
+ <div class="definition-term">{{ seg.title | e }}</div>
32
+ {% if seg.body %}
33
+ <div class="definition-text">{{ seg.body | e }}</div>
34
+ {% endif %}
35
+ </div>
36
+ {% endif %}
37
+ {% endblock %}
@@ -0,0 +1,13 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--blue); }{% endblock %}
3
+ {% block content %}
4
+ <div class="definition-slide">
5
+ <div class="definition-term">{{ seg.title | e }}</div>
6
+ {% if seg.body %}
7
+ <div class="definition-text">{{ seg.body | e }}</div>
8
+ {% endif %}
9
+ {% if seg.code %}
10
+ <pre><code class="language-{{ seg.language or 'text' }}">{{ seg.code | e }}</code></pre>
11
+ {% endif %}
12
+ </div>
13
+ {% endblock %}
@@ -0,0 +1,11 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--indigo); }{% endblock %}
3
+ {% block content %}
4
+ <div class="diagram-slide">
5
+ {% if seg.mermaid %}
6
+ <div class="mermaid">{{ seg.mermaid | e }}</div>
7
+ {% else %}
8
+ <p style="color:var(--text-sec);font-size:36px;">{{ seg.title | e }}</p>
9
+ {% endif %}
10
+ </div>
11
+ {% endblock %}
@@ -0,0 +1,17 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--cyan); }{% endblock %}
3
+ {% block content %}
4
+ <div class="hook-slide">
5
+ <div class="hook-question">{{ seg.title | e }}</div>
6
+ {% if seg.body %}
7
+ <div>
8
+ <div class="learn-label">What you'll learn</div>
9
+ <ul class="learn-list">
10
+ {% for item in seg.body.split('\n') if item.strip() %}
11
+ <li>{{ item.strip() | e }}</li>
12
+ {% endfor %}
13
+ </ul>
14
+ </div>
15
+ {% endif %}
16
+ </div>
17
+ {% endblock %}
@@ -0,0 +1,9 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--pink); }{% endblock %}
3
+ {% block content %}
4
+ <div class="key-insight-slide">
5
+ <div class="key-insight-rule"></div>
6
+ <div class="key-insight-text">{{ seg.body | e if seg.body else seg.title | e }}</div>
7
+ <div class="key-insight-rule"></div>
8
+ </div>
9
+ {% endblock %}
@@ -0,0 +1,7 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--rose); }{% endblock %}
3
+ {% block content %}
4
+ <div class="memory-hook-slide">
5
+ <p class="memory-hook-text">{{ seg.body | e if seg.body else seg.title | e }}</p>
6
+ </div>
7
+ {% endblock %}
@@ -0,0 +1,16 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block top_bar %}LearnX{% endblock %}
3
+ {% block footer %}{% endblock %}
4
+ {% block content %}
5
+ <div class="outro-slide">
6
+ <div class="outro-text">That's a wrap.</div>
7
+ {% if spec is defined and spec.memory_hooks %}
8
+ <div class="outro-sub">
9
+ {% for hook in spec.memory_hooks[:3] %}{{ hook | e }}{% if not loop.last %} &nbsp;·&nbsp; {% endif %}{% endfor %}
10
+ </div>
11
+ {% endif %}
12
+ {% if spec is defined and spec.session_stats %}
13
+ <div class="outro-sub">{{ spec.session_stats | e }}</div>
14
+ {% endif %}
15
+ </div>
16
+ {% endblock %}
@@ -0,0 +1,13 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--amber); }{% endblock %}
3
+ {% block top_bar %}{% endblock %}
4
+ {% block footer %}{% endblock %}
5
+ {% block content %}
6
+ <div class="question-slide">
7
+ {% set speaker = seg.title.split(':')[0].strip().upper() if seg.title and ':' in seg.title else 'MAYA' %}
8
+ <span class="speaker-badge {% if speaker == 'SAM' %}badge-sam{% else %}badge-maya{% endif %}">
9
+ {{ speaker }}
10
+ </span>
11
+ <p class="question-text">{{ seg.body | e if seg.body else seg.title | e }}</p>
12
+ </div>
13
+ {% endblock %}
@@ -0,0 +1,14 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block extra_style %}:root { --type-accent: var(--sky); }{% endblock %}
3
+ {% block content %}
4
+ <div class="step-slide">
5
+ {% if seg.body %}
6
+ {% for step in seg.body.split('\n') if step.strip() %}
7
+ <div class="step-item">
8
+ <div class="step-num">{{ loop.index }}</div>
9
+ <div class="step-text">{{ step.strip() | e }}</div>
10
+ </div>
11
+ {% endfor %}
12
+ {% endif %}
13
+ </div>
14
+ {% endblock %}
@@ -0,0 +1,12 @@
1
+ {% extends "_base.html.j2" %}
2
+ {% block top_bar %}LearnX{% endblock %}
3
+ {% block footer %}{% endblock %}
4
+ {% block content %}
5
+ <div class="title-card-slide">
6
+ <div class="title-card-accent"></div>
7
+ <div class="title-card-title">{{ spec.title | e if spec is defined and spec.title else 'LearnX Tutorial' }}</div>
8
+ {% if spec is defined and spec.subtitle %}
9
+ <div class="title-card-sub">{{ spec.subtitle | e }}</div>
10
+ {% endif %}
11
+ </div>
12
+ {% endblock %}