keyframes 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {keyframes-0.2.0 → keyframes-0.3.0}/PKG-INFO +5 -1
  2. {keyframes-0.2.0 → keyframes-0.3.0}/README.md +4 -0
  3. {keyframes-0.2.0 → keyframes-0.3.0}/pyproject.toml +1 -1
  4. {keyframes-0.2.0 → keyframes-0.3.0}/pyproject.toml.orig +1 -1
  5. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/patch.py +47 -3
  6. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/server.py +12 -1
  7. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/runtime/presenter.js +70 -2
  8. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/__init__.py +0 -0
  9. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/build.py +0 -0
  10. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/check.py +0 -0
  11. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/cli.py +0 -0
  12. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/deck.py +0 -0
  13. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/export.py +0 -0
  14. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/guide/__init__.py +0 -0
  15. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/guide/components/keyframes-code.md +0 -0
  16. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/guide/components/keyframes-excalidraw.md +0 -0
  17. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/guide/components/keyframes-mujoco.md +0 -0
  18. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/guide/components/keyframes-slider.md +0 -0
  19. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/guide/components/keyframes-three.md +0 -0
  20. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/guide/core.md +0 -0
  21. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/render.py +0 -0
  22. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/scaffold/.gitignore +0 -0
  23. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/scaffold/CLAUDE.md +0 -0
  24. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/scaffold/assets/deck.css +0 -0
  25. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/scaffold/assets/figure.svg +0 -0
  26. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/scaffold/deck.toml +0 -0
  27. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/scaffold/references/.gitkeep +0 -0
  28. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/scaffold/slides/01-title.html +0 -0
  29. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/scaffold/slides/02-intro.html +0 -0
  30. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/components/keyframes-code.js +0 -0
  31. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/components/keyframes-excalidraw.js +0 -0
  32. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/components/keyframes-mujoco.js +0 -0
  33. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/components/keyframes-slider.js +0 -0
  34. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/components/keyframes-three.js +0 -0
  35. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/editor/editor.css +0 -0
  36. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/editor/editor.js +0 -0
  37. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/editor/panel.css +0 -0
  38. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/editor/panel.js +0 -0
  39. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/index.html +0 -0
  40. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/runtime/component.js +0 -0
  41. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/runtime/controls.js +0 -0
  42. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/runtime/live.js +0 -0
  43. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/runtime/math.js +0 -0
  44. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/runtime/runtime.css +0 -0
  45. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/runtime/runtime.js +0 -0
  46. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/static/runtime/steps.js +0 -0
  47. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/terminal.py +0 -0
  48. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/STYLE_GUIDE.md +0 -0
  49. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/assets/arc-top.webp +0 -0
  50. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/assets/orbit-ring.webp +0 -0
  51. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/assets/streaks-top.webp +0 -0
  52. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/assets/streaks-wide.webp +0 -0
  53. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/assets/sun-top.webp +0 -0
  54. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/assets/title-orbit.webp +0 -0
  55. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/content.html +0 -0
  56. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/figure.html +0 -0
  57. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/grid-2.html +0 -0
  58. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/grid-3x2.html +0 -0
  59. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/grid-4.html +0 -0
  60. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/media-side.html +0 -0
  61. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/section-arc.html +0 -0
  62. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/section-streaks.html +0 -0
  63. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/section.html +0 -0
  64. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/split-wide.html +0 -0
  65. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/split.html +0 -0
  66. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/statement.html +0 -0
  67. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/title.html +0 -0
  68. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/layouts/two-column.html +0 -0
  69. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/default/theme.css +0 -0
  70. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/minimal/STYLE_GUIDE.md +0 -0
  71. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/minimal/layouts/figure.html +0 -0
  72. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/minimal/layouts/section.html +0 -0
  73. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/minimal/layouts/statement.html +0 -0
  74. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/minimal/layouts/title-body.html +0 -0
  75. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/minimal/layouts/title.html +0 -0
  76. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/minimal/layouts/two-column.html +0 -0
  77. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/themes/minimal/theme.css +0 -0
  78. {keyframes-0.2.0 → keyframes-0.3.0}/src/keyframes/vendor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: keyframes
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Agent-written, interactive HTML slide decks with live 3D, physics, math, code and diagrams
5
5
  Keywords: slides,presentation,llm,agents,three.js,mujoco,katex
6
6
  Author: Pranay Thangeda
@@ -60,6 +60,10 @@ For exports install the browser once: `uv run playwright install chromium` (or h
60
60
  Hover the bottom-left corner for the `⋯` options menu (laser pointer, fullscreen, black
61
61
  screen, presenter view).
62
62
 
63
+ Speaker notes live in each slide as `<aside class="notes">…</aside>`. Under `keyframes dev`
64
+ you can also double-click the notes in presenter view to edit them; ⌘/Ctrl+Enter or clicking
65
+ away saves straight into the slide file, Esc cancels.
66
+
63
67
  Editor: drag to move (Shift locks axis, Alt disables snapping), handles resize (Shift keeps
64
68
  aspect), arrows nudge (Shift ×10), `[`/`]` z-order, double-click to interact with a live
65
69
  embed, `C` copies a block reference like `slides/04-results.html#sim` for your next prompt,
@@ -39,6 +39,10 @@ For exports install the browser once: `uv run playwright install chromium` (or h
39
39
  Hover the bottom-left corner for the `⋯` options menu (laser pointer, fullscreen, black
40
40
  screen, presenter view).
41
41
 
42
+ Speaker notes live in each slide as `<aside class="notes">…</aside>`. Under `keyframes dev`
43
+ you can also double-click the notes in presenter view to edit them; ⌘/Ctrl+Enter or clicking
44
+ away saves straight into the slide file, Esc cancels.
45
+
42
46
  Editor: drag to move (Shift locks axis, Alt disables snapping), handles resize (Shift keeps
43
47
  aspect), arrows nudge (Shift ×10), `[`/`]` z-order, double-click to interact with a live
44
48
  embed, `C` copies a block reference like `slides/04-results.html#sim` for your next prompt,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "keyframes"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Agent-written, interactive HTML slide decks with live 3D, physics, math, code and diagrams"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "keyframes"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Agent-written, interactive HTML slide decks with live 3D, physics, math, code and diagrams"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -16,12 +16,21 @@ class BlockTag:
16
16
  attrs: dict[str, str | None]
17
17
 
18
18
 
19
- class _BlockFinder(HTMLParser):
19
+ class _OffsetParser(HTMLParser):
20
20
  def __init__(self, source: str):
21
21
  super().__init__(convert_charrefs=False)
22
22
  self.line_offsets = [0]
23
23
  for line in source.split("\n"):
24
24
  self.line_offsets.append(self.line_offsets[-1] + len(line) + 1)
25
+
26
+ def source_offset(self) -> int:
27
+ line, col = self.getpos()
28
+ return self.line_offsets[line - 1] + col
29
+
30
+
31
+ class _BlockFinder(_OffsetParser):
32
+ def __init__(self, source: str):
33
+ super().__init__(source)
25
34
  self.blocks: list[BlockTag] = []
26
35
 
27
36
  def handle_starttag(self, tag, attrs):
@@ -29,14 +38,34 @@ class _BlockFinder(HTMLParser):
29
38
  block_id = attr_map.get("data-block")
30
39
  if block_id is None:
31
40
  return
32
- line, col = self.getpos()
33
- start = self.line_offsets[line - 1] + col
41
+ start = self.source_offset()
34
42
  text = self.get_starttag_text()
35
43
  self.blocks.append(BlockTag(block_id, start, start + len(text), text, attr_map))
36
44
 
37
45
  handle_startendtag = handle_starttag
38
46
 
39
47
 
48
+ class _NotesFinder(_OffsetParser):
49
+ """Locates the inner content of the first <aside class="notes"> and the last </section>."""
50
+
51
+ def __init__(self, source: str):
52
+ super().__init__(source)
53
+ self.content: tuple[int, int] | None = None
54
+ self.content_start: int | None = None
55
+ self.section_end: int | None = None
56
+
57
+ def handle_starttag(self, tag, attrs):
58
+ classes = (dict(attrs).get("class") or "").split()
59
+ if tag == "aside" and "notes" in classes and self.content_start is None:
60
+ self.content_start = self.source_offset() + len(self.get_starttag_text())
61
+
62
+ def handle_endtag(self, tag):
63
+ if tag == "aside" and self.content_start is not None and self.content is None:
64
+ self.content = (self.content_start, self.source_offset())
65
+ elif tag == "section":
66
+ self.section_end = self.source_offset()
67
+
68
+
40
69
  def find_block_tags(source: str) -> list[BlockTag]:
41
70
  finder = _BlockFinder(source)
42
71
  finder.feed(source)
@@ -75,3 +104,18 @@ def patch_blocks(source: str, updates: dict[str, dict[str, str | None]]) -> str:
75
104
  new_text = set_attrs(tag.text, updates[tag.id])
76
105
  source = source[: tag.start] + new_text + source[tag.end :]
77
106
  return source
107
+
108
+
109
+ def set_notes(source: str, notes: str) -> str:
110
+ """Replace the speaker notes' inner HTML, adding an <aside class="notes"> if the slide has none."""
111
+ finder = _NotesFinder(source)
112
+ finder.feed(source)
113
+ finder.close()
114
+ if finder.content:
115
+ start, end = finder.content
116
+ return source[:start] + notes + source[end:]
117
+ if not notes.strip():
118
+ return source
119
+ aside = f' <aside class="notes">{notes}</aside>\n'
120
+ end = finder.section_end if finder.section_end is not None else len(source)
121
+ return source[:end] + aside + source[end:]
@@ -16,7 +16,7 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
16
16
  from watchfiles import Change, awatch
17
17
 
18
18
  from .deck import STATIC_DIR, Deck, DeckError
19
- from .patch import geometry_attrs, patch_blocks
19
+ from .patch import geometry_attrs, patch_blocks, set_notes
20
20
  from .render import render_index, render_slide
21
21
  from .terminal import TerminalManager
22
22
  from .vendor import VendorCache
@@ -132,6 +132,16 @@ def create_app(deck: Deck, mode: str = "dev") -> Starlette:
132
132
  return JSONResponse({"ok": True})
133
133
  return Response(path.read_text(), media_type="text/plain", headers={"Cache-Control": "no-store"})
134
134
 
135
+ async def save_notes(request: Request):
136
+ if not same_origin(request.headers):
137
+ return JSONResponse({"error": "cross-origin request"}, status_code=403)
138
+ try:
139
+ path = deck.slide_path(request.path_params["slide"])
140
+ except DeckError as e:
141
+ return JSONResponse({"error": str(e)}, status_code=404)
142
+ live.write(path, set_notes(path.read_text(), (await request.body()).decode()))
143
+ return JSONResponse({"ok": True})
144
+
135
145
  async def info(request: Request):
136
146
  return JSONResponse({"terminals": list(terminals.commands)})
137
147
 
@@ -162,6 +172,7 @@ def create_app(deck: Deck, mode: str = "dev") -> Starlette:
162
172
  dev_routes = [
163
173
  Route("/_api/geometry", save_geometry, methods=["POST"]),
164
174
  Route("/_api/source/{slide}", source, methods=["GET", "PUT"]),
175
+ Route("/_api/notes/{slide}", save_notes, methods=["PUT"]),
165
176
  Route("/_api/info", info),
166
177
  WebSocketRoute("/_api/live", websocket),
167
178
  WebSocketRoute("/_api/term/{name}", terminal),
@@ -17,6 +17,8 @@ const STYLE = `
17
17
  .pv-side { display: flex; flex-direction: column; gap: 16px; min-height: 0; }
18
18
  .pv-label { color: #999; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 4px; }
19
19
  .pv-notes { flex: 1; overflow: auto; font-size: 20px; background: #26272b; border-radius: 8px; padding: 16px; }
20
+ .pv-notes[contenteditable="true"] { outline: 2px solid #4c8dff; }
21
+ .pv-notes-status { margin-left: 8px; color: #777; text-transform: none; letter-spacing: 0; }
20
22
  .pv-close { margin-left: auto; }
21
23
  .pv-strip { grid-column: 1 / -1; display: flex; gap: 12px; overflow-x: auto; padding: 2px 2px 8px; }
22
24
  .pv-thumb { position: relative; flex: none; height: ${THUMB_HEIGHT}px; aspect-ratio: var(--ar); padding: 0; border: 0;
@@ -48,7 +50,7 @@ export function startPresenter(deck) {
48
50
  <div><div class="pv-label">Current</div><div class="pv-frame"><iframe class="pv-current"></iframe></div></div>
49
51
  <div class="pv-side">
50
52
  <div><div class="pv-label">Next</div><div class="pv-frame"><iframe class="pv-next"></iframe></div></div>
51
- <div class="pv-label">Notes</div><div class="pv-notes"></div>
53
+ <div class="pv-label">Notes<span class="pv-notes-status"></span></div><div class="pv-notes"></div>
52
54
  </div>
53
55
  <div class="pv-strip" style="--thumb-scale:${THUMB_HEIGHT / height}"></div>
54
56
  </div>`,
@@ -59,6 +61,7 @@ export function startPresenter(deck) {
59
61
  current.src = `?embed${location.hash}`;
60
62
  next.src = "?embed&static";
61
63
  const thumbs = buildStrip($(".pv-strip"), slides, (index) => goTo(index, 0));
64
+ const notes = createNotes($(".pv-notes"), $(".pv-notes-status"), deck.config.mode === "dev");
62
65
 
63
66
  const send = (frame, index, step) =>
64
67
  frame.contentWindow?.postMessage({ type: "keyframes:goto", index, step }, "*");
@@ -72,7 +75,7 @@ export function startPresenter(deck) {
72
75
  send(next, Math.min(nextIndex, slides.length - 1), nextStep);
73
76
  $(".pv-count").textContent = `${state.index + 1} / ${slides.length}`;
74
77
  $(".pv-steps").textContent = steps ? `build ${state.step}/${steps}` : "";
75
- $(".pv-notes").innerHTML = slide.querySelector("aside.notes")?.innerHTML || "<i>No notes</i>";
78
+ notes.show(slide);
76
79
  thumbs.forEach((thumb, i) => thumb.classList.toggle("is-current", i === state.index));
77
80
  thumbs[state.index].scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
78
81
  history.replaceState(null, "", `?presenter#/${state.index + 1}/${state.step}`);
@@ -96,6 +99,7 @@ export function startPresenter(deck) {
96
99
 
97
100
  deck.channel.onmessage = ({ data }) => data.type === "nav" && goTo(data.index, data.step, false);
98
101
  document.addEventListener("keydown", (e) => {
102
+ if (e.target.isContentEditable) return;
99
103
  if (["ArrowRight", "ArrowDown", "PageDown", " "].includes(e.key)) advance(1);
100
104
  else if (["ArrowLeft", "ArrowUp", "PageUp"].includes(e.key)) advance(-1);
101
105
  else return;
@@ -132,3 +136,67 @@ function buildStrip(strip, slides, onPick) {
132
136
  return thumb;
133
137
  });
134
138
  }
139
+
140
+ function createNotes(el, status, editable) {
141
+ const hint = editable ? "double-click to edit" : "";
142
+ const notesOf = (slide) => slide.querySelector("aside.notes");
143
+ let shown = null;
144
+ let editing = null;
145
+ status.textContent = hint;
146
+
147
+ function show(slide) {
148
+ shown = slide;
149
+ if (!editing) el.innerHTML = notesOf(slide)?.innerHTML || "<i>No notes</i>";
150
+ }
151
+
152
+ function start() {
153
+ if (editing) return;
154
+ editing = shown;
155
+ el.innerHTML = notesOf(editing)?.innerHTML ?? "";
156
+ el.contentEditable = "true";
157
+ document.execCommand("defaultParagraphSeparator", false, "p");
158
+ el.focus();
159
+ status.textContent = "editing · ⌘/Ctrl+Enter to save · Esc to cancel";
160
+ }
161
+
162
+ async function finish(save) {
163
+ if (!editing) return;
164
+ const slide = editing;
165
+ const html = el.textContent.trim() ? el.innerHTML.trim() : "";
166
+ editing = null;
167
+ el.contentEditable = "false";
168
+ status.textContent = hint;
169
+ if (save && html !== (notesOf(slide)?.innerHTML.trim() ?? "")) await persist(slide, html);
170
+ show(shown);
171
+ }
172
+
173
+ async function persist(slide, html) {
174
+ const url = `_api/notes/${encodeURIComponent(slide.dataset.slide)}`;
175
+ const response = await fetch(url, { method: "PUT", body: html }).catch(() => null);
176
+ if (!response?.ok) {
177
+ status.textContent = "save failed";
178
+ return;
179
+ }
180
+ let aside = notesOf(slide);
181
+ if (!aside) {
182
+ aside = document.createElement("aside");
183
+ aside.className = "notes";
184
+ (slide.querySelector("section") ?? slide).append(aside);
185
+ }
186
+ aside.innerHTML = html;
187
+ status.textContent = "saved";
188
+ setTimeout(() => !editing && (status.textContent = hint), 2000);
189
+ }
190
+
191
+ if (editable) {
192
+ el.addEventListener("dblclick", start);
193
+ el.addEventListener("blur", () => finish(true));
194
+ el.addEventListener("keydown", (e) => {
195
+ if (e.key === "Escape") finish(false);
196
+ else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) finish(true);
197
+ else return;
198
+ e.preventDefault();
199
+ });
200
+ }
201
+ return { show };
202
+ }