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.
- learnx_cli-0.3.0.dist-info/METADATA +240 -0
- learnx_cli-0.3.0.dist-info/RECORD +131 -0
- learnx_cli-0.3.0.dist-info/WHEEL +4 -0
- learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
- tutor/.env copy.example +4 -0
- tutor/__init__.py +0 -0
- tutor/__main__.py +4 -0
- tutor/assets/__init__.py +5 -0
- tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
- tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
- tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
- tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
- tutor/assets/html/highlight-java.min.js +2 -0
- tutor/assets/html/highlight-javascript.min.js +2 -0
- tutor/assets/html/highlight-python.min.js +2 -0
- tutor/assets/html/highlight.min.js +17 -0
- tutor/assets/html/mermaid.min.js +31 -0
- tutor/assets/html/slide_base.css +464 -0
- tutor/assets/html/theme-learnx-dark.css +12 -0
- tutor/audio/__init__.py +0 -0
- tutor/audio/audio_builder.py +143 -0
- tutor/audio/sanitizer.py +9 -0
- tutor/audio/tts_renderer.py +54 -0
- tutor/cli/__init__.py +0 -0
- tutor/cli/commands.py +391 -0
- tutor/cli/logo.py +21 -0
- tutor/cli/playback_commands.py +239 -0
- tutor/cli/shell.py +91 -0
- tutor/cli/shell_context.py +18 -0
- tutor/cli/theme.py +39 -0
- tutor/cli/video_commands.py +123 -0
- tutor/config.py +122 -0
- tutor/conftest.py +5 -0
- tutor/constants.py +82 -0
- tutor/exceptions.py +26 -0
- tutor/generation/__init__.py +0 -0
- tutor/generation/assembler.py +81 -0
- tutor/generation/curriculum.py +97 -0
- tutor/generation/dialogue.py +172 -0
- tutor/generation/narrator.py +122 -0
- tutor/generation/segment_parser.py +223 -0
- tutor/generation/segment_planner.py +200 -0
- tutor/generation/visual_planner.py +205 -0
- tutor/infra/__init__.py +0 -0
- tutor/infra/llm.py +152 -0
- tutor/ingestion/__init__.py +0 -0
- tutor/ingestion/chunker.py +171 -0
- tutor/ingestion/doc_analyzer.py +41 -0
- tutor/ingestion/parse_content.py +19 -0
- tutor/ingestion/summarizer.py +51 -0
- tutor/inspector.py +117 -0
- tutor/llm_config.toml +58 -0
- tutor/models.py +147 -0
- tutor/player/__init__.py +0 -0
- tutor/player/input_handler.py +45 -0
- tutor/player/player.py +308 -0
- tutor/player/player_display.py +117 -0
- tutor/prompts/curriculum.txt +67 -0
- tutor/prompts/dialogue.txt +62 -0
- tutor/prompts/narrate.txt +34 -0
- tutor/prompts/qa.txt +17 -0
- tutor/prompts/summarize.txt +9 -0
- tutor/prompts/visual.txt +60 -0
- tutor/prompts/visual_v3.txt +91 -0
- tutor/qa/__init__.py +0 -0
- tutor/qa/qa.py +105 -0
- tutor/requirements-dev.txt +2 -0
- tutor/requirements.txt +12 -0
- tutor/sample_docs/headingless_large.md +1 -0
- tutor/sample_docs/headingless_test.md +1 -0
- tutor/sample_docs/java-basics.md +78 -0
- tutor/tests/__init__.py +0 -0
- tutor/tests/audio/__init__.py +0 -0
- tutor/tests/audio/test_audio_builder.py +106 -0
- tutor/tests/audio/test_sanitizer.py +41 -0
- tutor/tests/cli/__init__.py +0 -0
- tutor/tests/cli/test_commands.py +67 -0
- tutor/tests/cli/test_video_commands.py +190 -0
- tutor/tests/e2e/README.md +61 -0
- tutor/tests/e2e/__init__.py +0 -0
- tutor/tests/e2e/conftest.py +117 -0
- tutor/tests/e2e/fixtures/README.md +17 -0
- tutor/tests/e2e/fixtures/sample.md +13 -0
- tutor/tests/e2e/test_audio_quality.py +40 -0
- tutor/tests/e2e/test_av_sync.py +56 -0
- tutor/tests/e2e/test_pipeline_smoke.py +37 -0
- tutor/tests/e2e/test_slide_render.py +72 -0
- tutor/tests/e2e/test_video_streams.py +104 -0
- tutor/tests/generation/__init__.py +0 -0
- tutor/tests/generation/conftest.py +134 -0
- tutor/tests/generation/test_assembler.py +64 -0
- tutor/tests/generation/test_curriculum.py +107 -0
- tutor/tests/generation/test_narrator.py +165 -0
- tutor/tests/generation/test_segment_edge_cases.py +280 -0
- tutor/tests/generation/test_segment_planner.py +324 -0
- tutor/tests/generation/test_visual_planner.py +319 -0
- tutor/tests/ingestion/__init__.py +0 -0
- tutor/tests/ingestion/test_chunker.py +94 -0
- tutor/tests/ingestion/test_doc_analyzer.py +51 -0
- tutor/tests/player/__init__.py +0 -0
- tutor/tests/player/test_player_states.py +88 -0
- tutor/tests/test_assets.py +39 -0
- tutor/tests/test_models_visual.py +180 -0
- tutor/tests/visual/__init__.py +0 -0
- tutor/tests/visual/test_beat_timer.py +321 -0
- tutor/tests/visual/test_pipeline_integration.py +178 -0
- tutor/tests/visual/test_slide_renderer.py +298 -0
- tutor/tests/visual/test_subtitle_writer.py +165 -0
- tutor/tests/visual/test_video_assembler.py +108 -0
- tutor/tests/visual/test_visual_pipeline.py +270 -0
- tutor/tutor.py +365 -0
- tutor/visual/__init__.py +213 -0
- tutor/visual/beat_timer.py +222 -0
- tutor/visual/slide_renderer.py +236 -0
- tutor/visual/subtitle_writer.py +187 -0
- tutor/visual/templates/_base.html.j2 +40 -0
- tutor/visual/templates/analogy.html.j2 +21 -0
- tutor/visual/templates/callout.html.j2 +10 -0
- tutor/visual/templates/code_example.html.j2 +12 -0
- tutor/visual/templates/comparison.html.j2 +28 -0
- tutor/visual/templates/decision_guide.html.j2 +37 -0
- tutor/visual/templates/definition.html.j2 +13 -0
- tutor/visual/templates/diagram.html.j2 +11 -0
- tutor/visual/templates/hook_question.html.j2 +17 -0
- tutor/visual/templates/key_insight.html.j2 +9 -0
- tutor/visual/templates/memory_hook.html.j2 +7 -0
- tutor/visual/templates/outro.html.j2 +16 -0
- tutor/visual/templates/question_prompt.html.j2 +13 -0
- tutor/visual/templates/step_sequence.html.j2 +14 -0
- tutor/visual/templates/title_card.html.j2 +12 -0
- 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 }} · {{ 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">≈</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 %} · {% 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 %}
|