revision-engine 0.2.2__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.
@@ -0,0 +1,153 @@
1
+ """Local preview: build to a temp folder, serve it, rebuild when the content changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.server
6
+ import os
7
+ import re
8
+ import sys
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ from functools import partial
13
+ from pathlib import Path
14
+
15
+ from revision_engine.build import build
16
+ from revision_engine.errors import BuildError
17
+
18
+ _RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
19
+
20
+
21
+ class _Slice:
22
+ """A bounded reader over an open file, for streaming one byte range."""
23
+
24
+ def __init__(self, f, remaining: int) -> None:
25
+ self._f = f
26
+ self._remaining = remaining
27
+
28
+ def read(self, n: int = -1) -> bytes:
29
+ if self._remaining <= 0:
30
+ return b""
31
+ n = self._remaining if n is None or n < 0 else min(n, self._remaining)
32
+ data = self._f.read(n)
33
+ self._remaining -= len(data)
34
+ return data
35
+
36
+ def close(self) -> None:
37
+ self._f.close()
38
+
39
+
40
+ class RangeHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
41
+ """SimpleHTTPRequestHandler plus HTTP Range support, which <audio> needs to seek."""
42
+
43
+ def end_headers(self) -> None:
44
+ if not any(h.lower().startswith("accept-ranges") for h in self._headers_buffer_text()):
45
+ self.send_header("Accept-Ranges", "bytes")
46
+ super().end_headers()
47
+
48
+ def _headers_buffer_text(self) -> list[str]:
49
+ buf = getattr(self, "_headers_buffer", [])
50
+ return [b.decode("latin-1", "replace") for b in buf]
51
+
52
+ def send_head(self):
53
+ path = self.translate_path(self.path)
54
+ range_header = self.headers.get("Range")
55
+ if os.path.isdir(path) or not range_header:
56
+ return super().send_head()
57
+ m = _RANGE_RE.match(range_header.strip())
58
+ if not m or (m.group(1) == "" and m.group(2) == ""):
59
+ return super().send_head()
60
+ try:
61
+ f = open(path, "rb")
62
+ except OSError:
63
+ self.send_error(404, "File not found")
64
+ return None
65
+ stat = os.fstat(f.fileno())
66
+ size = stat.st_size
67
+ if m.group(1):
68
+ start = int(m.group(1))
69
+ end = int(m.group(2)) if m.group(2) else size - 1
70
+ else:
71
+ start = max(0, size - int(m.group(2)))
72
+ end = size - 1
73
+ end = min(end, size - 1)
74
+ if start > end or start >= size:
75
+ f.close()
76
+ self.send_response(416, "Requested Range Not Satisfiable")
77
+ self.send_header("Content-Range", f"bytes */{size}")
78
+ self.end_headers()
79
+ return None
80
+ self.send_response(206, "Partial Content")
81
+ self.send_header("Content-Type", self.guess_type(path))
82
+ self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
83
+ self.send_header("Content-Length", str(end - start + 1))
84
+ self.send_header("Last-Modified", self.date_time_string(stat.st_mtime))
85
+ self.end_headers()
86
+ f.seek(start)
87
+ return _Slice(f, end - start + 1)
88
+
89
+
90
+ class Watcher:
91
+ """Polls file modification times under `root`, skipping ignored folders and VCS/cache noise."""
92
+
93
+ def __init__(self, root: Path, ignore: tuple[Path, ...] = ()) -> None:
94
+ self.root = root.resolve()
95
+ self.ignore = tuple(p.resolve() for p in ignore)
96
+ self._last = self.snapshot()
97
+
98
+ def _skip(self, p: Path) -> bool:
99
+ parts = set(p.parts)
100
+ if ".git" in parts or "__pycache__" in parts or ".venv" in parts:
101
+ return True
102
+ return any(ig == p or ig in p.parents for ig in self.ignore)
103
+
104
+ def snapshot(self) -> dict[str, int]:
105
+ out: dict[str, int] = {}
106
+ for p in self.root.rglob("*"):
107
+ if p.is_file() and not self._skip(p):
108
+ try:
109
+ out[p.as_posix()] = p.stat().st_mtime_ns
110
+ except OSError:
111
+ pass
112
+ return out
113
+
114
+ def changed(self) -> bool:
115
+ now = self.snapshot()
116
+ if now != self._last:
117
+ self._last = now
118
+ return True
119
+ return False
120
+
121
+
122
+ def _build(root: Path, output: Path, commit: str | None) -> None:
123
+ try:
124
+ result = build(root, output=output, commit=commit)
125
+ print(f"built {len(result.pages)} pages -> {output}", flush=True)
126
+ for w in result.report.warnings:
127
+ print(f"warning: {w}", flush=True)
128
+ except BuildError as e:
129
+ print(f"error: {e}", file=sys.stderr, flush=True)
130
+
131
+
132
+ def serve(root: Path, port: int, commit: str | None, once: bool = False) -> None:
133
+ root = root.resolve()
134
+ with tempfile.TemporaryDirectory(prefix="rv2-serve-") as tmp:
135
+ output = Path(tmp) / "site"
136
+ _build(root, output, commit)
137
+ if once:
138
+ return
139
+ handler = partial(RangeHTTPRequestHandler, directory=str(output))
140
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
141
+ print(f"serving http://127.0.0.1:{server.server_address[1]}/ (Ctrl+C to stop)", flush=True)
142
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
143
+ thread.start()
144
+ watcher = Watcher(root, ignore=(root / "public",))
145
+ try:
146
+ while True:
147
+ time.sleep(1.0)
148
+ if watcher.changed():
149
+ _build(root, output, commit)
150
+ except KeyboardInterrupt:
151
+ pass
152
+ finally:
153
+ server.shutdown()
@@ -0,0 +1,61 @@
1
+ """Snapshot source files from the working tree and identify the commit being built."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from revision_engine.config import RepoConfig
10
+ from revision_engine.errors import BuildError
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SourceFile:
15
+ path: str
16
+ text: str
17
+ lines: tuple[str, ...]
18
+
19
+
20
+ def read_source(root: Path, rel: str) -> SourceFile:
21
+ root = root.resolve()
22
+ target = (root / rel).resolve()
23
+ try:
24
+ target.relative_to(root)
25
+ except ValueError:
26
+ raise BuildError(f"source path {rel!r} is outside the repository") from None
27
+ if not target.is_file():
28
+ raise BuildError(f"source file not found: {rel}")
29
+ raw = target.read_bytes()
30
+ try:
31
+ text = raw.decode("utf-8")
32
+ except UnicodeDecodeError as e:
33
+ raise BuildError(f"source file is not UTF-8: {rel} ({e})") from e
34
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
35
+ body = text[:-1] if text.endswith("\n") else text
36
+ lines = tuple(body.split("\n")) if body else ()
37
+ return SourceFile(path=rel.replace("\\", "/"), text=text, lines=lines)
38
+
39
+
40
+ def git_commit(root: Path) -> str | None:
41
+ try:
42
+ out = subprocess.run(
43
+ ["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, check=False
44
+ )
45
+ except FileNotFoundError:
46
+ return None
47
+ if out.returncode != 0:
48
+ return None
49
+ sha = out.stdout.strip()
50
+ return sha if len(sha) == 40 else None
51
+
52
+
53
+ def short(commit: str) -> str:
54
+ return commit[:7]
55
+
56
+
57
+ def file_url(repo: RepoConfig, commit: str, rel: str) -> str:
58
+ base = repo.url.rstrip("/")
59
+ if repo.host == "gitlab":
60
+ return f"{base}/-/blob/{commit}/{rel}"
61
+ return f"{base}/blob/{commit}/{rel}"
@@ -0,0 +1,292 @@
1
+ /* reVision engine front end. No network, no framework. Behaviour only; the page is complete without it. */
2
+ (function () {
3
+ "use strict";
4
+
5
+ const dataEl = document.getElementById("rv-data");
6
+ const data = dataEl ? JSON.parse(dataEl.textContent) : { anchors: {}, start: null, cues: null, variants: [] };
7
+ const body = document.body;
8
+ const $ = (id) => document.getElementById(id);
9
+
10
+ /* ---- storage helpers: per-device conveniences only, never required ---- */
11
+ function load(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
12
+ function save(key, value) { try { localStorage.setItem(key, value); } catch (e) { /* ignore */ } }
13
+
14
+ /* ---- rail and side panel ---- */
15
+ const panel = $("rv-panel");
16
+ const railButtons = Array.from(document.querySelectorAll(".rv-rail-btn"));
17
+ function openPanel(name) {
18
+ const open = !!name && body.dataset.panel !== name;
19
+ body.dataset.panel = open ? name : "";
20
+ document.documentElement.style.setProperty("--rv-panel-w", open ? "280px" : "0px");
21
+ if (panel) panel.hidden = !open;
22
+ document.querySelectorAll(".rv-panel-view").forEach((v) => { v.hidden = !(open && v.dataset.view === name); });
23
+ railButtons.forEach((b) => b.setAttribute("aria-pressed", String(open && b.dataset.panel === name)));
24
+ }
25
+ railButtons.forEach((b) => b.addEventListener("click", () => openPanel(b.dataset.panel)));
26
+
27
+ /* ---- theme ---- */
28
+ function setTheme(mode) {
29
+ if (mode === "light" || mode === "dark") document.documentElement.dataset.theme = mode;
30
+ else delete document.documentElement.dataset.theme;
31
+ save("rv-theme", mode || "auto");
32
+ document.querySelectorAll(".rv-theme").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.theme === (mode || "auto"))));
33
+ }
34
+ document.querySelectorAll(".rv-theme").forEach((b) => b.addEventListener("click", () => setTheme(b.dataset.theme)));
35
+ setTheme(load("rv-theme") || "auto");
36
+
37
+ /* ---- splitter ---- */
38
+ const splitter = $("rv-splitter");
39
+ const main = document.querySelector(".rv-main");
40
+ function setCodeWidth(px) {
41
+ const min = 240, max = main.clientWidth - 240 - 6;
42
+ const w = Math.max(min, Math.min(max, px));
43
+ document.documentElement.style.setProperty("--rv-code-w", w + "px");
44
+ save("rv-code-w", String(w));
45
+ }
46
+ if (splitter && main) {
47
+ const saved = parseInt(load("rv-code-w") || "", 10);
48
+ if (saved && window.innerWidth > 900) setCodeWidth(saved);
49
+ let dragging = false;
50
+ splitter.addEventListener("pointerdown", (e) => { dragging = true; splitter.setPointerCapture(e.pointerId); });
51
+ splitter.addEventListener("pointermove", (e) => { if (dragging && window.innerWidth > 900) setCodeWidth(e.clientX - main.getBoundingClientRect().left); });
52
+ splitter.addEventListener("pointerup", () => { dragging = false; });
53
+ splitter.addEventListener("keydown", (e) => {
54
+ const cur = $("rv-code").getBoundingClientRect().width;
55
+ if (e.key === "ArrowLeft") setCodeWidth(cur - 24);
56
+ if (e.key === "ArrowRight") setCodeWidth(cur + 24);
57
+ });
58
+ /* A width saved on a wide window must not squeeze the lesson to nothing on a narrower one. */
59
+ window.addEventListener("resize", () => {
60
+ if (window.innerWidth > 900 && document.documentElement.style.getPropertyValue("--rv-code-w").endsWith("px")) {
61
+ setCodeWidth($("rv-code").getBoundingClientRect().width);
62
+ }
63
+ });
64
+ }
65
+
66
+ /* ---- code boxes ---- */
67
+ const codeBody = $("rv-code-body");
68
+ const lines = codeBody ? Array.from(codeBody.querySelectorAll(".rv-line")) : [];
69
+ let boxed = null; // {start, end} currently drawn
70
+ let pinned = null; // anchor name pinned by click, or null
71
+ let cueBox = null; // {start, end} from the active narration segment, or null
72
+
73
+ function clearBox() {
74
+ lines.forEach((l) => l.classList.remove("rv-boxed", "rv-box-start", "rv-box-end"));
75
+ boxed = null;
76
+ }
77
+ function boxLines(start, end, opts) {
78
+ clearBox();
79
+ for (let n = start; n <= end; n++) {
80
+ const el = lines[n - 1];
81
+ if (!el) continue;
82
+ el.classList.add("rv-boxed");
83
+ if (n === start) el.classList.add("rv-box-start");
84
+ if (n === end) el.classList.add("rv-box-end");
85
+ }
86
+ boxed = { start, end };
87
+ if (!opts || opts.scroll !== false) {
88
+ const first = lines[start - 1];
89
+ if (first && codeBody) {
90
+ const top = first.offsetTop - codeBody.clientHeight * 0.3;
91
+ codeBody.scrollTo({ top: Math.max(0, top), behavior: opts && opts.instant ? "auto" : "smooth" });
92
+ }
93
+ }
94
+ }
95
+ function anchorRange(name) { return data.anchors[name] || null; }
96
+ function showAnchor(name, opts) {
97
+ const r = anchorRange(name);
98
+ if (r) boxLines(r.start, r.end, opts);
99
+ return !!r;
100
+ }
101
+ function restore() {
102
+ if (pinned && showAnchor(pinned, { scroll: false })) return;
103
+ if (cueBox) { boxLines(cueBox.start, cueBox.end, { scroll: false }); return; }
104
+ clearBox();
105
+ }
106
+ function pin(name) {
107
+ pinned = name;
108
+ document.querySelectorAll(".rv-anchor.rv-pinned, .rv-svg-anchor.rv-pinned").forEach((el) => el.classList.remove("rv-pinned"));
109
+ body.classList.toggle("rv-pinned", !!name);
110
+ if (name) {
111
+ document.querySelectorAll('[data-anchor="' + name + '"]').forEach((el) => el.classList.add("rv-pinned"));
112
+ showAnchor(name);
113
+ if (history.replaceState) history.replaceState(null, "", "#" + name);
114
+ } else {
115
+ if (history.replaceState) history.replaceState(null, "", location.pathname + location.search);
116
+ restore();
117
+ }
118
+ }
119
+
120
+ /* A link inside an anchor phrase (a backticked intrinsic) keeps its own click and Enter. */
121
+ function fromNestedLink(el, e) {
122
+ const a = e.target.closest("a[href]");
123
+ return a !== el && el.contains(a);
124
+ }
125
+
126
+ /* anchor phrases in the lesson and anchor nodes in diagrams */
127
+ document.querySelectorAll("[data-anchor]").forEach((el) => {
128
+ const name = el.dataset.anchor;
129
+ const toggle = (e) => { if (fromNestedLink(el, e)) return; e.preventDefault(); pin(pinned === name ? null : name); };
130
+ el.addEventListener("mouseenter", () => showAnchor(name));
131
+ el.addEventListener("focusin", () => showAnchor(name));
132
+ el.addEventListener("mouseleave", restore);
133
+ el.addEventListener("focusout", restore);
134
+ el.addEventListener("click", toggle);
135
+ el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") toggle(e); });
136
+ });
137
+
138
+ /* variants jump list */
139
+ document.querySelectorAll(".rv-variant").forEach((b) => b.addEventListener("click", () => pin(b.dataset.anchor)));
140
+
141
+ /* URL fragment: #anchor-name pins, #t=12.5 seeks. A fragment that is not valid percent-encoding is ignored. */
142
+ function applyHash() {
143
+ let h;
144
+ try { h = decodeURIComponent(location.hash.replace(/^#/, "")); } catch (e) { return; }
145
+ if (!h) return;
146
+ if (/^t=/.test(h)) { const t = parseFloat(h.slice(2)); if (Number.isFinite(t)) seek(Math.max(0, t)); return; }
147
+ if (anchorRange(h)) pin(h);
148
+ }
149
+
150
+ /* keyboard */
151
+ document.addEventListener("keydown", (e) => {
152
+ if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
153
+ if (e.key === "Escape") { pin(null); return; }
154
+ if (e.key === "[") { const a = document.querySelector(".rv-prev"); if (a) location.href = a.href; }
155
+ if (e.key === "]") { const a = document.querySelector(".rv-next"); if (a) location.href = a.href; }
156
+ });
157
+
158
+ /* ---- media and cues ---- */
159
+ const mediaEl = $("rv-media-el");
160
+ const segEls = Array.from(document.querySelectorAll(".rv-seg"));
161
+ const cues = Array.isArray(data.cues) ? data.cues : [];
162
+ let activeIndex = -1;
163
+
164
+ function unionRange(names) {
165
+ let start = Infinity, end = -Infinity;
166
+ names.forEach((n) => { const r = anchorRange(n); if (r) { start = Math.min(start, r.start); end = Math.max(end, r.end); } });
167
+ return start === Infinity ? null : { start, end };
168
+ }
169
+ function applyCue(index) {
170
+ segEls.forEach((el, i) => el.classList.toggle("rv-active", i === index));
171
+ document.querySelectorAll(".rv-node-active").forEach((el) => el.classList.remove("rv-node-active"));
172
+ activeIndex = index;
173
+ const seg = cues[index];
174
+ if (!seg) { cueBox = null; return; }
175
+ const range = seg.show ? unionRange(seg.show) : null;
176
+ cueBox = range;
177
+ if (range) boxLines(range.start, range.end);
178
+ if (seg.diagram) {
179
+ const fig = document.querySelector('.rv-diagram[data-diagram="' + seg.diagram + '"]');
180
+ if (fig) {
181
+ fig.scrollIntoView({ block: "center", behavior: "smooth" });
182
+ if (seg.node) { const node = fig.querySelector('[data-node="' + seg.node + '"]'); if (node) node.classList.add("rv-node-active"); }
183
+ }
184
+ }
185
+ revealSeg(index);
186
+ }
187
+ function revealSeg(index) {
188
+ const el = segEls[index];
189
+ if (el) el.scrollIntoView({ block: "nearest" });
190
+ }
191
+ function segmentAt(t) {
192
+ for (let i = cues.length - 1; i >= 0; i--) if (t >= cues[i].start) return i;
193
+ return -1;
194
+ }
195
+ function activeSegment() { return activeIndex; }
196
+ /* A seek before metadata has loaded is dropped by the browser and may echo a timeupdate at 0,
197
+ so the target is parked until loadedmetadata and timeupdate is ignored meanwhile. */
198
+ let pendingSeekTo = null;
199
+ function seek(seconds) {
200
+ if (!mediaEl) return;
201
+ const i = segmentAt(seconds);
202
+ if (i !== activeIndex) applyCue(i);
203
+ if (mediaEl.readyState >= 1) mediaEl.currentTime = seconds;
204
+ else pendingSeekTo = seconds;
205
+ }
206
+ if (mediaEl) {
207
+ const mediaPane = $("rv-media");
208
+ /* Reader collapse: hides the pane's content but never pauses or removes the media, so playback and cues carry on. */
209
+ const mediaToggle = $("rv-media-toggle");
210
+ if (mediaToggle && mediaPane) {
211
+ mediaToggle.hidden = false;
212
+ mediaToggle.addEventListener("click", () => {
213
+ const collapsed = mediaPane.classList.toggle("rv-media-collapsed");
214
+ mediaToggle.setAttribute("aria-expanded", String(!collapsed));
215
+ if (!collapsed) revealSeg(activeIndex);
216
+ });
217
+ }
218
+ mediaEl.addEventListener("loadedmetadata", () => {
219
+ if (pendingSeekTo !== null) { mediaEl.currentTime = pendingSeekTo; pendingSeekTo = null; }
220
+ });
221
+ mediaEl.addEventListener("timeupdate", () => {
222
+ if (pendingSeekTo !== null || mediaEl.seeking) return;
223
+ const i = segmentAt(mediaEl.currentTime);
224
+ if (i !== activeIndex) applyCue(i);
225
+ });
226
+ function onMediaError() {
227
+ const err = document.querySelector(".rv-media-error");
228
+ if (err) err.hidden = false;
229
+ mediaEl.hidden = true;
230
+ if (mediaPane) mediaPane.classList.add("rv-media-failed");
231
+ }
232
+ mediaEl.addEventListener("error", onMediaError);
233
+ segEls.forEach((el) => {
234
+ const go = () => seek(parseFloat(el.dataset.start));
235
+ el.addEventListener("click", go);
236
+ el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); } });
237
+ });
238
+ if (mediaPane) mediaPane.addEventListener("keydown", (e) => {
239
+ if (e.key === " " && e.target === mediaPane) { e.preventDefault(); if (mediaEl.paused) mediaEl.play(); else mediaEl.pause(); }
240
+ });
241
+ /* A load failure before this deferred script ran has already fired "error"; catch it up. */
242
+ if (mediaEl.error) onMediaError();
243
+ }
244
+
245
+ /* initial state: fragment wins, else the lesson's start anchor. Stays below the media section: a #t= fragment calls seek(), which reads mediaEl. */
246
+ applyHash();
247
+ if (!pinned && !cueBox && data.start) showAnchor(data.start, { instant: true });
248
+ window.addEventListener("hashchange", applyHash);
249
+
250
+ /* ---- quiz: page-local, nothing stored ---- */
251
+ document.querySelectorAll(".rv-quiz form").forEach((form) => {
252
+ const questions = Array.from(form.querySelectorAll(".rv-q"));
253
+ /* Shuffle unless the author fixed the order; two choices (True/False) keep theirs. Grading uses authored indexes. */
254
+ questions.forEach((q) => {
255
+ const labels = Array.from(q.querySelectorAll(":scope > label"));
256
+ if (q.hasAttribute("data-fixed-order") || labels.length < 3) return;
257
+ for (let i = labels.length - 1; i > 0; i--) {
258
+ const j = Math.floor(Math.random() * (i + 1));
259
+ [labels[i], labels[j]] = [labels[j], labels[i]];
260
+ }
261
+ const fb = q.querySelector(".rv-q-feedback");
262
+ labels.forEach((l) => q.insertBefore(l, fb));
263
+ });
264
+ function grade(q) {
265
+ const chosen = q.querySelector("input:checked");
266
+ const fb = q.querySelector(".rv-q-feedback");
267
+ if (!chosen) { q.classList.remove("rv-correct", "rv-incorrect"); fb.hidden = true; return null; }
268
+ const right = chosen.value === q.dataset.answer;
269
+ q.classList.toggle("rv-correct", right);
270
+ q.classList.toggle("rv-incorrect", !right);
271
+ fb.textContent = right ? "Correct." : "Not quite. Try another answer.";
272
+ fb.hidden = false;
273
+ return right;
274
+ }
275
+ questions.forEach((q) => q.addEventListener("change", () => grade(q)));
276
+ form.addEventListener("submit", (e) => {
277
+ e.preventDefault();
278
+ const results = questions.map(grade);
279
+ const answered = results.filter((r) => r !== null).length;
280
+ const correct = results.filter((r) => r === true).length;
281
+ const score = form.querySelector(".rv-quiz-score");
282
+ score.textContent = answered < questions.length
283
+ ? correct + " of " + questions.length + " correct so far; " + (questions.length - answered) + " unanswered."
284
+ : correct + " of " + questions.length + " correct.";
285
+ score.hidden = false;
286
+ });
287
+ });
288
+
289
+ window.RV = { data, openPanel, setTheme, setCodeWidth, boxLines, clearBox, pin, showAnchor, applyHash, applyCue, seek, activeSegment,
290
+ get pinned() { return pinned; }, get boxed() { return boxed; },
291
+ setCueBox(r) { cueBox = r; } };
292
+ })();
@@ -0,0 +1,160 @@
1
+ /* reVision engine stylesheet. Tokens first, then layout, then components. */
2
+ :root {
3
+ --rv-bg: #ffffff;
4
+ --rv-bg-2: #f4f5f7;
5
+ --rv-fg: #1d2129;
6
+ --rv-muted: #5f6672;
7
+ --rv-line: #e1e4e8;
8
+ --rv-accent: #1f6feb;
9
+ --rv-box: rgba(31, 111, 235, 0.14);
10
+ --rv-box-edge: #1f6feb;
11
+ --rv-pin: #d29922;
12
+ --rv-rail-w: 48px;
13
+ --rv-panel-w: 0px;
14
+ --rv-code-w: 1fr;
15
+ --rv-media-h: 200px;
16
+ --rv-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
17
+ --rv-mono: ui-monospace, "Cascadia Code", Consolas, "Liberation Mono", monospace;
18
+ }
19
+ @media (prefers-color-scheme: dark) {
20
+ :root:not([data-theme="light"]) {
21
+ --rv-bg: #0f1218; --rv-bg-2: #171b23; --rv-fg: #e6e8ec; --rv-muted: #9aa3b2;
22
+ --rv-line: #2a3140; --rv-accent: #6ea8ff; --rv-box: rgba(110, 168, 255, 0.18); --rv-box-edge: #6ea8ff; --rv-pin: #e3b341;
23
+ }
24
+ }
25
+ :root[data-theme="dark"] {
26
+ --rv-bg: #0f1218; --rv-bg-2: #171b23; --rv-fg: #e6e8ec; --rv-muted: #9aa3b2;
27
+ --rv-line: #2a3140; --rv-accent: #6ea8ff; --rv-box: rgba(110, 168, 255, 0.18); --rv-box-edge: #6ea8ff; --rv-pin: #e3b341;
28
+ }
29
+
30
+ * { box-sizing: border-box; }
31
+ html, body { height: 100%; margin: 0; }
32
+ body.rv {
33
+ display: grid;
34
+ grid-template-columns: var(--rv-rail-w) var(--rv-panel-w) minmax(0, 1fr);
35
+ background: var(--rv-bg); color: var(--rv-fg); font: 15px/1.55 var(--rv-font);
36
+ overflow: hidden;
37
+ }
38
+
39
+ /* Explicit grid placement: the panel is display:none when closed, so nothing may slide into its column. */
40
+ .rv-rail { grid-column: 1; }
41
+ .rv-panel { grid-column: 2; }
42
+ .rv-main { grid-column: 3; }
43
+
44
+ /* Rail */
45
+ .rv-rail { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 6px 0; background: var(--rv-bg-2); border-right: 1px solid var(--rv-line); }
46
+ .rv-rail-btn { width: 36px; height: 36px; border: 0; border-radius: 6px; background: transparent; color: var(--rv-muted); font-size: 18px; cursor: pointer; }
47
+ .rv-rail-btn:hover, .rv-rail-btn[aria-pressed="true"] { color: var(--rv-fg); background: var(--rv-bg); }
48
+ .rv-rail-spacer { flex: 1; }
49
+
50
+ /* Side panel */
51
+ .rv-panel { overflow: auto; padding: 12px 14px; background: var(--rv-bg-2); border-right: 1px solid var(--rv-line); }
52
+ .rv-panel[hidden] { display: none; }
53
+ .rv-panel h2 { font-size: 14px; margin: 4px 0 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--rv-muted); }
54
+ .rv-panel h3 { font-size: 14px; margin: 12px 0 4px; }
55
+ .rv-panel ol, .rv-panel ul { margin: 0; padding-left: 18px; }
56
+ .rv-panel li { margin: 2px 0; }
57
+ .rv-panel a { color: var(--rv-fg); text-decoration: none; }
58
+ .rv-panel li.rv-current > a { color: var(--rv-accent); font-weight: 600; }
59
+ .rv-variant, .rv-theme { border: 1px solid var(--rv-line); background: var(--rv-bg); color: var(--rv-fg); border-radius: 6px; padding: 4px 8px; cursor: pointer; font: inherit; }
60
+ .rv-variant small { color: var(--rv-muted); }
61
+ .rv-theme[aria-pressed="true"] { border-color: var(--rv-accent); color: var(--rv-accent); }
62
+ .rv-theme-row { display: flex; gap: 6px; }
63
+
64
+ /* Main two-pane area */
65
+ .rv-main { display: grid; grid-template-columns: var(--rv-code-w) 6px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); min-width: 0; min-height: 0; height: 100vh; }
66
+ .rv-code-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; border-right: 1px solid var(--rv-line); tab-size: 4; }
67
+ .rv-code-head { display: flex; justify-content: space-between; gap: 12px; padding: 8px 12px; border-bottom: 1px solid var(--rv-line); background: var(--rv-bg-2); font-size: 13px; }
68
+ .rv-file { font-family: var(--rv-mono); }
69
+ .rv-commit { color: var(--rv-muted); text-decoration: none; white-space: nowrap; }
70
+ .rv-code-body { flex: 1; overflow: auto; padding: 8px 0; font: 13px/1.5 var(--rv-mono); }
71
+ .rv-line { display: flex; white-space: pre; padding: 0 12px 0 0; border-left: 3px solid transparent; }
72
+ .rv-ln { width: 56px; flex: none; text-align: right; padding-right: 14px; color: var(--rv-muted); user-select: none; }
73
+ .rv-line.rv-boxed { background: var(--rv-box); border-left-color: var(--rv-box-edge); }
74
+ .rv-line.rv-boxed.rv-box-start { box-shadow: inset 0 1px 0 var(--rv-box-edge); }
75
+ .rv-line.rv-boxed.rv-box-end { box-shadow: inset 0 -1px 0 var(--rv-box-edge); }
76
+ .rv-line.rv-boxed.rv-box-start.rv-box-end { box-shadow: inset 0 1px 0 var(--rv-box-edge), inset 0 -1px 0 var(--rv-box-edge); }
77
+ body.rv-pinned .rv-line.rv-boxed { border-left-color: var(--rv-pin); }
78
+
79
+ .rv-splitter { cursor: col-resize; background: var(--rv-line); }
80
+ .rv-splitter:hover, .rv-splitter:focus { background: var(--rv-accent); outline: none; }
81
+
82
+ .rv-right { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-width: 0; height: 100vh; }
83
+ .rv-lesson { overflow: auto; padding: 20px 28px 40px; max-width: 78ch; }
84
+ .rv-crumb { margin: 0 0 4px; font-size: 13px; color: var(--rv-muted); }
85
+ .rv-lesson h1 { font-size: 26px; margin: 0 0 12px; }
86
+ .rv-lesson pre { background: var(--rv-bg-2); padding: 10px 12px; border-radius: 6px; overflow: auto; font: 13px/1.5 var(--rv-mono); }
87
+ .rv-lesson code { font-family: var(--rv-mono); font-size: 0.92em; }
88
+ .rv-lesson table { border-collapse: collapse; }
89
+ .rv-lesson td, .rv-lesson th { border: 1px solid var(--rv-line); padding: 4px 8px; }
90
+ .rv-pager { display: flex; justify-content: space-between; margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--rv-line); }
91
+ .rv-pager a { color: var(--rv-accent); text-decoration: none; }
92
+
93
+ /* Anchor phrases, intrinsics, diagrams */
94
+ .rv-anchor { border-bottom: 2px solid var(--rv-box-edge); background: var(--rv-box); border-radius: 3px 3px 0 0; padding: 0 2px; cursor: pointer; }
95
+ .rv-anchor:hover, .rv-anchor:focus { outline: 2px solid var(--rv-box-edge); outline-offset: 1px; }
96
+ .rv-anchor.rv-pinned { border-bottom-color: var(--rv-pin); background: transparent; box-shadow: inset 0 -2px 0 var(--rv-pin); }
97
+ .rv-intrinsic { color: var(--rv-accent); text-decoration: none; border-bottom: 1px dotted var(--rv-accent); }
98
+ .rv-diagram { margin: 16px 0; text-align: center; }
99
+ .rv-diagram svg { max-width: 100%; height: auto; }
100
+ /* Graphviz emits black strokes and fills; recolour from tokens so diagrams follow the theme. */
101
+ .rv-svg text { fill: var(--rv-fg); }
102
+ .rv-svg .node polygon, .rv-svg .node ellipse, .rv-svg .node path, .rv-svg .edge path { stroke: var(--rv-fg); }
103
+ .rv-svg .edge polygon { fill: var(--rv-fg); stroke: var(--rv-fg); }
104
+ .rv-svg .node.rv-svg-anchor { cursor: pointer; }
105
+ .rv-svg .node.rv-node-active polygon, .rv-svg .node.rv-node-active ellipse { stroke: var(--rv-box-edge); stroke-width: 2.5; fill: var(--rv-box); }
106
+ .rv-diagram figcaption { font-size: 13px; color: var(--rv-muted); }
107
+
108
+ /* Media pane */
109
+ .rv-media { border-top: 1px solid var(--rv-line); background: var(--rv-bg-2); padding: 8px 12px; height: var(--rv-media-h); display: grid; grid-template-rows: auto minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) auto; row-gap: 6px; column-gap: 0; }
110
+ .rv-media[hidden] { display: none; }
111
+ .rv-media audio, .rv-media video { width: 100%; max-height: 120px; grid-row: 1; grid-column: 1; }
112
+ /* Firefox sizes the player's grid row from a 150px default height, not its 40px controls (#45); 54px is Chromium's, so only Firefox changes. */
113
+ .rv-media audio { max-height: 54px; }
114
+ .rv-media-toggle { grid-row: 1; grid-column: 2; align-self: start; margin-left: 6px; border: 1px solid var(--rv-line); background: var(--rv-bg); color: var(--rv-fg); border-radius: 6px; padding: 4px 8px; cursor: pointer; font: 13px/1.4 var(--rv-font); }
115
+ .rv-media-toggle:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 1px; }
116
+ .rv-media-toggle[hidden] { display: none; }
117
+ .rv-media-toggle[aria-expanded="false"] .rv-media-chev { display: inline-block; transform: rotate(-90deg); }
118
+ .rv-transcript { margin: 0; padding: 0 0 0 20px; overflow: auto; font-size: 14px; grid-row: 2; grid-column: 1 / -1; }
119
+ .rv-seg { cursor: pointer; padding: 2px 4px; border-radius: 4px; color: var(--rv-muted); }
120
+ .rv-seg:hover, .rv-seg:focus { background: var(--rv-bg); outline: none; }
121
+ .rv-seg.rv-active { color: var(--rv-fg); background: var(--rv-box); }
122
+ .rv-media-error { color: var(--rv-muted); }
123
+ /* Media failed to load: message first, transcript scrolls below it, pane shrinks to content up to --rv-media-h. */
124
+ .rv-media.rv-media-failed { height: auto; max-height: var(--rv-media-h); }
125
+ .rv-media-failed .rv-media-error { grid-row: 1; grid-column: 1; margin: 0; }
126
+ /* Collapsed by the reader: only the toggle remains, as a full-width strip; the lesson takes the freed height. */
127
+ .rv-media.rv-media-collapsed { height: auto; max-height: none; padding: 0; border-top-width: 0; display: block; }
128
+ .rv-media-collapsed > :not(.rv-media-toggle) { display: none; }
129
+ .rv-media-collapsed .rv-media-toggle { display: block; width: 100%; margin: 0; border-radius: 0; border: 0; border-top: 1px solid var(--rv-line); text-align: left; }
130
+
131
+ /* Quiz */
132
+ .rv-quiz { margin-top: 28px; padding: 14px 16px; border: 1px solid var(--rv-line); border-radius: 8px; }
133
+ .rv-quiz h2 { margin: 0 0 8px; font-size: 18px; }
134
+ .rv-q { border: 0; padding: 0; margin: 0 0 12px; }
135
+ .rv-q legend { font-weight: 600; padding: 0; }
136
+ .rv-q label { display: block; margin: 2px 0; }
137
+ .rv-q-feedback { margin: 4px 0 0; font-size: 14px; }
138
+ .rv-q.rv-correct .rv-q-feedback { color: #1a7f37; }
139
+ .rv-q.rv-incorrect .rv-q-feedback { color: #cf222e; }
140
+ .rv-quiz button { border: 1px solid var(--rv-line); background: var(--rv-bg-2); color: var(--rv-fg); border-radius: 6px; padding: 6px 12px; cursor: pointer; font: inherit; }
141
+ .rv-quiz-score { font-weight: 600; }
142
+
143
+ /* Narrow layout: stack panes, rail becomes a top bar */
144
+ @media (max-width: 900px) {
145
+ body.rv { grid-template-columns: 1fr; grid-template-rows: var(--rv-rail-w) auto minmax(0, 1fr); height: 100vh; }
146
+ .rv-rail { grid-column: 1; grid-row: 1; flex-direction: row; border-right: 0; border-bottom: 1px solid var(--rv-line); padding: 0 6px; }
147
+ .rv-panel { grid-column: 1; grid-row: 2; border-right: 0; border-bottom: 1px solid var(--rv-line); max-height: 40vh; }
148
+ .rv-main { grid-column: 1; grid-row: 3; grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) 6px minmax(0, 1fr); height: auto; min-height: 0; }
149
+ .rv-code-pane { border-right: 0; border-bottom: 1px solid var(--rv-line); }
150
+ .rv-splitter { cursor: row-resize; }
151
+ .rv-right { height: auto; }
152
+ .rv-lesson { padding: 16px; }
153
+ /* The media pane shares .rv-right with the lesson: cap it at half, so the lesson keeps at least as much (#13).
154
+ Guarded because a var() inside min() defeats the cascade fallback: an engine without cq units would make the height auto and grow the pane to fit the transcript. */
155
+ @supports (height: 1cqh) {
156
+ .rv-right { container-type: size; }
157
+ .rv-media { height: min(var(--rv-media-h), 50cqh); }
158
+ .rv-media.rv-media-failed { max-height: min(var(--rv-media-h), 50cqh); }
159
+ }
160
+ }