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.
- revision_engine/__init__.py +3 -0
- revision_engine/anchors.py +163 -0
- revision_engine/build.py +268 -0
- revision_engine/cli.py +159 -0
- revision_engine/config.py +159 -0
- revision_engine/diagrams.py +108 -0
- revision_engine/errors.py +18 -0
- revision_engine/highlight.py +62 -0
- revision_engine/intrinsics/__init__.py +34 -0
- revision_engine/intrinsics/arm.yaml +22 -0
- revision_engine/intrinsics/intel.yaml +42 -0
- revision_engine/intrinsics/riscv.yaml +28 -0
- revision_engine/lesson.py +64 -0
- revision_engine/markdown_ext.py +285 -0
- revision_engine/narration.py +276 -0
- revision_engine/render.py +119 -0
- revision_engine/serve.py +153 -0
- revision_engine/sources.py +61 -0
- revision_engine/static/app.js +292 -0
- revision_engine/static/styles.css +160 -0
- revision_engine/templates/lesson.html.j2 +89 -0
- revision_engine/templates/redirect.html.j2 +5 -0
- revision_engine-0.2.2.dist-info/METADATA +134 -0
- revision_engine-0.2.2.dist-info/RECORD +27 -0
- revision_engine-0.2.2.dist-info/WHEEL +4 -0
- revision_engine-0.2.2.dist-info/entry_points.txt +3 -0
- revision_engine-0.2.2.dist-info/licenses/LICENSE +21 -0
revision_engine/serve.py
ADDED
|
@@ -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
|
+
}
|