keyframes 0.1.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.
- keyframes/__init__.py +1 -0
- keyframes/build.py +102 -0
- keyframes/check.py +107 -0
- keyframes/cli.py +149 -0
- keyframes/deck.py +67 -0
- keyframes/export.py +106 -0
- keyframes/guide/__init__.py +25 -0
- keyframes/guide/components/keyframes-code.md +60 -0
- keyframes/guide/components/keyframes-excalidraw.md +148 -0
- keyframes/guide/components/keyframes-mujoco.md +76 -0
- keyframes/guide/components/keyframes-slider.md +65 -0
- keyframes/guide/components/keyframes-three.md +81 -0
- keyframes/guide/core.md +113 -0
- keyframes/patch.py +77 -0
- keyframes/render.py +32 -0
- keyframes/scaffold/.gitignore +3 -0
- keyframes/scaffold/CLAUDE.md +1 -0
- keyframes/scaffold/assets/deck.css +1 -0
- keyframes/scaffold/assets/figure.svg +1 -0
- keyframes/scaffold/deck.toml +10 -0
- keyframes/scaffold/references/.gitkeep +0 -0
- keyframes/scaffold/slides/01-title.html +4 -0
- keyframes/scaffold/slides/02-intro.html +17 -0
- keyframes/server.py +204 -0
- keyframes/static/components/keyframes-code.js +423 -0
- keyframes/static/components/keyframes-excalidraw.js +189 -0
- keyframes/static/components/keyframes-mujoco.js +515 -0
- keyframes/static/components/keyframes-slider.js +255 -0
- keyframes/static/components/keyframes-three.js +224 -0
- keyframes/static/editor/editor.css +228 -0
- keyframes/static/editor/editor.js +594 -0
- keyframes/static/editor/panel.css +127 -0
- keyframes/static/editor/panel.js +357 -0
- keyframes/static/index.html +23 -0
- keyframes/static/runtime/component.js +126 -0
- keyframes/static/runtime/live.js +31 -0
- keyframes/static/runtime/math.js +33 -0
- keyframes/static/runtime/presenter.js +91 -0
- keyframes/static/runtime/runtime.css +91 -0
- keyframes/static/runtime/runtime.js +215 -0
- keyframes/static/runtime/steps.js +15 -0
- keyframes/terminal.py +144 -0
- keyframes/themes/default/STYLE_GUIDE.md +73 -0
- keyframes/themes/default/assets/arc-top.webp +0 -0
- keyframes/themes/default/assets/orbit-ring.webp +0 -0
- keyframes/themes/default/assets/streaks-top.webp +0 -0
- keyframes/themes/default/assets/streaks-wide.webp +0 -0
- keyframes/themes/default/assets/sun-top.webp +0 -0
- keyframes/themes/default/assets/title-orbit.webp +0 -0
- keyframes/themes/default/layouts/content.html +16 -0
- keyframes/themes/default/layouts/figure.html +7 -0
- keyframes/themes/default/layouts/grid-2.html +19 -0
- keyframes/themes/default/layouts/grid-3x2.html +28 -0
- keyframes/themes/default/layouts/grid-4.html +44 -0
- keyframes/themes/default/layouts/media-side.html +16 -0
- keyframes/themes/default/layouts/section-arc.html +4 -0
- keyframes/themes/default/layouts/section-streaks.html +4 -0
- keyframes/themes/default/layouts/section.html +4 -0
- keyframes/themes/default/layouts/split-wide.html +7 -0
- keyframes/themes/default/layouts/split.html +13 -0
- keyframes/themes/default/layouts/statement.html +4 -0
- keyframes/themes/default/layouts/title.html +5 -0
- keyframes/themes/default/layouts/two-column.html +5 -0
- keyframes/themes/default/theme.css +330 -0
- keyframes/themes/minimal/STYLE_GUIDE.md +41 -0
- keyframes/themes/minimal/layouts/figure.html +7 -0
- keyframes/themes/minimal/layouts/section.html +4 -0
- keyframes/themes/minimal/layouts/statement.html +3 -0
- keyframes/themes/minimal/layouts/title-body.html +10 -0
- keyframes/themes/minimal/layouts/title.html +5 -0
- keyframes/themes/minimal/layouts/two-column.html +11 -0
- keyframes/themes/minimal/theme.css +196 -0
- keyframes/vendor.py +181 -0
- keyframes-0.1.0.dist-info/METADATA +119 -0
- keyframes-0.1.0.dist-info/RECORD +77 -0
- keyframes-0.1.0.dist-info/WHEEL +4 -0
- keyframes-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { maxStep } from "./steps.js";
|
|
2
|
+
|
|
3
|
+
const STYLE = `
|
|
4
|
+
body { background: #1b1c1f; color: #e8e8e8; font: 16px/1.45 system-ui, sans-serif; overflow: hidden; }
|
|
5
|
+
.keyframes-deck { display: none; }
|
|
6
|
+
.pv { display: grid; grid-template-columns: 1.7fr 1fr; grid-template-rows: auto 1fr; gap: 16px;
|
|
7
|
+
height: 100vh; padding: 16px; box-sizing: border-box; }
|
|
8
|
+
.pv-bar { grid-column: 1 / -1; display: flex; gap: 24px; align-items: baseline; font-variant-numeric: tabular-nums; }
|
|
9
|
+
.pv-bar b { font-size: 28px; }
|
|
10
|
+
.pv-bar button { background: #333; color: inherit; border: 0; border-radius: 6px; padding: 4px 10px; cursor: pointer; }
|
|
11
|
+
.pv-frame { position: relative; aspect-ratio: var(--ar); background: #000; border-radius: 8px; overflow: hidden; }
|
|
12
|
+
.pv-frame iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
|
13
|
+
.pv-side { display: flex; flex-direction: column; gap: 16px; min-height: 0; }
|
|
14
|
+
.pv-label { color: #999; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 4px; }
|
|
15
|
+
.pv-notes { flex: 1; overflow: auto; font-size: 20px; background: #26272b; border-radius: 8px; padding: 16px; }
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
export function startPresenter(deck) {
|
|
19
|
+
const { width, height } = deck.config;
|
|
20
|
+
const slides = deck.slides;
|
|
21
|
+
const state = { index: 0, step: 0, started: Date.now() };
|
|
22
|
+
|
|
23
|
+
document.head.insertAdjacentHTML("beforeend", `<style>${STYLE}</style>`);
|
|
24
|
+
document.title = `Presenter · ${document.title}`;
|
|
25
|
+
document.body.insertAdjacentHTML(
|
|
26
|
+
"beforeend",
|
|
27
|
+
`<div class="pv" style="--ar:${width}/${height}">
|
|
28
|
+
<div class="pv-bar"><b class="pv-count"></b><b class="pv-timer">00:00</b>
|
|
29
|
+
<button class="pv-reset">reset timer</button><span class="pv-steps"></span></div>
|
|
30
|
+
<div><div class="pv-label">Current</div><div class="pv-frame"><iframe class="pv-current"></iframe></div></div>
|
|
31
|
+
<div class="pv-side">
|
|
32
|
+
<div><div class="pv-label">Next</div><div class="pv-frame"><iframe class="pv-next"></iframe></div></div>
|
|
33
|
+
<div class="pv-label">Notes</div><div class="pv-notes"></div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>`,
|
|
36
|
+
);
|
|
37
|
+
const $ = (s) => document.querySelector(s);
|
|
38
|
+
const current = $(".pv-current");
|
|
39
|
+
const next = $(".pv-next");
|
|
40
|
+
current.src = `?embed${location.hash}`;
|
|
41
|
+
next.src = "?embed&static";
|
|
42
|
+
|
|
43
|
+
const send = (frame, index, step) =>
|
|
44
|
+
frame.contentWindow?.postMessage({ type: "keyframes:goto", index, step }, "*");
|
|
45
|
+
|
|
46
|
+
function render() {
|
|
47
|
+
const slide = slides[state.index];
|
|
48
|
+
const steps = maxStep(slide);
|
|
49
|
+
const [nextIndex, nextStep] =
|
|
50
|
+
state.step < steps ? [state.index, state.step + 1] : [state.index + 1, 0];
|
|
51
|
+
send(current, state.index, state.step);
|
|
52
|
+
send(next, Math.min(nextIndex, slides.length - 1), nextStep);
|
|
53
|
+
$(".pv-count").textContent = `${state.index + 1} / ${slides.length}`;
|
|
54
|
+
$(".pv-steps").textContent = steps ? `build ${state.step}/${steps}` : "";
|
|
55
|
+
$(".pv-notes").innerHTML = slide.querySelector("aside.notes")?.innerHTML || "<i>No notes</i>";
|
|
56
|
+
history.replaceState(null, "", `?presenter#/${state.index + 1}/${state.step}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function goTo(index, step, broadcast = true) {
|
|
60
|
+
state.index = Math.max(0, Math.min(index, slides.length - 1));
|
|
61
|
+
state.step = Math.max(0, Math.min(step, maxStep(slides[state.index])));
|
|
62
|
+
render();
|
|
63
|
+
if (broadcast) deck.channel.postMessage({ type: "nav", index: state.index, step: state.step });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function advance(delta) {
|
|
67
|
+
const steps = maxStep(slides[state.index]);
|
|
68
|
+
if (delta > 0) {
|
|
69
|
+
if (state.step < steps) goTo(state.index, state.step + 1);
|
|
70
|
+
else goTo(state.index + 1, 0);
|
|
71
|
+
} else if (state.step > 0) goTo(state.index, state.step - 1);
|
|
72
|
+
else if (state.index > 0) goTo(state.index - 1, maxStep(slides[state.index - 1]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
deck.channel.onmessage = ({ data }) => data.type === "nav" && goTo(data.index, data.step, false);
|
|
76
|
+
document.addEventListener("keydown", (e) => {
|
|
77
|
+
if (["ArrowRight", "ArrowDown", "PageDown", " "].includes(e.key)) advance(1);
|
|
78
|
+
else if (["ArrowLeft", "ArrowUp", "PageUp"].includes(e.key)) advance(-1);
|
|
79
|
+
else return;
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
});
|
|
82
|
+
$(".pv-reset").onclick = () => (state.started = Date.now());
|
|
83
|
+
setInterval(() => {
|
|
84
|
+
const s = Math.floor((Date.now() - state.started) / 1000);
|
|
85
|
+
$(".pv-timer").textContent = `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
|
86
|
+
}, 500);
|
|
87
|
+
|
|
88
|
+
window.addEventListener("message", ({ data }) => data?.type === "keyframes:ready" && render());
|
|
89
|
+
const [, n, s] = location.hash.match(/^#\/(\d+)(?:\/(\d+))?/) || [];
|
|
90
|
+
goTo(n ? Number(n) - 1 : 0, s ? Number(s) : 0, false);
|
|
91
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
html, body {
|
|
2
|
+
margin: 0;
|
|
3
|
+
height: 100%;
|
|
4
|
+
overflow: hidden;
|
|
5
|
+
background: #111;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.keyframes-deck {
|
|
9
|
+
position: fixed;
|
|
10
|
+
inset: 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.keyframes-slide {
|
|
14
|
+
position: absolute;
|
|
15
|
+
left: 50%;
|
|
16
|
+
top: 50%;
|
|
17
|
+
width: var(--keyframes-w);
|
|
18
|
+
height: var(--keyframes-h);
|
|
19
|
+
transform: translate(-50%, -50%) scale(var(--keyframes-scale, 1));
|
|
20
|
+
visibility: hidden;
|
|
21
|
+
pointer-events: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.keyframes-slide.is-current {
|
|
25
|
+
visibility: visible;
|
|
26
|
+
pointer-events: auto;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.keyframes-slide > section {
|
|
30
|
+
position: relative;
|
|
31
|
+
width: 100%;
|
|
32
|
+
height: 100%;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
box-sizing: border-box;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[data-block] {
|
|
38
|
+
box-sizing: border-box;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
[data-block][data-x] {
|
|
42
|
+
position: absolute;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[data-step] {
|
|
46
|
+
transition: opacity 0.35s ease;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.keyframes-step-hidden {
|
|
50
|
+
opacity: 0 !important;
|
|
51
|
+
visibility: hidden;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
aside.notes {
|
|
55
|
+
display: none !important;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.keyframes-blackout .keyframes-slide.is-current {
|
|
59
|
+
visibility: hidden;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.keyframes-error {
|
|
63
|
+
position: absolute;
|
|
64
|
+
inset: 0;
|
|
65
|
+
overflow: auto;
|
|
66
|
+
padding: 16px;
|
|
67
|
+
background: #fff0f0;
|
|
68
|
+
color: #a00;
|
|
69
|
+
font: 14px/1.4 ui-monospace, Menlo, monospace;
|
|
70
|
+
white-space: pre-wrap;
|
|
71
|
+
border: 2px solid #e55;
|
|
72
|
+
box-sizing: border-box;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
html.keyframes-print, html.keyframes-print body {
|
|
76
|
+
background: transparent;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
html.keyframes-print .keyframes-slide {
|
|
80
|
+
left: 0;
|
|
81
|
+
top: 0;
|
|
82
|
+
transform: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
html.keyframes-print [data-step] {
|
|
86
|
+
transition: none;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@page {
|
|
90
|
+
margin: 0;
|
|
91
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { renderMath } from "./math.js";
|
|
2
|
+
import { applySteps, maxStep } from "./steps.js";
|
|
3
|
+
|
|
4
|
+
const config = window.KEYFRAMES_CONFIG;
|
|
5
|
+
const params = new URLSearchParams(location.search);
|
|
6
|
+
const KEEP_ALIVE_DISTANCE = 2;
|
|
7
|
+
const SLIDE_SELECTOR = ".keyframes-slide";
|
|
8
|
+
|
|
9
|
+
export function applyGeometry(root) {
|
|
10
|
+
for (const el of root.querySelectorAll("[data-block]")) {
|
|
11
|
+
const { x, y, w, h, z } = el.dataset;
|
|
12
|
+
el.style.left = x != null ? `${x}px` : "";
|
|
13
|
+
el.style.top = y != null ? `${y}px` : "";
|
|
14
|
+
el.style.width = w != null ? `${w}px` : "";
|
|
15
|
+
el.style.height = h != null ? `${h}px` : "";
|
|
16
|
+
el.style.zIndex = z ?? "";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function componentsIn(root) {
|
|
21
|
+
return [...root.querySelectorAll("*")].filter((el) => el.localName.startsWith("keyframes-"));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const requestedComponents = new Set();
|
|
25
|
+
|
|
26
|
+
async function loadComponents(root) {
|
|
27
|
+
const pending = [];
|
|
28
|
+
for (const el of componentsIn(root)) {
|
|
29
|
+
const tag = el.localName;
|
|
30
|
+
if (customElements.get(tag) || requestedComponents.has(tag)) continue;
|
|
31
|
+
requestedComponents.add(tag);
|
|
32
|
+
pending.push(
|
|
33
|
+
import(`../components/${tag}.js`).catch((e) => console.error(`Failed to load <${tag}>`, e)),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
await Promise.all(pending);
|
|
37
|
+
await Promise.all(
|
|
38
|
+
[...new Set(componentsIn(root).map((el) => el.localName))].map((t) =>
|
|
39
|
+
customElements.whenDefined(t),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class Deck extends EventTarget {
|
|
45
|
+
constructor() {
|
|
46
|
+
super();
|
|
47
|
+
this.config = config;
|
|
48
|
+
this.index = 0;
|
|
49
|
+
this.step = 0;
|
|
50
|
+
this.scale = 1;
|
|
51
|
+
this.revealAll = false;
|
|
52
|
+
this.channel = new BroadcastChannel(`keyframes:${location.pathname}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get slides() {
|
|
56
|
+
return [...document.querySelectorAll(SLIDE_SELECTOR)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get current() {
|
|
60
|
+
return this.slides[this.index];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async init() {
|
|
64
|
+
document.documentElement.style.setProperty("--keyframes-w", `${config.width}px`);
|
|
65
|
+
document.documentElement.style.setProperty("--keyframes-h", `${config.height}px`);
|
|
66
|
+
for (const slide of this.slides) await this.prepareSlide(slide);
|
|
67
|
+
if (params.has("print")) return this.initPrint(Number(params.get("print")) || 1);
|
|
68
|
+
this.fit();
|
|
69
|
+
new ResizeObserver(() => this.fit()).observe(document.querySelector(".keyframes-deck"));
|
|
70
|
+
window.addEventListener("hashchange", () => this.goToHash());
|
|
71
|
+
if (!params.has("embed")) document.addEventListener("keydown", (e) => this.onKey(e));
|
|
72
|
+
this.channel.onmessage = ({ data }) => {
|
|
73
|
+
if (data.type === "nav") this.goTo(data.index, data.step, { broadcast: false });
|
|
74
|
+
};
|
|
75
|
+
window.addEventListener("message", ({ data }) => {
|
|
76
|
+
if (data?.type === "keyframes:goto") this.goTo(data.index, data.step, { broadcast: false });
|
|
77
|
+
});
|
|
78
|
+
this.goToHash();
|
|
79
|
+
if (params.has("embed")) parent.postMessage({ type: "keyframes:ready" }, "*");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async prepareSlide(slide) {
|
|
83
|
+
applyGeometry(slide);
|
|
84
|
+
slide.dataset.number = String(this.slides.indexOf(slide) + 1);
|
|
85
|
+
await Promise.all([renderMath(slide), loadComponents(slide)]);
|
|
86
|
+
await Promise.all(componentsIn(slide).map((el) => el.keyframesPrepare?.()));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fit() {
|
|
90
|
+
const area = document.querySelector(".keyframes-deck");
|
|
91
|
+
this.scale = Math.min(area.clientWidth / config.width, area.clientHeight / config.height);
|
|
92
|
+
document.documentElement.style.setProperty("--keyframes-scale", this.scale);
|
|
93
|
+
this.dispatchEvent(new CustomEvent("scale", { detail: this.scale }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
goToHash() {
|
|
97
|
+
const [, n, s] = location.hash.match(/^#\/(\d+)(?:\/(\d+))?/) || [];
|
|
98
|
+
this.goTo(n ? Number(n) - 1 : 0, s ? Number(s) : 0, { broadcast: false });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
goTo(index, step = 0, { broadcast = true } = {}) {
|
|
102
|
+
const slides = this.slides;
|
|
103
|
+
index = Math.max(0, Math.min(index, slides.length - 1));
|
|
104
|
+
const previous = this.current;
|
|
105
|
+
const changedSlide = previous !== slides[index] || !previous?.classList.contains("is-current");
|
|
106
|
+
this.index = index;
|
|
107
|
+
this.step = Math.max(0, Math.min(step, maxStep(slides[index])));
|
|
108
|
+
|
|
109
|
+
slides.forEach((slide, i) => slide.classList.toggle("is-current", i === index));
|
|
110
|
+
applySteps(this.current, this.revealAll ? Infinity : this.step);
|
|
111
|
+
if (changedSlide) this.updateLifecycle(previous);
|
|
112
|
+
for (const el of componentsIn(this.current)) el.keyframesStep?.(this.step);
|
|
113
|
+
|
|
114
|
+
const hash = `#/${index + 1}${this.step ? `/${this.step}` : ""}`;
|
|
115
|
+
if (location.hash !== hash) history.replaceState(null, "", hash);
|
|
116
|
+
if (broadcast) this.channel.postMessage({ type: "nav", index, step: this.step });
|
|
117
|
+
this.dispatchEvent(new CustomEvent("change", { detail: { index, step: this.step } }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
updateLifecycle(previous) {
|
|
121
|
+
if (params.has("static")) return;
|
|
122
|
+
if (previous && previous !== this.current) {
|
|
123
|
+
for (const el of componentsIn(previous)) el.keyframesStop?.();
|
|
124
|
+
for (const video of previous.querySelectorAll("video")) video.pause();
|
|
125
|
+
}
|
|
126
|
+
this.slides.forEach((slide, i) => {
|
|
127
|
+
if (Math.abs(i - this.index) > KEEP_ALIVE_DISTANCE) {
|
|
128
|
+
for (const el of componentsIn(slide)) el.keyframesDispose?.();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
for (const el of componentsIn(this.current)) el.keyframesStart?.();
|
|
132
|
+
for (const video of this.current.querySelectorAll("video[autoplay]")) {
|
|
133
|
+
video.currentTime = 0;
|
|
134
|
+
video.play().catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
next() {
|
|
139
|
+
if (this.step < maxStep(this.current)) this.goTo(this.index, this.step + 1);
|
|
140
|
+
else if (this.index < this.slides.length - 1) this.goTo(this.index + 1, 0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
prev() {
|
|
144
|
+
if (this.step > 0) this.goTo(this.index, this.step - 1);
|
|
145
|
+
else if (this.index > 0) this.goTo(this.index - 1, maxStep(this.slides[this.index - 1]));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
onKey(e) {
|
|
149
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
150
|
+
if (e.target.closest?.("input, textarea, [contenteditable], .cm-editor, keyframes-code, keyframes-slider")) return;
|
|
151
|
+
const actions = {
|
|
152
|
+
ArrowRight: () => this.next(),
|
|
153
|
+
ArrowDown: () => this.next(),
|
|
154
|
+
PageDown: () => this.next(),
|
|
155
|
+
" ": () => this.next(),
|
|
156
|
+
ArrowLeft: () => this.prev(),
|
|
157
|
+
ArrowUp: () => this.prev(),
|
|
158
|
+
PageUp: () => this.prev(),
|
|
159
|
+
Home: () => this.goTo(0),
|
|
160
|
+
End: () => this.goTo(this.slides.length - 1),
|
|
161
|
+
f: () => document.documentElement.requestFullscreen?.(),
|
|
162
|
+
b: () => document.body.classList.toggle("keyframes-blackout"),
|
|
163
|
+
s: () => window.open(`?presenter${location.hash}`, "keyframes-presenter", "width=1400,height=850"),
|
|
164
|
+
};
|
|
165
|
+
const action = actions[e.key];
|
|
166
|
+
if (action) {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
action();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async replaceSlide(name, html) {
|
|
173
|
+
const old = this.slides.find((s) => s.dataset.slide === name);
|
|
174
|
+
if (!old) return location.reload();
|
|
175
|
+
const template = document.createElement("template");
|
|
176
|
+
template.innerHTML = html;
|
|
177
|
+
const fresh = template.content.firstElementChild;
|
|
178
|
+
old.replaceWith(fresh);
|
|
179
|
+
await this.prepareSlide(fresh);
|
|
180
|
+
this.goTo(this.index, this.step, { broadcast: false });
|
|
181
|
+
this.dispatchEvent(new CustomEvent("slidereplaced", { detail: { name, slide: fresh } }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async initPrint(number) {
|
|
185
|
+
document.documentElement.classList.add("keyframes-print");
|
|
186
|
+
const style = document.createElement("style");
|
|
187
|
+
style.textContent = `@page { size: ${config.width}px ${config.height}px; }`;
|
|
188
|
+
document.head.append(style);
|
|
189
|
+
this.revealAll = true;
|
|
190
|
+
this.index = number - 1;
|
|
191
|
+
const slide = this.current;
|
|
192
|
+
this.slides.forEach((s) => s !== slide && s.remove());
|
|
193
|
+
slide.classList.add("is-current");
|
|
194
|
+
applySteps(slide, Infinity);
|
|
195
|
+
const components = componentsIn(slide);
|
|
196
|
+
for (const el of components) el.keyframesStep?.(maxStep(slide));
|
|
197
|
+
await Promise.all(components.map((el) => el.keyframesStart?.()));
|
|
198
|
+
await document.fonts.ready;
|
|
199
|
+
await new Promise((r) => setTimeout(r, Number(params.get("settle") || 600)));
|
|
200
|
+
await Promise.all(components.map((el) => el.keyframesSnapshot?.()));
|
|
201
|
+
document.documentElement.dataset.ready = "1";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const deck = new Deck();
|
|
206
|
+
window.keyframes = deck;
|
|
207
|
+
|
|
208
|
+
if (params.has("presenter")) {
|
|
209
|
+
import("./presenter.js").then((m) => m.startPresenter(deck));
|
|
210
|
+
} else {
|
|
211
|
+
deck.init().then(() => {
|
|
212
|
+
document.documentElement.dataset.loaded = "1";
|
|
213
|
+
if (config.mode === "dev") import("./live.js").then((m) => m.connectLive(deck));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function maxStep(slide) {
|
|
2
|
+
let max = 0;
|
|
3
|
+
for (const el of slide.querySelectorAll("[data-step], [data-step-out]")) {
|
|
4
|
+
max = Math.max(max, Number(el.dataset.step || 0), Number(el.dataset.stepOut || 0));
|
|
5
|
+
}
|
|
6
|
+
return max;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function applySteps(slide, step) {
|
|
10
|
+
for (const el of slide.querySelectorAll("[data-step], [data-step-out]")) {
|
|
11
|
+
const shownAt = Number(el.dataset.step || 0);
|
|
12
|
+
const hidden = el.dataset.stepOut != null && step >= Number(el.dataset.stepOut);
|
|
13
|
+
el.classList.toggle("keyframes-step-hidden", step < shownAt || hidden);
|
|
14
|
+
}
|
|
15
|
+
}
|
keyframes/terminal.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""PTY-backed terminal sessions (claude, codex, shell) streamed to the editor panel.
|
|
2
|
+
|
|
3
|
+
Sessions outlive browser connections, so page reloads reattach and replay recent output."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import codecs
|
|
7
|
+
import fcntl
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import struct
|
|
13
|
+
import subprocess
|
|
14
|
+
import termios
|
|
15
|
+
|
|
16
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
17
|
+
|
|
18
|
+
SCROLLBACK_CHARS = 400_000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def available_commands() -> dict[str, list[str]]:
|
|
22
|
+
commands = {name: [path] for name in ("claude", "codex") if (path := shutil.which(name))}
|
|
23
|
+
commands["shell"] = [os.environ.get("SHELL", "/bin/bash"), "-l"]
|
|
24
|
+
return commands
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _become_controlling_tty():
|
|
28
|
+
os.setsid()
|
|
29
|
+
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TerminalSession:
|
|
33
|
+
def __init__(self, argv: list[str], cwd: str):
|
|
34
|
+
self.argv = argv
|
|
35
|
+
self.cwd = cwd
|
|
36
|
+
self.clients: set[WebSocket] = set()
|
|
37
|
+
self.scrollback = ""
|
|
38
|
+
self.process: subprocess.Popen | None = None
|
|
39
|
+
self.fd: int | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def running(self) -> bool:
|
|
43
|
+
return self.process is not None and self.process.poll() is None
|
|
44
|
+
|
|
45
|
+
def start(self, cols: int = 100, rows: int = 30):
|
|
46
|
+
master, slave = os.openpty()
|
|
47
|
+
self._set_size(master, cols, rows)
|
|
48
|
+
env = {**os.environ, "TERM": "xterm-256color", "COLORTERM": "truecolor"}
|
|
49
|
+
self.process = subprocess.Popen(
|
|
50
|
+
self.argv,
|
|
51
|
+
cwd=self.cwd,
|
|
52
|
+
env=env,
|
|
53
|
+
stdin=slave,
|
|
54
|
+
stdout=slave,
|
|
55
|
+
stderr=slave,
|
|
56
|
+
preexec_fn=_become_controlling_tty,
|
|
57
|
+
close_fds=True,
|
|
58
|
+
)
|
|
59
|
+
os.close(slave)
|
|
60
|
+
os.set_blocking(master, False)
|
|
61
|
+
self.fd = master
|
|
62
|
+
self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
63
|
+
asyncio.get_running_loop().add_reader(master, self._on_output)
|
|
64
|
+
|
|
65
|
+
def _on_output(self):
|
|
66
|
+
try:
|
|
67
|
+
data = os.read(self.fd, 65536)
|
|
68
|
+
except OSError:
|
|
69
|
+
data = b""
|
|
70
|
+
if not data:
|
|
71
|
+
self._close_fd()
|
|
72
|
+
self._broadcast({"type": "exit"})
|
|
73
|
+
return
|
|
74
|
+
text = self.decoder.decode(data)
|
|
75
|
+
self.scrollback = (self.scrollback + text)[-SCROLLBACK_CHARS:]
|
|
76
|
+
self._broadcast({"type": "output", "data": text})
|
|
77
|
+
|
|
78
|
+
def _broadcast(self, message: dict):
|
|
79
|
+
payload = json.dumps(message)
|
|
80
|
+
for ws in list(self.clients):
|
|
81
|
+
asyncio.ensure_future(self._send(ws, payload))
|
|
82
|
+
|
|
83
|
+
async def _send(self, ws: WebSocket, payload: str):
|
|
84
|
+
try:
|
|
85
|
+
await ws.send_text(payload)
|
|
86
|
+
except Exception:
|
|
87
|
+
self.clients.discard(ws)
|
|
88
|
+
|
|
89
|
+
def write(self, data: str):
|
|
90
|
+
if self.fd is not None:
|
|
91
|
+
os.write(self.fd, data.encode())
|
|
92
|
+
|
|
93
|
+
def resize(self, cols: int, rows: int):
|
|
94
|
+
if self.fd is not None:
|
|
95
|
+
self._set_size(self.fd, cols, rows)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _set_size(fd: int, cols: int, rows: int):
|
|
99
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
100
|
+
|
|
101
|
+
def _close_fd(self):
|
|
102
|
+
if self.fd is not None:
|
|
103
|
+
asyncio.get_running_loop().remove_reader(self.fd)
|
|
104
|
+
os.close(self.fd)
|
|
105
|
+
self.fd = None
|
|
106
|
+
|
|
107
|
+
def stop(self):
|
|
108
|
+
if self.running:
|
|
109
|
+
os.killpg(self.process.pid, signal.SIGHUP)
|
|
110
|
+
self._close_fd()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TerminalManager:
|
|
114
|
+
def __init__(self, cwd: str):
|
|
115
|
+
self.cwd = cwd
|
|
116
|
+
self.commands = available_commands()
|
|
117
|
+
self.sessions: dict[str, TerminalSession] = {}
|
|
118
|
+
|
|
119
|
+
async def serve(self, ws: WebSocket, name: str):
|
|
120
|
+
if name not in self.commands:
|
|
121
|
+
await ws.close(code=4404)
|
|
122
|
+
return
|
|
123
|
+
session = self.sessions.setdefault(name, TerminalSession(self.commands[name], self.cwd))
|
|
124
|
+
await ws.accept()
|
|
125
|
+
session.clients.add(ws)
|
|
126
|
+
await ws.send_text(json.dumps({"type": "replay", "data": session.scrollback}))
|
|
127
|
+
if not session.running:
|
|
128
|
+
await ws.send_text(json.dumps({"type": "exit"}))
|
|
129
|
+
try:
|
|
130
|
+
while True:
|
|
131
|
+
message = json.loads(await ws.receive_text())
|
|
132
|
+
if message["type"] == "input":
|
|
133
|
+
session.write(message["data"])
|
|
134
|
+
elif message["type"] == "resize":
|
|
135
|
+
session.resize(message["cols"], message["rows"])
|
|
136
|
+
elif message["type"] == "start" and not session.running:
|
|
137
|
+
session.scrollback = ""
|
|
138
|
+
session.start(message.get("cols", 100), message.get("rows", 30))
|
|
139
|
+
except WebSocketDisconnect:
|
|
140
|
+
session.clients.discard(ws)
|
|
141
|
+
|
|
142
|
+
def stop_all(self):
|
|
143
|
+
for session in self.sessions.values():
|
|
144
|
+
session.stop()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Theme: default (Google "simple-light" clone)
|
|
2
|
+
|
|
3
|
+
Light, quiet research-talk style: lots of white space, small bold titles top-left, grey light-weight
|
|
4
|
+
body text, Google-colored decorative artwork only on title and section slides.
|
|
5
|
+
|
|
6
|
+
## Type
|
|
7
|
+
|
|
8
|
+
| class | font | size | color | use |
|
|
9
|
+
| --- | --- | --- | --- | --- |
|
|
10
|
+
| `headline` | Google Sans 700 | 80px | `--ink` #202124 | title-slide and section-slide titles |
|
|
11
|
+
| `title` | Google Sans 700 | 53px | `--ink` | slide titles, top-left, ≤ 2 lines |
|
|
12
|
+
| `subtitle` | Google Sans 400 | 48px | `--fg` #606366 | title-slide subtitle |
|
|
13
|
+
| `byline` | Google Sans 400 | 37px | `--fg` | speaker / affiliation / date on the title slide |
|
|
14
|
+
| `body` | Roboto Light | 37px | `--fg` | lead-in sentences and main points |
|
|
15
|
+
| `lead` | Roboto Light | 28px | `--ink` | one-sentence takeaway or setup next to media |
|
|
16
|
+
| `subhead` | Google Sans 500 | 28px | `--ink` | column / tile heading (`<h3 class="subhead">`) |
|
|
17
|
+
| `small` | Roboto Light | 24px | `--fg` | detail bullets under a lead-in (`<ul class="small">`) |
|
|
18
|
+
| `statement` | Roboto Light | 128px | `--fg` | one centered idea per slide |
|
|
19
|
+
| `caption` | Google Sans 400 | 24px | `--fg` | figure labels |
|
|
20
|
+
| `cite` | Google Sans 400 | 19px | `--fg` | references footer, `Author et al. Venue Year · …` |
|
|
21
|
+
| `eyebrow` | Google Sans 400 | 24px | `--fg` | one-liner under a section title |
|
|
22
|
+
|
|
23
|
+
The template's signature body pattern is a 37px lead-in line followed by 24px detail bullets
|
|
24
|
+
(grey `●` markers), repeated in groups, often ending with one 64px takeaway line with
|
|
25
|
+
**bold** keywords: `<p style="font-size:64px">Idea: Learn a <b>set</b> of skills</p>`.
|
|
26
|
+
Use `<i>` for term names inside bullets ("*Exploration* - …"). Bare `<ul>` renders at 37px: use it
|
|
27
|
+
only for a few top-level points without a lead-in.
|
|
28
|
+
|
|
29
|
+
Tables: plain `<table>` with `<th>` headers (24px, hairline rows). Media blocks: `media contain`
|
|
30
|
+
letterboxes any child (img, video, component); `media figure` adds a white background for plots on
|
|
31
|
+
the grey split panel.
|
|
32
|
+
|
|
33
|
+
Text boxes include Google Slides' default insets (18px top, 19px sides), so the block position is
|
|
34
|
+
the box, not the glyphs: titles sit at x=88, y=72.
|
|
35
|
+
|
|
36
|
+
## Color
|
|
37
|
+
|
|
38
|
+
Text is grey (`--fg`), titles near-black (`--ink`), links teal (`--link` #0097a7). Accents are the
|
|
39
|
+
Google palette — `--blue` #4285f4, `--red` #ea4335, `--yellow` #fbbc04, `--green` #34a853 — for
|
|
40
|
+
plots, diagrams and highlights; never for body text. `--panel` #f8f9fa is the light grey used for
|
|
41
|
+
section backgrounds and split-slide panels.
|
|
42
|
+
|
|
43
|
+
## Layouts (`data-layout` on the `<section>`)
|
|
44
|
+
|
|
45
|
+
| layout | look | use |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `title` | white, blue orbit artwork on the right | first slide |
|
|
48
|
+
| `title-ring`, `title-streaks` | white, alternate artwork | alternate title / closing slide |
|
|
49
|
+
| `section` | grey, yellow sun artwork top-left | "Part I: …" dividers |
|
|
50
|
+
| `section-arc`, `section-streaks` | grey, alternate artwork | further parts, "Extra Slides" |
|
|
51
|
+
| `content` | white | default for everything else |
|
|
52
|
+
| `split` | right half grey panel (x ≥ 960) | text left (w=800), figure/video/sim right |
|
|
53
|
+
| `split-wide` | grey panel from x=610 | narrow text left (w=469), large media right |
|
|
54
|
+
|
|
55
|
+
Title and section layouts hide the slide number; all others show it bottom-right.
|
|
56
|
+
|
|
57
|
+
Media templates (all `data-layout="content"`, see `keyframes guide`): `figure` one large figure,
|
|
58
|
+
`grid-2` two media with captions and a bullet strip, `grid-3x2` six clips with captions, `grid-4`
|
|
59
|
+
four columns of media + subhead + bullets, `media-side` large media with a narrow text/table column.
|
|
60
|
+
|
|
61
|
+
## Geometry
|
|
62
|
+
|
|
63
|
+
- Content titles: x=88 y=72 w=1663 h=104 (two-line titles need h≈170; push the body down).
|
|
64
|
+
- Box height ≈ line-height × lines + 28 (insets): `headline` 96px/line (two lines → 220), `title`
|
|
65
|
+
64px/line.
|
|
66
|
+
- Content body: x=88 y=175 w=1663 h=748. Bottom of content ≤ 960; slide number lives at y≈1002.
|
|
67
|
+
- `split`: text column x=88 w=800; media inside the panel, e.g. x=1010 y=92 w=860 h=896.
|
|
68
|
+
- Title slide: headline x=220 y=193 w=735; subtitle y=465; byline y=698 (y=465 without a subtitle).
|
|
69
|
+
- Section: headline x=220 y=401 w=1471; eyebrow y=609.
|
|
70
|
+
- Media blocks have no inset: put them at x=107 (the text glyph edge) and end at x≤1813. Media top
|
|
71
|
+
at y≈175–190; its caption block 5–10px below the media bottom, x−19 and w+38 so the text aligns.
|
|
72
|
+
- Cite block: x=88 y=975 w=1660 h=60 (two lines: y=962 h=80).
|
|
73
|
+
- Figures are plain (no border, no radius, no shadow), usually with a 24px caption.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<section data-layout="content">
|
|
2
|
+
<h2 class="title" data-block="title" data-x="88" data-y="72" data-w="1663" data-h="104">Slide Title</h2>
|
|
3
|
+
<div class="body" data-block="body" data-x="88" data-y="175" data-w="1663" data-h="748">
|
|
4
|
+
Lead-in sentence:
|
|
5
|
+
<ul class="small">
|
|
6
|
+
<li>Supporting point</li>
|
|
7
|
+
<li data-step="1">Revealed on click</li>
|
|
8
|
+
</ul>
|
|
9
|
+
Second group:
|
|
10
|
+
<ol>
|
|
11
|
+
<li>Numbered item</li>
|
|
12
|
+
</ol>
|
|
13
|
+
</div>
|
|
14
|
+
<p class="cite" data-block="cite" data-x="88" data-y="975" data-w="1660" data-h="60">Author et al. Venue Year · Author et al. Venue Year</p>
|
|
15
|
+
<aside class="notes">Speaker notes.</aside>
|
|
16
|
+
</section>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<section data-layout="content">
|
|
2
|
+
<h2 class="title" data-block="title" data-x="88" data-y="72" data-w="1663" data-h="104">One-sentence claim the figure supports</h2>
|
|
3
|
+
<div class="media" data-block="figure" data-x="107" data-y="180" data-w="1706" data-h="730">
|
|
4
|
+
<img class="contain" src="assets/figure.png" alt="">
|
|
5
|
+
</div>
|
|
6
|
+
<p class="caption" data-block="caption" data-x="88" data-y="917" data-w="1744" data-h="60">What the figure shows · provenance, units</p>
|
|
7
|
+
</section>
|